qss 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/CHANGELOG.md CHANGED
@@ -3,124 +3,165 @@
3
3
  All notable changes to the qss gem. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
4
4
  versions follow [Semantic Versioning](https://semver.org/) (before 1.0, minor versions may break the API).
5
5
 
6
- ## [0.6.0] - 2026-10-22
7
-
8
- Never published as 0.4.3 or 0.5.0; both numbers were used for this same work before it shipped.
9
-
10
- ### Added
11
- - `qss-text-size-linear[N]` (N 1 to 96): text that scales in pure proportion to the viewport, `N * 100vw / 1440`, with no
12
- floor, no cap and no dampening, for cosmetic text (wordmarks, titles) that should resize like a picture. It uses the
13
- same unit as `qss-icon-size[N]`, so it tracks the viewport width, not the canvas. Body copy should keep using
14
- `qss-text-size[N]`. The Ruby auditor's below-11 legibility check only reads `qss-text-size[N]`, so it does not apply here.
6
+ Each entry below is written from that release's own vantage point — what problem prompted it, what was tried and
7
+ discarded along the way, and what shipped instead — rather than edited later with knowledge of what came after. A
8
+ rejected approach stays rejected in the entry that rejected it, even where a later entry finds a different answer
9
+ to the same question. For the same story told forward in time, with the full unabridged reasoning, see
10
+ [docs/DESIGN_HISTORY.md](docs/DESIGN_HISTORY.md); this file stays to the point a release note needs.
11
+
12
+ ## Origins (before 0.1.0)
13
+
14
+ QSS began inside Parapet, a Rails development tool, after a recurring failure: asked to move a sidebar 10px left,
15
+ an AI coding agent would edit a margin, then an `order`, then a `flex-basis` — and something else on the page
16
+ would move. It would report the change as done while the page was visibly broken. The diagnosis mattered: this
17
+ wasn't agents being bad at arithmetic (a single explicit value is easy to edit correctly), it was *ambiguous
18
+ intent-to-mechanism mapping* — "left" is not one property under flex or grid, so the agent has to guess which of
19
+ several plausible ones to touch, and the guess is where it goes wrong. The fix that followed: keep every
20
+ structural value explicit, single-purpose and directly addressable, so an edit always has exactly one target.
21
+
22
+ The first attempt at that fix was a custom bracket grammar (`pos-x[500]`, `w[240]`) with its own JavaScript parser
23
+ and a whitelist auditor. It didn't hold up: the hand-maintained parser was the actual bug farm (a regex one comma
24
+ could defeat, a whitelist that drifted between client and server), an invented syntax cost agents an unfamiliarity
25
+ tax they didn't pay with plain CSS, and "verified" by the tool that made the change turned out not to mean
26
+ verified at all — repeatedly, a change shipped green with the auditor and tests passing while the page didn't
27
+ actually work, and only a real browser ever caught it. The rebuild that followed kept the structural ideas that
28
+ had held up (one flat canvas, frames that isolate their own layout, overlays routed to a portal instead of a
29
+ `z-index` war, no `!important`) and moved everything else to plain CSS custom properties and real browser
30
+ verification. Extracted as this gem once that rebuild stabilized.
15
31
 
16
- ### Changed
17
- - The `qss-text-size[N]` floor drops from a flat 9px to 5px (`text_scale.floor_px`). With 9px, every N from 1 to 10
18
- computed `N * 0.875` below the floor, so `[5]`, `[7]` and `[10]` all rendered at exactly 9px. Now `[5]` floors at 5px
19
- and `[7]` and up scale with the canvas (`N * 0.875` is the floor from N = 6), so small sizes are distinct. The cap and
20
- `floor_multiplier` are unchanged. A small size on a narrow canvas can now render smaller than before; the auditor's
21
- legibility check (below 11, `qss-legibility-override` to waive) is unchanged.
22
-
23
- ## [0.4.2] - 2026-10-20
32
+ ## [0.1.0] - 2026-09-25
24
33
 
25
- ### Added
26
- - A dimension can be declared by an `#id { ... }` rule in a configured tool or host stylesheet, not only by a size
27
- class. The implicit-dimension check (red for a `qss` actor, orange for other structural elements) now also reads the
28
- actor's own id from `tool_stylesheets`, `host_stylesheets` and `stylesheet_path`: `width`/`inline-size` declares the
29
- width, `height`/`block-size` the height, `left`+`right` (or `inset`, `inset-inline`) pin the width, `top`+`bottom`
30
- (or `inset`, `inset-block`) pin the height. This is how an actor anchors to another actor's resizable dimension with
31
- plain custom properties (`right: var(--bar-w)`), which the bracket classes cannot express. Only unconditional rules
32
- count: a rule inside `@media`/`@supports`, a nested rule, or a compound selector (`div#x`, `#x:hover`, `#x.on`)
33
- declares the size only sometimes and is ignored, as is a value that sizes nothing (`auto`, `fit-content`, ...).
34
+ First release, extracted from the Parapet project.
34
35
 
35
- ## [0.4.1] - 2026-10-20
36
+ The canvas unit (`--qnt`, canvas width / 1000) had spent its whole history being *set by JavaScript* after
37
+ measuring the page — until the day before this release, when it moved to pure CSS (`0.1cqw`, a container query
38
+ unit, registered as a length so it resolves once at the canvas and inherits unchanged). That one change is why the
39
+ compiled stylesheet works with no JavaScript engine at all: previously, the base actor reset outranked every
40
+ compiled utility on CSS specificity, and utilities had only ever worked because the runtime engine re-emitted them
41
+ to win the fight. A render check (headless Chrome, CSS alone, no engine) exists specifically so that fact can
42
+ never go unnoticed again. Also fixed on the way here: an actor's scroll area silently growing to its content's
43
+ full height because nothing required a declared height (now an auditor rule), and the vocabulary settling on
44
+ noun/adjective/attribute (an id is the noun; classes are adjectives; `qss-function`/`qss-relation` are attributes).
36
45
 
37
46
  ### Added
38
- - `QSS::Error`, the base of every failure QSS raises on purpose: `ConfigurationError`, `IntegrityError` (and so
39
- `CoreIntegrityError`, `ToolIntegrityError`). One `rescue QSS::Error` covers all of them; each means the audit cannot
40
- run as configured, not that a view has a finding. `ConfigurationError` is not an `IntegrityError`.
47
+ - Core stylesheet (`qss/qss_core.scss`, compiled `qss/qss_core.css`): utilities on a 1000-unit canvas grid; the unit
48
+ (`--qnt`, canvas width / 1000) computed by CSS alone (`0.1cqw` of the canvas; the viewport width outside one).
49
+ - Base reset for QSS actors at the canvas id's weight only, so every core utility applies without the JS engine.
50
+ - Neutral `!default` SCSS settings (`qss/_qss_defaults.scss`); a project sets its own before importing core.
51
+ - Optional runtime engine (`qss/qss-engine.js`): rules for uncovered bracket classes, measured element edges.
52
+ - `QSS::QssNativeAuditor`: id grammar (`{qualifier}-{baseObject}`, `QSSObject` custom types) with a per-project
53
+ registry, `qss-function` / `qss-relation` attributes, explicit dimensions for every actor, unit rules (no raw CSS
54
+ lengths on QSS structure; rem or % only in foreign content), `!important`, z-index and inline-style checks.
55
+ - `QSS::QssAuditor`: the older class-whitelist auditor.
56
+ - `QSS.configure(root:, config:)`: a project's settings read at runtime; paths resolve against its root.
57
+ - `QSS::Blueprints` (`QSS.grid`, `QSS.object`): layout arithmetic and object blueprints.
58
+ - `QSS::Engine`: Rails engine for the asset pipeline, loaded only when Rails is present.
59
+ - `qss-sync`: writes a project's SCSS settings (`--core` regenerates the gem's own generated sections).
60
+ - `qss-core-render-check`: renders the compiled CSS alone in headless Chrome and checks core utilities.
41
61
 
42
- ### Changed
43
- - A configured stylesheet that is not on disk (`drywall_stylesheet_path`, `stylesheet_path`, or a literal `host_stylesheets` entry)
44
- now raises `QSS::ConfigurationError` naming the file. Before, it loaded as an empty stylesheet, so a Tailwind build that had
45
- not run yet showed up as a large, misleading `unresolved_class` count. A glob that matches nothing is still just empty.
62
+ ## [0.1.1] - 2026-10-05
46
63
 
47
- ## [0.4.0] - 2026-10-19
64
+ ### Fixed
65
+ - `contain-layout` and `qss-contain-layout` now have a rule (`contain: layout`) in `qss_core.css`. The README
66
+ documents the class for structural frames and the native auditor mandates it, but the stylesheet never defined it,
67
+ so a frame that satisfied the auditor received no containment. Anyone already using the class will now get layout
68
+ containment on those frames: check that absolutely and fixed-positioned descendants still position as expected.
69
+ Covered by a spec on the compiled stylesheet and by three checks in the browser render check (21 in total).
48
70
 
49
- ### Added
50
- - Three-tier stylesheet audit. The auditor now resolves classes against up to three tiers, and tiers 1 and 2 are
51
- integrity-checked and otherwise silent (they surface only as an integrity failure):
52
- 1. **Core**: the gem's own `qss_core.css`, always known, never configured. `rake compile` marks it DO NOT EDIT and
53
- writes `config/integrity.json` (its SHA-256, line endings normalized, plus the gem version); the auditor
54
- verifies it before use. A mismatch, a missing file or a manifest from another version raises
55
- `QSS::CoreIntegrityError`.
56
- 2. **Tool**: `auditor.tool_stylesheets`, a dev tool's own compiled chrome (for example Parapet's
57
- `qss_application.css`). The tool seals each file when it builds it with the new `qss-seal` executable (or
58
- `QSS::Integrity.seal`), which writes `<file>.qss-seal`; `--header` also marks the file DO NOT EDIT. An unsealed or
59
- changed file raises `QSS::ToolIntegrityError`.
60
- 3. **Host**: `auditor.host_stylesheets`, the end consumer's own compiled output, as paths or globs relative to the
61
- project root, plus whatever `QSS.configure(host_stylesheets: -> { ... })` returns (evaluated each time the
62
- settings are read, in the host's own process). With nothing configured, a Rails host gets
63
- `app/assets/builds/*.css`.
64
- - `QSS::IntegrityError`, the base of both integrity errors, so a host's CI can rescue and report one class.
71
+ ## [0.1.2] - 2026-10-05
65
72
 
66
73
  ### Changed
67
- - Core's classes are now always known to `unresolved_class`. Before, setting `stylesheet_path` replaced core's
68
- stylesheet, so a consumer that listed only its own file lost every core class. `stylesheet_path` and
69
- `drywall_stylesheet_path` keep working (`stylesheet_path` counts as a host stylesheet). The Tailwind/QSS boundary
70
- check is unchanged and still reads `drywall_stylesheet_path` only.
71
-
72
- ## [0.3.1] - 2026-10-19
73
-
74
- ### Fixed
75
- - The overlay portal root had no base geometry in the compiled stylesheet, so anything portaled into it rendered at
76
- the wrong position and size. Every generated utility is scoped `:is(<canvas>, <portal root>) .class`, which only
77
- ever matches a descendant of a root, never the root itself, so `qss-absolute qss-inset-0` written on the portal
78
- root itself matched nothing; the canvas escapes this through its own unscoped "Root Anchor" rule, and the portal
79
- had no equivalent. Measured in a real browser: the root landed in normal flow after the canvas (zero width and
80
- height, `top` equal to the viewport height), and a `qss-w[640] qss-pos-r[16]` overlay inside it resolved against
81
- that empty box (negative `left`, wrong size). The portal root now gets its own rule by id: `position: fixed;
82
- inset: 0` (a viewport-sized layer; placed after the canvas it paints above it with no `z-index`),
83
- `container-type: inline-size` with `--qnt` resolved on its children exactly as the canvas does (so qnt-sized
84
- overlays measure against the viewport's width, equal to the canvas unit whenever the canvas spans the viewport),
85
- `pointer-events: none` on the root and `auto` on its direct children (a viewport-sized transparent layer must not
86
- swallow clicks meant for the page), and the same actor reset the canvas gives its actors. That reset moved into a
87
- shared mixin (`qss-actor-reset`), so an actor portaled into an overlay is a full Steel Frame actor too.
88
- Classes written on the portal root itself still do nothing; its geometry is built in. The engine's runtime rules
89
- were already unscoped and needed no change.
90
- - New real-browser coverage in `rake render_check` (`qss-core-render-check`, now 35 checks): a portal root carrying
91
- the documented `qss-absolute qss-inset-0` is asserted to compute a fixed, exactly viewport-sized box, and a
92
- qnt-sized overlay inside it to have its real width, height and edge offsets. Run against the 0.3.0 stylesheet, 7 of
93
- those checks fail, reproducing the report. The static auditor cannot catch this class of bug, since statically the
94
- classes on the portal root look fine.
74
+ - Gem metadata only, no code changes: the summary and description now explain why QSS exists (agents and flex/grid
75
+ layout, addressable structure, static auditing and its limits, the origin in Parapet), and the gem links to
76
+ `docs/DESIGN_HISTORY.md` as its documentation. The rubygems.org page reads these from the latest release.
95
77
 
96
- ### Changed
97
- - `config.portal_root` now defaults to `#qss-portal` in `config/grammar.yml` (it was empty), so the shipped stylesheet,
98
- the auditors and the engine agree on one default portal id. The compiled core CSS therefore scopes its utilities to
99
- `#qss-portal` alongside the canvas and carries the portal rule above. Set it to `""` to build without a portal root.
78
+ ## [0.1.3] - 2026-10-17
100
79
 
101
- ## [0.3.0] - 2026-10-18
80
+ A last round of structural loose ends closed before the vocabulary itself became the next focus: a size container
81
+ that cannot depend on its content, a 29-percent-dead-class census, an extreme-viewport blind spot, and whether a
82
+ frame's own overflow should be allowed to paint over its neighbors.
102
83
 
103
84
  ### Added
104
- - `QSSEngine.auditPositioning()`: an opt-in runtime diagnostic (never run by the pulse) that lists every element whose
105
- computed position is `fixed` or `sticky` and that QSS does not own, and logs one console warning per call (pass
106
- `{ warn: false }` to silence it). QSS owns positioning, so the only legitimate holders are a Steel Frame actor
107
- (`qss`, e.g. `qss-fixed`), anything inside the overlay portal root, content inside `foreign`, and an element in the
108
- browser's native top layer (an open modal `<dialog>`, a shown popover). The static auditor cannot see the rest: a
109
- Drywall `fixed`/`sticky` utility on an element QSS does not mark only becomes a position once a stylesheet resolves
110
- it. `QSSEngine.setPortal(selector)` tells it where the portal root is (default `#qss-portal`). Covered by
111
- `rake engine_check`.
85
+ - `QSS::QssNativeAuditor::Dimensions.missing_axes` now recognizes two more ways an axis counts as declared,
86
+ revisiting a 2026-09-24 deferral now that flex and Tailwind's boundary are settled (DESIGN_HISTORY #12):
87
+ two opposite insets on the same axis (`pos-x[N]`+`pos-r[N]` for width, `pos-y[N]`+`pos-b[N]` for height -- both
88
+ edges pinned fixes the size exactly with no width/height class at all), and `flex-1`/`flex-auto` on the flex
89
+ parent's own main axis (resolved from the parent's `.vertical`/`.flex`/`.horizontal` classes; with no flex
90
+ parent, or on the cross axis, it still declares nothing -- never guessed). Takes an optional `parent_classes:`
91
+ keyword, passed by `audit_file` from the actual DOM parent.
92
+ - `QSS::QssNativeAuditor` now checks that every class literally resolves to a rule in the compiled stylesheet, not
93
+ just that its shape matches a known pattern. The 2026-09-21 census found about 29 percent of real class tokens
94
+ resolved to no CSS at all while both auditors reported green; this is the guardrail that finding called for,
95
+ closing the gap. Checks core's own `qss_core.css` by default, or a consumer's own compiled output via the new
96
+ `auditor.stylesheet_path` config key (following the same pattern as `registry_path`) -- a consumer should point
97
+ this at what its markup actually renders against. A handful of classes are deliberately CSS-free behavioral
98
+ flags (`qss`, `foreign`, `qss-legibility-override`, `qss-ergonomic-override`, `qss-payload-override`,
99
+ `qss-dampen-scaling`, `qss-content-driven`) and are exempt. New finding: `unresolved_class` (red), deduplicated
100
+ per distinct class per file. Covered by a new spec file (21 examples) plus a configuration spec for the new key.
112
101
 
113
102
  ### Changed
114
- - The overlay portal root is now recognised by exact id only: the id configured as `config.portal_root`, or
115
- `qss-portal` when none is configured. `config.portal_root` is validated when the settings load (it must be empty or
116
- a simple `#id` selector; anything else raises `QSS::ConfigurationError`, and `qss-sync` aborts before generating
117
- anything). Removed: the 0.2.0 `portal`-behind-the-project-`id_prefix` form (`app-portal`), the implicit
118
- `qss-portal-*` match, and the older `QssAuditor`'s substring match on `portal`. A project that configures
119
- `portal_root` to its real element id is unaffected; only one that relied on the implicit matching with
120
- `portal_root` unset needs to configure it. New `QSS.validate_portal_root!` and `QSS.portal_id?`.
103
+ - `contain-layout`/`qss-contain-layout` now clip paint by default (`contain: layout paint`, was `contain: layout`
104
+ alone), so a frame's overflow can no longer bleed into its neighbours -- `contain: layout` isolates layout math
105
+ but not paint. Anything that needs to visually escape a frame on purpose (tooltips, dropdowns, modals) already
106
+ has to go through the portal (a `z-index` outside it is its own red violation), so this doesn't reopen that
107
+ case. New opt-out for the rare frame that wants decorative overflow (a shadow, a hover scale) without the
108
+ portal: add `qss-paint-override` alongside `contain-layout`/`qss-contain-layout` to restore layout-only
109
+ containment. Covered by two new specs on the compiled stylesheet and, in the browser render check, a real
110
+ `elementFromPoint` hit-test proving an overflowing child is actually clipped (and, with the override, actually
111
+ isn't) -- not just that `getComputedStyle` reports the right `contain` value.
112
+
113
+ ### Fixed
114
+ - `QSS::QssNativeAuditor`'s advice for an unregistered native qualifier (`{qualifier}-{nativeTag}`) no longer
115
+ suggests dropping the qualifier. Doing so turns the id into a bare native tag, which the auditor itself flags as
116
+ a separate red `missing_qualifier` violation -- the old advice traded one red finding for another instead of
117
+ resolving anything. The only advice now given is to register what the qualifier adds to the base object.
118
+ - `--qnt` (the structural unit) is now dampened at the extremes: `clamp(0.39px, 0.1cqw, 3.2px)` instead of plain
119
+ `0.1cqw`/`0.1vw`. Floor is the qnt value at a 390px CSS viewport width (a real narrow-mobile width regardless of
120
+ the panel's physical/FHD+/QHD+ resolution, since `cqw`/`vw` only ever see CSS pixels); cap is the qnt value at
121
+ 3200px, just past a maximized 1440p/27" monitor, so ordinary desktop widths (up to ~2560px) scale exactly as
122
+ before and only genuine ultra-wide/high-resolution canvases or genuine phone-narrow ones are dampened. Past the
123
+ cap, every structural dimension stays fixed regardless of how much wider the canvas gets -- extra width becomes
124
+ more usable space, not bigger borders/gaps/padding. Bounds are configurable (`config.dampen_scale` in
125
+ `grammar.yml`/a consumer's config). Covered by 4 new render-check assertions (25 in total), 2 proving the clamp
126
+ engages at real extreme widths, not just that the unclamped math is right.
127
+ - Responsive percentage utilities (`qss-{breakpoint}:w-pct[N]`, `h-pct`, `pos-x-pct`, `pos-y-pct`, for every
128
+ horizontal and vertical breakpoint) now compile to valid CSS. The generator previously interpolated the raw
129
+ number (`#{$i * 0.1}%`), which Dart Sass renders with a space before the unit (`width: 0.1 %;`), an invalid
130
+ declaration browsers drop; it now uses `percentage()`, same as the non-responsive `w-pct[N]` rule already did.
131
+ These rules were also never scoped to `#qss-canvas` like every other utility in the file (missing the
132
+ `qss-dsl-boost` wrapper), so once valid they would have applied anywhere a host app reused the class name; they
133
+ are now scoped along with everyone else. Covered by two new specs on the compiled stylesheet, confirmed to fail
134
+ against the old output and pass against the fix.
121
135
 
122
136
  ## [0.2.0] - 2026-10-18
123
137
 
138
+ The largest decision in the gem's history so far, and a correction rather than new direction: Kraig's original
139
+ intent for QSS, from before any earlier pivot, was never "QSS replaces CSS" — it was QSS for positioning and
140
+ custom element definitions, a real content framework (Tailwind) for standard form elements and common widgets.
141
+ That division was written into Parapet's own project docs but never actually built; over several pivots, the
142
+ hand-authored bracket vocabulary had quietly absorbed the content-styling job too, without anyone deciding that on
143
+ purpose. A direct coverage audit of the compiled stylesheet made the drift concrete: zero `@keyframes`, zero
144
+ gradients, zero native form-control restyling, zero `aspect-ratio`; the pseudo-class/pseudo-element coverage
145
+ committed to a month earlier had never been built; the "a class must resolve to real CSS" guardrail the
146
+ 29-percent-dead-class census had called for had never been built either; roughly 32,000 responsive rules were
147
+ still silently emitting invalid CSS.
148
+
149
+ The reasoning that settled it: two different problems had been treated as one. The founding QSS problem — "move X
150
+ left 10px" breaking under flex/grid — is ambiguous intent-to-mechanism mapping, and QSS's `--qnt` grid and bracket
151
+ utilities actually solve that; it stays. Gradients, animations, native form-control restyling and full
152
+ pseudo-class coverage are not ambiguous-mechanism problems at all — each has exactly one correct CSS mechanism —
153
+ they're a *breadth* problem, and hand-authoring breadth inside QSS's own grammar is the one move that had broken
154
+ every single time it was tried here (a generator gap affecting 786 elements, the 29-percent dead-class census, the
155
+ still-open 32,000-rule bug, `contain-layout` shipping with no implementing rule at all in 0.1.1). Scaling the
156
+ hand-authored surface up to match the full CSS spec would bet on the exact mechanism that kept failing, without
157
+ first closing the verification gap every one of those postmortems had already called for. So: QSS's core stays
158
+ scoped to structure, positioning and the native auditor's governance; a real, content-scanning Tailwind build is
159
+ adopted as a dependency for everything else, where that coverage already exists, is battle-tested, and is
160
+ something agents already have deep training exposure to — the same unfamiliarity-tax argument that killed the
161
+ original custom DSL, now applying in Tailwind's favor instead. The one piece that had to be built, not assumed, to
162
+ make this a real boundary rather than a convention someone could forget: a native-auditor rule that a QSS-marked
163
+ actor may never carry a plain content-framework utility that sets a structural property.
164
+
124
165
  ### Added
125
166
  - Every Steel Frame actor (class `qss`) is now its own CSS size container (`container-type: inline-size`, a static
126
167
  default in `qss_core.scss`) with a `container-name` derived from its own id, set at runtime by `qss-engine.js`
@@ -178,8 +219,8 @@ Never published as 0.4.3 or 0.5.0; both numbers were used for this same work bef
178
219
  `qss-flex-row`/`qss-flex-col`; `spaced` is `qss-justify-between`; `clickable` is `qss-cursor-pointer`; `smooth` is
179
220
  `qss-transition-all`. Identity/relational markers that aren't adjectives (`qss`, `group`, `foreign`) are
180
221
  unchanged. This restores `QSS-MANIFESTO.md`'s own original grammar (`.qss-pos-x[500]`, `.qss-Button[...]`), which
181
- had drifted during the pivots in `docs/DESIGN_HISTORY.md` into a mix of bare and `qss-` spellings for the same
182
- rule -- a drift one of core's own comments flagged as "prefix decision still open."
222
+ had drifted during earlier pivots into a mix of bare and `qss-` spellings for the same rule -- a drift one of
223
+ core's own comments had flagged as "prefix decision still open."
183
224
  A bare, unprefixed vocabulary this close to a content framework's own utility names (`flex`, `absolute`,
184
225
  `w-full`, ...) is the exact collision the boundary exists to prevent: a framework compiling its own unprefixed
185
226
  utilities couldn't be told apart, in source text, from QSS's own. `qss-engine.js`'s runtime parsing (already
@@ -230,92 +271,221 @@ Never published as 0.4.3 or 0.5.0; both numbers were used for this same work bef
230
271
  whatever text sat between two rules, comment included, as selector text for the rule that followed it. Comments
231
272
  are now stripped before extraction in both methods.
232
273
 
233
- ## [0.1.3] - 2026-10-17
274
+ ## [0.3.0] - 2026-10-18
275
+
276
+ Two follow-ups built the same day 0.2.0 shipped, once a real consumer had started migrating to it. First: 0.2.0
277
+ recognized the overlay portal root three different ways (a configured id, the literal `qss-portal`, or `portal`
278
+ behind the project's own id prefix), and every one of those was a convention an auditor had to guess at — one real
279
+ view had an unrelated `customer-portal` nav flagged by the substring match. Second: the static auditor can see a
280
+ Drywall class that sets `position` only when it resolves against the configured Drywall stylesheet on a frame; a
281
+ plain `fixed`/`sticky` utility sitting on any other element is invisible to a class-shape check, because a class
282
+ name only becomes a position once a stylesheet resolves it — something only the browser actually knows.
234
283
 
235
284
  ### Added
236
- - `QSS::QssNativeAuditor::Dimensions.missing_axes` now recognizes two more ways an axis counts as declared,
237
- revisiting a 2026-09-24 deferral now that flex and Tailwind's boundary are settled (DESIGN_HISTORY #12):
238
- two opposite insets on the same axis (`pos-x[N]`+`pos-r[N]` for width, `pos-y[N]`+`pos-b[N]` for height -- both
239
- edges pinned fixes the size exactly with no width/height class at all), and `flex-1`/`flex-auto` on the flex
240
- parent's own main axis (resolved from the parent's `.vertical`/`.flex`/`.horizontal` classes; with no flex
241
- parent, or on the cross axis, it still declares nothing -- never guessed). Takes an optional `parent_classes:`
242
- keyword, passed by `audit_file` from the actual DOM parent.
243
- - `QSS::QssNativeAuditor` now checks that every class literally resolves to a rule in the compiled stylesheet, not
244
- just that its shape matches a known pattern. The 2026-09-21 census found about 29 percent of real class tokens
245
- resolved to no CSS at all while both auditors reported green; this is the guardrail that finding called for,
246
- closing the gap. Checks core's own `qss_core.css` by default, or a consumer's own compiled output via the new
247
- `auditor.stylesheet_path` config key (following the same pattern as `registry_path`) -- a consumer should point
248
- this at what its markup actually renders against. A handful of classes are deliberately CSS-free behavioral
249
- flags (`qss`, `foreign`, `qss-legibility-override`, `qss-ergonomic-override`, `qss-payload-override`,
250
- `qss-dampen-scaling`, `qss-content-driven`) and are exempt. New finding: `unresolved_class` (red), deduplicated
251
- per distinct class per file. Covered by a new spec file (21 examples) plus a configuration spec for the new key.
285
+ - `QSSEngine.auditPositioning()`: an opt-in runtime diagnostic (never run by the pulse) that lists every element whose
286
+ computed position is `fixed` or `sticky` and that QSS does not own, and logs one console warning per call (pass
287
+ `{ warn: false }` to silence it). QSS owns positioning, so the only legitimate holders are a Steel Frame actor
288
+ (`qss`, e.g. `qss-fixed`), anything inside the overlay portal root, content inside `foreign`, and an element in the
289
+ browser's native top layer (an open modal `<dialog>`, a shown popover). The static auditor cannot see the rest: a
290
+ Drywall `fixed`/`sticky` utility on an element QSS does not mark only becomes a position once a stylesheet resolves
291
+ it. `QSSEngine.setPortal(selector)` tells it where the portal root is (default `#qss-portal`). Covered by
292
+ `rake engine_check`.
252
293
 
253
294
  ### Changed
254
- - `contain-layout`/`qss-contain-layout` now clip paint by default (`contain: layout paint`, was `contain: layout`
255
- alone), so a frame's overflow can no longer bleed into its neighbours -- `contain: layout` isolates layout math
256
- but not paint. Anything that needs to visually escape a frame on purpose (tooltips, dropdowns, modals) already
257
- has to go through the portal (a `z-index` outside it is its own red violation), so this doesn't reopen that
258
- case. New opt-out for the rare frame that wants decorative overflow (a shadow, a hover scale) without the
259
- portal: add `qss-paint-override` alongside `contain-layout`/`qss-contain-layout` to restore layout-only
260
- containment. Covered by two new specs on the compiled stylesheet and, in the browser render check, a real
261
- `elementFromPoint` hit-test proving an overflowing child is actually clipped (and, with the override, actually
262
- isn't) -- not just that `getComputedStyle` reports the right `contain` value.
295
+ - The overlay portal root is now recognised by exact id only: the id configured as `config.portal_root`, or
296
+ `qss-portal` when none is configured. `config.portal_root` is validated when the settings load (it must be empty or
297
+ a simple `#id` selector; anything else raises `QSS::ConfigurationError`, and `qss-sync` aborts before generating
298
+ anything). Removed: the 0.2.0 `portal`-behind-the-project-`id_prefix` form (`app-portal`), the implicit
299
+ `qss-portal-*` match, and the older `QssAuditor`'s substring match on `portal`. A project that configures
300
+ `portal_root` to its real element id is unaffected; only one that relied on the implicit matching with
301
+ `portal_root` unset needs to configure it. New `QSS.validate_portal_root!` and `QSS.portal_id?`.
302
+
303
+ ## [0.3.1] - 2026-10-19
304
+
305
+ The same consumer's migration immediately found a second, worse gap: a portal root configured per 0.3.0's exact-id
306
+ rule had no actual geometry in the compiled stylesheet at all. Every generated utility is scoped
307
+ `:is(<canvas>, <portal root>) .class`, which can only ever match a *descendant* of a root, never the root itself —
308
+ so `qss-absolute qss-inset-0`, written directly on the portal root, matched nothing there. The canvas had always
309
+ escaped this through its own separate, unscoped "Root Anchor" rule; the portal had no equivalent. Neither auditor
310
+ could see it, because statically the classes on the portal root look completely fine — only measuring a real
311
+ browser revealed the root sitting in normal flow after the canvas with zero width and height, and a
312
+ `qss-w[640] qss-pos-r[16]` overlay inside it resolving its position and size against that empty box.
263
313
 
264
314
  ### Fixed
265
- - `QSS::QssNativeAuditor`'s advice for an unregistered native qualifier (`{qualifier}-{nativeTag}`) no longer
266
- suggests dropping the qualifier. Doing so turns the id into a bare native tag, which the auditor itself flags as
267
- a separate red `missing_qualifier` violation -- the old advice traded one red finding for another instead of
268
- resolving anything. The only advice now given is to register what the qualifier adds to the base object.
269
- - `--qnt` (the structural unit) is now dampened at the extremes: `clamp(0.39px, 0.1cqw, 3.2px)` instead of plain
270
- `0.1cqw`/`0.1vw`. Floor is the qnt value at a 390px CSS viewport width (a real narrow-mobile width regardless of
271
- the panel's physical/FHD+/QHD+ resolution, since `cqw`/`vw` only ever see CSS pixels); cap is the qnt value at
272
- 3200px, just past a maximized 1440p/27" monitor, so ordinary desktop widths (up to ~2560px) scale exactly as
273
- before and only genuine ultra-wide/high-resolution canvases or genuine phone-narrow ones are dampened. Past the
274
- cap, every structural dimension stays fixed regardless of how much wider the canvas gets -- extra width becomes
275
- more usable space, not bigger borders/gaps/padding. Bounds are configurable (`config.dampen_scale` in
276
- `grammar.yml`/a consumer's config). Covered by 4 new render-check assertions (25 in total), 2 proving the clamp
277
- engages at real extreme widths, not just that the unclamped math is right.
278
- - Responsive percentage utilities (`qss-{breakpoint}:w-pct[N]`, `h-pct`, `pos-x-pct`, `pos-y-pct`, for every
279
- horizontal and vertical breakpoint) now compile to valid CSS. The generator previously interpolated the raw
280
- number (`#{$i * 0.1}%`), which Dart Sass renders with a space before the unit (`width: 0.1 %;`), an invalid
281
- declaration browsers drop; it now uses `percentage()`, same as the non-responsive `w-pct[N]` rule already did.
282
- These rules were also never scoped to `#qss-canvas` like every other utility in the file (missing the
283
- `qss-dsl-boost` wrapper), so once valid they would have applied anywhere a host app reused the class name; they
284
- are now scoped along with everyone else. Covered by two new specs on the compiled stylesheet, confirmed to fail
285
- against the old output and pass against the fix.
315
+ - The overlay portal root had no base geometry in the compiled stylesheet, so anything portaled into it rendered at
316
+ the wrong position and size. Every generated utility is scoped `:is(<canvas>, <portal root>) .class`, which only
317
+ ever matches a descendant of a root, never the root itself, so `qss-absolute qss-inset-0` written on the portal
318
+ root itself matched nothing; the canvas escapes this through its own unscoped "Root Anchor" rule, and the portal
319
+ had no equivalent. Measured in a real browser: the root landed in normal flow after the canvas (zero width and
320
+ height, `top` equal to the viewport height), and a `qss-w[640] qss-pos-r[16]` overlay inside it resolved against
321
+ that empty box (negative `left`, wrong size). The portal root now gets its own rule by id: `position: fixed;
322
+ inset: 0` (a viewport-sized layer; placed after the canvas it paints above it with no `z-index`),
323
+ `container-type: inline-size` with `--qnt` resolved on its children exactly as the canvas does (so qnt-sized
324
+ overlays measure against the viewport's width, equal to the canvas unit whenever the canvas spans the viewport),
325
+ `pointer-events: none` on the root and `auto` on its direct children (a viewport-sized transparent layer must not
326
+ swallow clicks meant for the page), and the same actor reset the canvas gives its actors. That reset moved into a
327
+ shared mixin (`qss-actor-reset`), so an actor portaled into an overlay is a full Steel Frame actor too.
328
+ Classes written on the portal root itself still do nothing; its geometry is built in. The engine's runtime rules
329
+ were already unscoped and needed no change.
330
+ - New real-browser coverage in `rake render_check` (`qss-core-render-check`, now 35 checks): a portal root carrying
331
+ the documented `qss-absolute qss-inset-0` is asserted to compute a fixed, exactly viewport-sized box, and a
332
+ qnt-sized overlay inside it to have its real width, height and edge offsets. Run against the 0.3.0 stylesheet, 7 of
333
+ those checks fail, reproducing the report. The static auditor cannot catch this class of bug, since statically the
334
+ classes on the portal root look fine.
286
335
 
287
- ## [0.1.2] - 2026-10-05
336
+ ### Changed
337
+ - `config.portal_root` now defaults to `#qss-portal` in `config/grammar.yml` (it was empty), so the shipped stylesheet,
338
+ the auditors and the engine agree on one default portal id. The compiled core CSS therefore scopes its utilities to
339
+ `#qss-portal` alongside the canvas and carries the portal rule above. Set it to `""` to build without a portal root.
340
+
341
+ ## [0.4.0] - 2026-10-19
342
+
343
+ ### Added
344
+ - Three-tier stylesheet audit. The auditor now resolves classes against up to three tiers, and tiers 1 and 2 are
345
+ integrity-checked and otherwise silent (they surface only as an integrity failure):
346
+ 1. **Core**: the gem's own `qss_core.css`, always known, never configured. `rake compile` marks it DO NOT EDIT and
347
+ writes `config/integrity.json` (its SHA-256, line endings normalized, plus the gem version); the auditor
348
+ verifies it before use. A mismatch, a missing file or a manifest from another version raises
349
+ `QSS::CoreIntegrityError`.
350
+ 2. **Tool**: `auditor.tool_stylesheets`, a dev tool's own compiled chrome (for example Parapet's
351
+ `qss_application.css`). The tool seals each file when it builds it with the new `qss-seal` executable (or
352
+ `QSS::Integrity.seal`), which writes `<file>.qss-seal`; `--header` also marks the file DO NOT EDIT. An unsealed or
353
+ changed file raises `QSS::ToolIntegrityError`.
354
+ 3. **Host**: `auditor.host_stylesheets`, the end consumer's own compiled output, as paths or globs relative to the
355
+ project root, plus whatever `QSS.configure(host_stylesheets: -> { ... })` returns (evaluated each time the
356
+ settings are read, in the host's own process). With nothing configured, a Rails host gets
357
+ `app/assets/builds/*.css`.
358
+ - `QSS::IntegrityError`, the base of both integrity errors, so a host's CI can rescue and report one class.
288
359
 
289
360
  ### Changed
290
- - Gem metadata only, no code changes: the summary and description now explain why QSS exists (agents and flex/grid
291
- layout, addressable structure, static auditing and its limits, the origin in Parapet), and the gem links to
292
- `docs/DESIGN_HISTORY.md` as its documentation. The rubygems.org page reads these from the latest release.
361
+ - Core's classes are now always known to `unresolved_class`. Before, setting `stylesheet_path` replaced core's
362
+ stylesheet, so a consumer that listed only its own file lost every core class. `stylesheet_path` and
363
+ `drywall_stylesheet_path` keep working (`stylesheet_path` counts as a host stylesheet). The Tailwind/QSS boundary
364
+ check is unchanged and still reads `drywall_stylesheet_path` only.
293
365
 
294
- ## [0.1.1] - 2026-10-05
366
+ ## [0.4.1] - 2026-10-20
295
367
 
296
- ### Fixed
297
- - `contain-layout` and `qss-contain-layout` now have a rule (`contain: layout`) in `qss_core.css`. The README
298
- documents the class for structural frames and the native auditor mandates it, but the stylesheet never defined it,
299
- so a frame that satisfied the auditor received no containment. Anyone already using the class will now get layout
300
- containment on those frames: check that absolutely and fixed-positioned descendants still position as expected.
301
- Covered by a spec on the compiled stylesheet and by three checks in the browser render check (21 in total).
368
+ ### Added
369
+ - `QSS::Error`, the base of every failure QSS raises on purpose: `ConfigurationError`, `IntegrityError` (and so
370
+ `CoreIntegrityError`, `ToolIntegrityError`). One `rescue QSS::Error` covers all of them; each means the audit cannot
371
+ run as configured, not that a view has a finding. `ConfigurationError` is not an `IntegrityError`.
302
372
 
303
- ## [0.1.0] - 2026-09-25
373
+ ### Changed
374
+ - A configured stylesheet that is not on disk (`drywall_stylesheet_path`, `stylesheet_path`, or a literal `host_stylesheets` entry)
375
+ now raises `QSS::ConfigurationError` naming the file. Before, it loaded as an empty stylesheet, so a Tailwind build that had
376
+ not run yet showed up as a large, misleading `unresolved_class` count. A glob that matches nothing is still just empty.
304
377
 
305
- First release, extracted from the Parapet project.
378
+ ## [0.4.2] - 2026-10-20
306
379
 
307
380
  ### Added
308
- - Core stylesheet (`qss/qss_core.scss`, compiled `qss/qss_core.css`): utilities on a 1000-unit canvas grid; the unit
309
- (`--qnt`, canvas width / 1000) computed by CSS alone (`0.1cqw` of the canvas; the viewport width outside one).
310
- - Base reset for QSS actors at the canvas id's weight only, so every core utility applies without the JS engine.
311
- - Neutral `!default` SCSS settings (`qss/_qss_defaults.scss`); a project sets its own before importing core.
312
- - Optional runtime engine (`qss/qss-engine.js`): rules for uncovered bracket classes, measured element edges.
313
- - `QSS::QssNativeAuditor`: id grammar (`{qualifier}-{baseObject}`, `QSSObject` custom types) with a per-project
314
- registry, `qss-function` / `qss-relation` attributes, explicit dimensions for every actor, unit rules (no raw CSS
315
- lengths on QSS structure; rem or % only in foreign content), `!important`, z-index and inline-style checks.
316
- - `QSS::QssAuditor`: the older class-whitelist auditor.
317
- - `QSS.configure(root:, config:)`: a project's settings read at runtime; paths resolve against its root.
318
- - `QSS::Blueprints` (`QSS.grid`, `QSS.object`): layout arithmetic and object blueprints.
319
- - `QSS::Engine`: Rails engine for the asset pipeline, loaded only when Rails is present.
320
- - `qss-sync`: writes a project's SCSS settings (`--core` regenerates the gem's own generated sections).
321
- - `qss-core-render-check`: renders the compiled CSS alone in headless Chrome and checks core utilities.
381
+ - A dimension can be declared by an `#id { ... }` rule in a configured tool or host stylesheet, not only by a size
382
+ class. The implicit-dimension check (red for a `qss` actor, orange for other structural elements) now also reads the
383
+ actor's own id from `tool_stylesheets`, `host_stylesheets` and `stylesheet_path`: `width`/`inline-size` declares the
384
+ width, `height`/`block-size` the height, `left`+`right` (or `inset`, `inset-inline`) pin the width, `top`+`bottom`
385
+ (or `inset`, `inset-block`) pin the height. This is how an actor anchors to another actor's resizable dimension with
386
+ plain custom properties (`right: var(--bar-w)`), which the bracket classes cannot express. Only unconditional rules
387
+ count: a rule inside `@media`/`@supports`, a nested rule, or a compound selector (`div#x`, `#x:hover`, `#x.on`)
388
+ declares the size only sometimes and is ignored, as is a value that sizes nothing (`auto`, `fit-content`, ...).
389
+
390
+ ## [0.5.0] - 2026-10-22
391
+
392
+ A consumer (Parapet) set its header title and its sub-title to two different `qss-text-size[N]` values and measured
393
+ both at exactly 9px in a real browser — not a coincidence. `text_scale.floor_px` was a flat 9, and the per-size
394
+ floor is `N * 0.875`, so every N from 1 to 10 computed below 9 and the flat floor won across that entire range:
395
+ `[5]`, `[7]` and `[10]` were indistinguishable from each other.
396
+
397
+ ### Changed
398
+ - The `qss-text-size[N]` floor drops from a flat 9px to 5px (`text_scale.floor_px`). With 9px, every N from 1 to 10
399
+ computed `N * 0.875` below the floor, so `[5]`, `[7]` and `[10]` all rendered at exactly 9px. Now `[5]` floors at 5px
400
+ and `[7]` and up scale with the canvas (`N * 0.875` is the floor from N = 6), so small sizes are distinct. The cap and
401
+ `floor_multiplier` are unchanged. A small size on a narrow canvas can now render smaller than before; the auditor's
402
+ legibility check (below 11, `qss-legibility-override` to waive) is unchanged. The 5 is a judgment call, not a
403
+ derived number: a floor and cap are the right guard for text people actually read, but as the next entry found,
404
+ wrong for text that was never meant to be legibility-constrained in the first place.
405
+
406
+ ## [0.6.0] - 2026-10-22
407
+
408
+ The same investigation that produced 0.5.0 raised a second question the floor-only fix couldn't answer: a legibility
409
+ clamp is right for body copy, and wrong for a wordmark or display title that should resize like a photo, with no
410
+ floor holding it artificially large on a narrow canvas and no cap holding it artificially small on a wide one.
411
+ Rather than removing the clamp from `qss-text-size[N]` (which every reader of body copy depends on), a second,
412
+ separate class was added for the different use case.
413
+
414
+ ### Added
415
+ - `qss-text-size-linear[N]` (N 1 to 96): text that scales in pure proportion to the viewport, `N * 100vw / 1440`, with no
416
+ floor, no cap and no dampening, for cosmetic text (wordmarks, titles) that should resize like a picture. It uses the
417
+ same unit as `qss-icon-size[N]`, so it tracks the viewport width, not the canvas. Body copy should keep using
418
+ `qss-text-size[N]`. The Ruby auditor's below-11 legibility check only reads `qss-text-size[N]`, so it does not apply here.
419
+ The name was a deliberate choice over `qss-display-size` (names the use, not the behavior) and `qss-text-fluid`
420
+ (collides with the CSS community's existing "fluid type," which usually still clamps). Known limitation at this
421
+ point: the unit this class rides on (`--u`, `100vw / 1440`) tracks the real viewport rather than the canvas, which
422
+ is right for a full-width header and wrong for the same text inside a narrower sized container — `--qnt` already
423
+ solves exactly this shape of problem for everything else, defaulting to the viewport and re-resolving via
424
+ container-query units inside a sized container as one variable rather than two parallel systems. The plan at this
425
+ point is for `--u` to learn that same trick in a future release, not to add a second parallel canvas unit.
426
+
427
+ ## [0.7.0] - 2026-11-02
428
+
429
+ Two real, independent consumers (Parapet and a second PWA app under development) hit the same root cause from two
430
+ different angles within days of each other: `--qnt` is one scalar, derived from the canvas's *width* alone, used
431
+ for both axes — deliberate, since it's what keeps two elements positioned on different axes holding their
432
+ relative position to each other as the canvas reshapes (the gem's founding guarantee). But a design that uses the
433
+ `y` axis the same way it uses `x` is implicitly assuming a **square** canvas, and nothing enforces that. The
434
+ second app's landing page measured elements rendering 1,408px and 2,048px below the bottom edge of a real
435
+ 1,298px-tall browser window — not clipped, rendering entirely outside the visible, `overflow: hidden` canvas —
436
+ and, separately, a logo clipping its own container on every ordinary 16:9/16:10 window because its height came
437
+ from one canvas axis and its width (derived from that
438
+ height via a fixed image aspect ratio) effectively depended on the other.
439
+
440
+ Several plausible-looking fixes were tried and rejected before landing on the real one:
441
+
442
+ - **Splitting `--qnt` into `--qnt-x`/`--qnt-y`.** Makes y-axis positioning finally proportional to the canvas's
443
+ actual height — but breaks the one guarantee the single scalar exists for, reintroducing "which property
444
+ actually moved it" ambiguity inside QSS's own unit system, the exact failure QSS exists to prevent. Proposed and
445
+ rejected twice, independently, by two different people, before either rejection's reasoning reached the other.
446
+ - **A configured canvas aspect ratio with a letterboxed/pillarboxed canvas.** Actually built, as an app-level
447
+ stopgap, then reverted the same day after producing visible pillarboxing on a real ultra-wide window. QSS is
448
+ built primarily for full-bleed, PWA-style apps that must fill every real window at every resolution with zero
449
+ per-project configuration; a visible gutter, on principle, is not an acceptable trade to get there, regardless of
450
+ whether the lock is a fixed square or a configurable ratio.
451
+ - **The mirror image: scale to cover the viewport and crop whatever overflows**, instead of letterboxing. Solves
452
+ the full-bleed requirement, but only for a design with a genuine decorative margin near its edges to sacrifice —
453
+ it's the wrong default for a design where the edges carry load-bearing UI (a nav bar, a file-tree explorer, a
454
+ toolbar) with nothing there to crop.
455
+ - **A static auditor rule bounding position-plus-size against a configured worst-case ratio.** Zero CSS cost, but
456
+ advisory only, with a hard silent cliff at whatever ratio is configured — a window narrower than that stays
457
+ completely unchecked, which is exactly the original failure.
458
+
459
+ What shipped instead: three separate tools, matched to three different kinds of content, rather than one
460
+ canvas-wide policy. Content whose relative position *is* the point (a toolbar's own internal spacing, a tightly
461
+ grouped cluster) keeps today's rigid, canvas-width-relative `--qnt` exactly as it works today — no change, and no
462
+ canvas content should assume otherwise. Content that's allowed to flex gracefully within a bounded range (headroom
463
+ above a cluster, a gap that's more margin than structural relationship) gets a new clamped percentage-of-parent
464
+ mechanism. Content that's genuinely edge-docked and load-bearing (global nav, a sidebar, a toolbar that must never
465
+ crop) gets documented as belonging in the overlay portal rather than on the canvas at all, sized by `--qnt` but
466
+ positioned by real CSS edges.
467
+
468
+ ### Added
469
+ - `qss-elastic-y-min[N]` / `qss-elastic-y-pct[N]` / `qss-elastic-y-max[N]` + the bare `qss-elastic-y` trigger class:
470
+ clamped percentage-of-parent spacing for gaps that should flex with their immediate containing block's actual
471
+ rendered size, bounded between a floor and a cap (`top: clamp(min, pct, max)`), rather than holding rigidly
472
+ proportional to the canvas (`qss-pos-y[N]`) or growing unbounded with the parent (`qss-pos-y-pct[N]` alone). Four
473
+ classes rather than one three-parameter bracket, because a bracket with three independent values can't be
474
+ precompiled the way a single-value one can (it would need every min×pct×max combination generated in advance);
475
+ composing single-value classes keeps this working with zero JavaScript, the same idiom the rest of the grammar
476
+ already uses. Stack all four classes on one element. See DESIGN_HISTORY.md #17.2.
477
+
478
+ - The overlay portal is now documented (`docs/HOWTO.md`) as the home for persistent, viewport-anchored chrome
479
+ (global nav, a file-tree explorer, a toolbar pinned to an edge), not only transient overlays: placed by real CSS
480
+ edges, sized by `--qnt` like any other actor, with no second root needed alongside transient overlays, because a
481
+ native top-layer modal always paints above everything regardless of which root anything lives in, and chrome is
482
+ never meant to float above a modal that's taking over the user's attention. The one contract this depends on:
483
+ overlay-mounting code must always append to the portal, never insert at a fixed index, so later-added overlays
484
+ keep painting over earlier, static chrome. See DESIGN_HISTORY.md #17.3.
485
+
486
+ ### Fixed
487
+ - The JS engine's `applyContainerNames` now also names actors inside `#qss-portal`, not just `#qss-canvas`. Named
488
+ container-query variants (Tailwind's `@sm/<frame-name>:`) silently never worked on any portal content before this
489
+ — existing overlays included, not just new chrome. Not a deliberate canvas-only design; found while documenting
490
+ the portal as the home for persistent, viewport-anchored chrome (nav bars, toolbars), which made the gap
491
+ impossible to miss. See DESIGN_HISTORY.md #17.3.