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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +345 -175
- data/QSS-MANIFESTO.md +16 -4
- data/README.md +62 -166
- data/app/assets/javascripts/qss/qss-engine.js +16 -6
- data/app/assets/stylesheets/qss/qss_core.css +12024 -0
- data/app/assets/stylesheets/qss/qss_core.scss +15 -0
- data/config/grammar.yml +6 -0
- data/config/integrity.json +2 -2
- data/docs/DESIGN_HISTORY.md +882 -0
- data/docs/HOWTO.md +269 -0
- data/exe/qss-core-render-check +38 -0
- data/exe/qss-engine-render-check +8 -2
- data/lib/qss/qss_auditor.rb +5 -1
- data/lib/qss/version.rb +1 -1
- metadata +12 -8
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
# The Design History of QSS
|
|
2
|
+
|
|
3
|
+
How QSS came to look the way it does: the problems that started it, the ideas that were tried and dropped, and the
|
|
4
|
+
reasoning behind what stayed. It is a curated account, not a commit log. QSS was created by Kraig Fairhurst, who made
|
|
5
|
+
the design decisions recorded here; much of the work was done with AI coding agents, which is also where most of
|
|
6
|
+
the lessons came from.
|
|
7
|
+
|
|
8
|
+
## 1. The problem: AI agents and CSS
|
|
9
|
+
|
|
10
|
+
If you have asked an AI coding agent to change a layout, you have probably seen some of this:
|
|
11
|
+
|
|
12
|
+
- You ask it to "move the sidebar 10px to the left." It edits a margin, then an `order`, then a `flex-basis`, and
|
|
13
|
+
something else on the page moves.
|
|
14
|
+
- It reports the change as done and "verified", while the page is visibly broken in a browser.
|
|
15
|
+
- Class names drift: two spellings of the same utility, classes that resolve to no CSS at all, `!important` and
|
|
16
|
+
`z-index` added until something shows up.
|
|
17
|
+
- The only way to know what a layout does is to render it.
|
|
18
|
+
|
|
19
|
+
QSS exists to make a layout something an agent (and a person) can read and change from its source, with one
|
|
20
|
+
unambiguous place to make each edit, and to make "done" mean something a developer does not have to re-check by
|
|
21
|
+
hand. The goal is confidence in the outcome, not a particular look.
|
|
22
|
+
|
|
23
|
+
## 2. The founding example
|
|
24
|
+
|
|
25
|
+
The case that shaped everything: an agent told "move X left by 10px" does it trivially when X is positioned
|
|
26
|
+
explicitly, and breaks it under flex or grid layout. It took several rounds to explain why correctly.
|
|
27
|
+
|
|
28
|
+
- It is **not** that agents are bad at arithmetic. "Read `--sidebar-x`, subtract 10, write it back" is reliable:
|
|
29
|
+
one explicit value, one target.
|
|
30
|
+
- Complex derived math (multi-step quantization, midpoints between named elements) is a genuine weakness.
|
|
31
|
+
- The flex/grid failure is a third thing: **ambiguous intent-to-mechanism mapping.** "Left" is not one property in
|
|
32
|
+
flex or grid; it emerges from margin, order, basis, gap and the siblings. The agent has to guess which to touch,
|
|
33
|
+
and the guess is where it goes wrong.
|
|
34
|
+
|
|
35
|
+
The design rule that follows: **keep every structural value explicit, single-purpose and directly addressable**, so
|
|
36
|
+
an edit always has exactly one target. This is what QSS calls **addressability**; being able to check what actually
|
|
37
|
+
rendered is **observability**. Together they are what "agent-aware" means here.
|
|
38
|
+
|
|
39
|
+
## 3. Origins: a bracket DSL and a runtime engine
|
|
40
|
+
|
|
41
|
+
QSS began inside Parapet, a Rails development tool whose own interface was the first thing built with it. The early
|
|
42
|
+
version had:
|
|
43
|
+
|
|
44
|
+
- a bracket grammar (`pos-x[500]`, `w[240]`) on a 1000-unit canvas grid, where one unit (a **qnt**) is one
|
|
45
|
+
thousandth of the canvas width;
|
|
46
|
+
- relational anchoring (`pos-y[^header]`: place this below the header, by measuring the header at runtime);
|
|
47
|
+
- a JavaScript engine that transpiled classes at runtime and measured elements;
|
|
48
|
+
- an auditor that checked every class against a whitelist.
|
|
49
|
+
|
|
50
|
+
Its structural ideas held up: a flat hierarchy of frames directly on one canvas (deep nesting is what confuses
|
|
51
|
+
agents, so remove it rather than tolerate it), frames that isolate their layout, overlays in a portal at the
|
|
52
|
+
document root instead of `z-index` wars, and a ban on `!important` and ad-hoc inline styles.
|
|
53
|
+
|
|
54
|
+
## 4. What broke, and what it taught
|
|
55
|
+
|
|
56
|
+
- **The custom parser was the bug farm.** Every serious defect of that period lived in the hand-maintained
|
|
57
|
+
regex/whitelist layer, not in CSS: a regex that one comma could defeat, a whitelist that drifted between client and
|
|
58
|
+
server, a validation shortcut that let anything starting with a capital letter through.
|
|
59
|
+
- **An invented syntax has a cost for agents.** A model has seen enormous amounts of real CSS and Tailwind, and
|
|
60
|
+
none of a project's private bracket language. The DSL was paying an unfamiliarity tax while trying to buy
|
|
61
|
+
certainty.
|
|
62
|
+
- **Self-graded verification did not hold.** Repeatedly, a change was reported as fixed, with the auditor green and
|
|
63
|
+
the unit tests passing, while the page did not work: once the JavaScript engine did not load at all. Each time it
|
|
64
|
+
was a real browser that caught it. Lesson: "verified" has to come from running the real thing, checked by
|
|
65
|
+
something other than the tool that made the change.
|
|
66
|
+
- **Framework CSS leaked.** A beta export of QSS broke the styling of a production application that used it,
|
|
67
|
+
because its rules could reach outside their own area.
|
|
68
|
+
|
|
69
|
+
Two of these are different problems and were kept apart on purpose: a better grammar narrows one class of mistake;
|
|
70
|
+
only real, independent verification makes an agent's "done" trustworthy.
|
|
71
|
+
|
|
72
|
+
## 5. 2026-09-16: retire the DSL as the foundation
|
|
73
|
+
|
|
74
|
+
The decision: structure moves to **native CSS**: plain custom properties with a naming convention (not a new
|
|
75
|
+
syntax), fluid scaling instead of breakpoints, and a short **native-CSS auditor** (frames isolate their layout,
|
|
76
|
+
declare their dimensions, stay out of `z-index`, keep structure off text tags) instead of a whitelist grammar. Reusable
|
|
77
|
+
interface patterns become Rails ViewComponents rather than a bespoke object system. A visual canvas editor stays
|
|
78
|
+
wanted but explicitly last: it is an editor for the other layers, so it cannot be designed before they are stable.
|
|
79
|
+
|
|
80
|
+
## 6. 2026-09-17: the first real pilot
|
|
81
|
+
|
|
82
|
+
Rebuilding one real feature (a file editor) on the new direction, and getting its browser tests to pass, found four
|
|
83
|
+
separate problems rather than one: scripts that the asset pipeline had silently never loaded; a base rule that won on
|
|
84
|
+
CSS specificity and made `hidden` panels visible (patched locally that day; its root cause was only fixed in core a
|
|
85
|
+
week later, section 11); a stylesheet generator that simply had no rule for dozens of commonly used classes (786
|
|
86
|
+
elements across 50 files depended on them); and a second, hyphenated class spelling that matched nothing. Browser
|
|
87
|
+
tests went from 0 of 12 scenarios passing to 8.
|
|
88
|
+
|
|
89
|
+
## 7. 2026-09-21: brackets kept for content, with guardrails
|
|
90
|
+
|
|
91
|
+
A census of every class token in the interface (7,887 occurrences) found about 29 percent resolving to no CSS at all,
|
|
92
|
+
while both auditors reported green. The bracket form was the one convention that actually worked (99 percent
|
|
93
|
+
resolved). So the brackets were **kept for content** (spacing, sizes, radius, borders, type and icon size, colour
|
|
94
|
+
alpha), on three conditions: one source of truth for the grammar, an auditor rule that every class must resolve, and
|
|
95
|
+
real-browser checks at several widths before anything is called done. Structure stays native CSS.
|
|
96
|
+
|
|
97
|
+
Also decided: a named palette with per-property alpha (`bg-alpha[50]`), because one shared alpha class was ambiguous
|
|
98
|
+
two times in three; and **scoping instead of prefixes**: every generated rule applies only inside the canvas (and the
|
|
99
|
+
overlay portal), so a host application's CSS and QSS's cannot collide, whatever anyone names their classes.
|
|
100
|
+
|
|
101
|
+
## 8. 2026-09-22: four layers, full CSS coverage, essential and accidental
|
|
102
|
+
|
|
103
|
+
- **Four layers**: (1) the core framework; (2) a library of interaction patterns; (3) the canvas, the authoring
|
|
104
|
+
tool for a project's own objects; (4) a project's own instances. Work the core first; data that belongs to one
|
|
105
|
+
project must never sit in the core.
|
|
106
|
+
- **Full coverage, not usage-gated.** Whether to support a pseudo-class is decided by the CSS specification, not by
|
|
107
|
+
what one project happens to use. Position selectors such as `:nth-child()` are included: they are not ambiguous
|
|
108
|
+
(one precise meaning), and an agent edits the template that defines every row, never the rendered list.
|
|
109
|
+
- **Essential and accidental properties.** Generating every colour for every state for every group had grown the
|
|
110
|
+
stylesheet past 485,000 lines and broken the test suite. Kraig traced it to Aristotle's distinction: what a thing
|
|
111
|
+
*is* (its type) is essential; what it happens to have (colour, spacing, which value a state takes) is accidental.
|
|
112
|
+
Core defines the essential and never pre-multiplies the accidental: `.group-hover:bg-accent` reads
|
|
113
|
+
`--accent-bg`, set by a separate colour class, so the rule count becomes additive instead of multiplicative.
|
|
114
|
+
- **Identity in the id.** An id is `{qualifier}-{baseObject}`: a real HTML tag, or the anchor `QSSObject` for a
|
|
115
|
+
project's own type (`activity-card-QSSObject`). Hyphens, because they mean nothing in CSS selectors. Relation and
|
|
116
|
+
function are separate attributes (`qss-relation="form"`, `qss-function="delete"`), not packed into the id, because
|
|
117
|
+
a positional parse of one long id has no reliable boundaries.
|
|
118
|
+
- **Drift prevention.** Open vocabularies (qualifiers, functions, custom types) go in a per-project registry. A new
|
|
119
|
+
name that is only a near-miss of a registered one ("frm" for "form", leetspeak, a vowel dropped) is flagged for a
|
|
120
|
+
person to confirm, never silently registered as a second name for the same thing.
|
|
121
|
+
|
|
122
|
+
## 9. 2026-09-23: separating the core, and a real auditor
|
|
123
|
+
|
|
124
|
+
Everything project-specific was moved out of the core into the consuming project (archived where unused). The native
|
|
125
|
+
auditor gained the identity rules: a closed alphabet for names (lowercase words and hyphens), similarity checks,
|
|
126
|
+
registry-driven types, and a severity scheme: RED means a rule is violated (fix the markup, or rethink the rule);
|
|
127
|
+
ORANGE means a judgment call. It was then run against code QSS had never seen: hundreds of templates from widely used
|
|
128
|
+
Rails gems (no crashes), and the class vocabularies of several production Tailwind sites, which is how the unit
|
|
129
|
+
rules were calibrated.
|
|
130
|
+
|
|
131
|
+
**The units and viewport rule.** QSS depends on the viewport through exactly one channel, the canvas grid. Raw
|
|
132
|
+
viewport units (`vw`, `svh`, ...) on QSS structure would add a second, unaddressable coordinate system, and some
|
|
133
|
+
change at runtime; so structure carries no raw CSS lengths at all, and foreign content may use `rem` or `%` only.
|
|
134
|
+
|
|
135
|
+
## 10. 2026-09-24: the fixes that made the gem possible
|
|
136
|
+
|
|
137
|
+
- **Every actor declares its size.** A panel's scroll area silently grew to the height of its content (3,013px
|
|
138
|
+
inside a 476px frame), because QSS's reset stops actors shrinking and nothing required it to declare a height. The
|
|
139
|
+
rule is now enforced by the auditor.
|
|
140
|
+
- **Compiled CSS works without JavaScript.** The base reset turned out to outrank every compiled utility on CSS
|
|
141
|
+
specificity; utilities only worked because the runtime engine re-emitted them with a doubled id. Rendered with the
|
|
142
|
+
CSS alone, `hidden`, `flex` and `w-full` all failed. The reset now uses `:where()`, and a render check proves the
|
|
143
|
+
compiled CSS alone in a headless browser.
|
|
144
|
+
- **The unit in pure CSS.** The qnt was set by JavaScript after measuring. It is now `0.1cqw` of the canvas (a CSS
|
|
145
|
+
container query unit), registered as a length so it resolves once at the canvas and inherits unchanged. A canvas
|
|
146
|
+
shown at half width, say a preview inside an editor, keeps its contents in proportion. The qnt stays the one unit
|
|
147
|
+
authors use; `cqw` appears only in its definition.
|
|
148
|
+
- **Vocabulary.** An element's id is its **noun**, its classes are **adjectives**, and `qss-function` /
|
|
149
|
+
`qss-relation` are **attributes**. Essential/accidental remains the principle underneath; the two are not
|
|
150
|
+
synonyms (some essential facts live in attributes, and a declared size is an essential fact written as a class).
|
|
151
|
+
- **The gem.** Every setting that belonged to the first consuming project (id prefix, palette, registry, portal)
|
|
152
|
+
moved out of the core into that project's own configuration, read at runtime. The core loads in plain Ruby, ships
|
|
153
|
+
neutral defaults, and became this gem, with a small Rails engine for the asset pipeline.
|
|
154
|
+
|
|
155
|
+
## 11. Open questions (all resolved 2026-10-17 -- see CHANGELOG.md's Unreleased section for each fix)
|
|
156
|
+
|
|
157
|
+
The last of this section's items was whether frames should clip their content: `contain: layout` isolates layout
|
|
158
|
+
but not paint, so a frame's overflow could still cover its neighbours. Resolved by making `contain-layout`/
|
|
159
|
+
`qss-contain-layout` clip paint too by default, with `qss-paint-override` as the opt-out for a frame that wants
|
|
160
|
+
decorative overflow without going through the portal (anything that needs to visually escape a frame on
|
|
161
|
+
purpose -- tooltips, dropdowns, modals -- already has to use the portal regardless, so this did not reopen that
|
|
162
|
+
case). The section number is kept stable for the cross-references below and in CHANGELOG.md.
|
|
163
|
+
|
|
164
|
+
## 12. 2026-10-16: the Tailwind/QSS boundary, restored
|
|
165
|
+
|
|
166
|
+
Kraig's original intent for QSS, from before any of the pivots above, was never "QSS replaces CSS." It was: QSS
|
|
167
|
+
handles positioning and custom element definitions; Tailwind handles standard form elements and other common HTML
|
|
168
|
+
widgets. That division was written into the first consumer's own `CLAUDE.md` ("Tailwind utility classes use a `qss-` prefix")
|
|
169
|
+
but never actually built — that consumer had no Tailwind dependency anywhere (no `package.json`, no `tailwindcss` entry),
|
|
170
|
+
and its compile task is a plain, non-content-scanning `Sass.compile` over hand-authored SCSS. Over the course of the
|
|
171
|
+
pivots in sections 5-10, the hand-authored bracket vocabulary quietly absorbed the content-styling job too (section
|
|
172
|
+
7's "brackets kept for content" decision), without anyone deciding that on purpose. This section is the correction.
|
|
173
|
+
|
|
174
|
+
### 12.1 What a direct coverage audit found
|
|
175
|
+
|
|
176
|
+
Checking the compiled surface directly, not the stated intent, on 2026-10-16:
|
|
177
|
+
|
|
178
|
+
- Zero `@keyframes`/`animation`, zero `gradient`, zero `appearance`, zero `input[type=...]`, zero `::-webkit-*`
|
|
179
|
+
(range thumbs, scrollbars, file-input buttons), zero `::placeholder`/`::selection`, zero
|
|
180
|
+
`clip-path`/`aspect-ratio`/`writing-mode`.
|
|
181
|
+
- `filter`/`transform`/`grid-template-columns` exist only as a handful of fixed, non-parametric presets (`.blur`,
|
|
182
|
+
`.bright`, `.contrast`, `.grayscale`, `.hue`; `.grid-cols-1`/`-2`; a couple of `translate(-50%)` centering
|
|
183
|
+
helpers), not general coverage.
|
|
184
|
+
- Section 10.2 formally committed QSS's core to full CSS pseudo-class/pseudo-element coverage regardless of current
|
|
185
|
+
usage. Section 10.3's own taxonomy lists the entire checkbox/input-state family (`checked`, `indeterminate`,
|
|
186
|
+
`valid`/`invalid`, `required`, `read-only`, `autofill`), `focus-visible`, link states, `target`/`scope`,
|
|
187
|
+
DOM-position selectors, and `open`/`popover-open`/`modal`/`fullscreen` as "committed, not yet built." The
|
|
188
|
+
2026-09-24 four-layer roadmap lists the same item as "not yet started." Nothing has moved on it since; the first consumer's
|
|
189
|
+
attention shifted to Authentication/Authorization/Activity-Tracking/Intentions, and it was never picked back up.
|
|
190
|
+
- The "a class must resolve to real CSS" auditor guardrail, named as a requirement in section 8 after the
|
|
191
|
+
29-percent-dead-class census, was also never built. The native auditor checks structural governance (containment,
|
|
192
|
+
explicit dimensions, no `!important`/manual `z-index`/inline style) and identity grammar, not whether a class
|
|
193
|
+
resolves to emitted CSS.
|
|
194
|
+
- Roughly 32,000 responsive (`md:w-pct[N]`-style) rules still silently emit invalid CSS at the Sass level, confirmed
|
|
195
|
+
still present, never fixed.
|
|
196
|
+
|
|
197
|
+
### 12.2 The decision: restore the original split, don't build full native coverage
|
|
198
|
+
|
|
199
|
+
Two different problems were being treated as one. The founding QSS problem — "move X left 10px" breaking under
|
|
200
|
+
flex/grid — is ambiguous intent-to-mechanism mapping: margin, order, flex-basis, gap and siblings all plausibly do
|
|
201
|
+
it, so an agent guesses. That is a structural/positioning problem, and QSS's `--qnt` grid, bracket utilities, and
|
|
202
|
+
the Thin Pulse relational-anchoring engine actually solve it. That part keeps.
|
|
203
|
+
|
|
204
|
+
Gradients, animations, native form-control restyling, and pseudo-class/pseudo-element coverage are not
|
|
205
|
+
ambiguous-mechanism problems — each has exactly one correct CSS mechanism. They are a breadth problem, and
|
|
206
|
+
hand-authoring breadth inside QSS's own grammar is the one move that has broken every time it has been tried here:
|
|
207
|
+
the 2026-09-17 generator gap (786 elements), the 2026-09-21 dead-class census (29 percent), the still-open
|
|
208
|
+
32,000-rule responsive bug, `contain-layout` shipping with no implementing rule at all in 0.1.1. Every one of those
|
|
209
|
+
postmortems called for the same fix (class-must-resolve verification), and it has never been built. Scaling the
|
|
210
|
+
hand-authored surface up to match the full CSS spec bets on the exact mechanism that keeps failing, without first
|
|
211
|
+
closing the gap every prior failure said to close first. It would also reopen the stylesheet-bloat ceiling QSS
|
|
212
|
+
already hit once (485,000 lines, forced the essential/accidental rewrite in section 8) and has no content-scanning
|
|
213
|
+
to bound it a second time.
|
|
214
|
+
|
|
215
|
+
**Decision**: QSS's core stays scoped to Steel Frame — structural addressability, relational anchoring, `--qnt`
|
|
216
|
+
resolution scaling, and the native auditor's structural governance. A real Tailwind compiler (JIT, content-scanning,
|
|
217
|
+
prefixed and scoped so it cannot collide with a host app's own styles) is adopted as a dependency for Drywall —
|
|
218
|
+
content, decoration, native form-control styling, animation, gradients, and CSS pseudo-class/pseudo-element
|
|
219
|
+
coverage. Section 10.2's "full CSS-spec coverage" commitment is retired as a QSS-core goal; that coverage comes from
|
|
220
|
+
Tailwind instead, where it already exists, is battle-tested, and is something agents already have deep training
|
|
221
|
+
exposure to — the same unfamiliarity-tax argument that killed the bracket DSL as a structural foundation in section
|
|
222
|
+
5 applies in Tailwind's favor here, in reverse.
|
|
223
|
+
|
|
224
|
+
### 12.3 Why Tailwind doesn't reopen the original wound
|
|
225
|
+
|
|
226
|
+
Tailwind's content/decoration utilities (`animate-spin`, `bg-gradient-to-r`, `rotate-45`, pseudo-state variants) are
|
|
227
|
+
each already single-purpose — there is no margin-vs-order-vs-basis ambiguity for "add a shadow" or "turn on
|
|
228
|
+
italics." The ambiguity problem was specific to layout/positioning, which stays QSS's job. Tailwind's JIT,
|
|
229
|
+
content-scanned build is also a stronger observability guarantee than QSS's current unscanned hand-generation,
|
|
230
|
+
which is how the 29-percent dead-class rate happened in the first place: every class Tailwind compiles is
|
|
231
|
+
guaranteed to be one actually referenced in scanned markup.
|
|
232
|
+
|
|
233
|
+
The one piece that must be built, not assumed: a native-auditor rule that a `qss`-marked Steel Frame actor may never
|
|
234
|
+
carry a plain Tailwind layout utility (flex/grid/positioning/sizing). That is what makes the boundary mechanically
|
|
235
|
+
enforced rather than a convention someone can forget to follow — exactly the kind of rule QSS is already good at
|
|
236
|
+
writing.
|
|
237
|
+
|
|
238
|
+
### 12.4 Resolution scaling is unaffected
|
|
239
|
+
|
|
240
|
+
`--qnt` is `clamp(0.39px, 0.1cqw, 3.2px)` (dampened at the extremes, settled 2026-10-17 — see the Unreleased entry
|
|
241
|
+
in `CHANGELOG.md`), set via `container-type: inline-size` on `#qss-canvas` and inherited unchanged. It resolves
|
|
242
|
+
once, at the canvas, and has no relationship to whatever styles content inside a frame. The units-and-viewport rule
|
|
243
|
+
from section 9 already anticipated this exact situation — foreign content inside frames may use `rem`/`%` only,
|
|
244
|
+
never raw viewport units — and Tailwind's utility scale is `rem`-based by default, so it satisfies that rule without
|
|
245
|
+
any special-casing. The dampening fix is completely orthogonal to this decision either way.
|
|
246
|
+
|
|
247
|
+
### 12.5 CSS Suite Preview Window: per-frame container queries
|
|
248
|
+
|
|
249
|
+
Tailwind's native container-query variants (`@sm:`, `@md:`, etc., reacting to a `@container` box instead of the real
|
|
250
|
+
browser viewport) let Drywall content inside a frame respond to that frame's own rendered width — exactly what a
|
|
251
|
+
Preview Window needs, with zero JavaScript. For this to work per-frame rather than canvas-wide, each Steel Frame
|
|
252
|
+
actor needs its own `container-type: inline-size` plus a `container-name` derived from its existing noun/id — one
|
|
253
|
+
line, riding on the dimension declaration every Steel Frame actor already must carry per the native auditor.
|
|
254
|
+
Tailwind content inside that frame then uses the named variant (`@sm/<frame-name>:`) to target that specific frame,
|
|
255
|
+
independent of the real window size or any sibling frame's size. `theme.containers` breakpoint thresholds should be
|
|
256
|
+
tuned to realistic frame widths, not inherited from Tailwind's viewport-shaped defaults (640/768/1024px...).
|
|
257
|
+
|
|
258
|
+
### 12.6 Follow-through from the boundary decision: all resolved by 0.2.0
|
|
259
|
+
|
|
260
|
+
The list this section carried when the boundary was decided (2026-10-16), with how each item ended:
|
|
261
|
+
|
|
262
|
+
- **A real Tailwind pipeline.** Belongs to the consuming app, not this gem (a JIT compiler has to content-scan real
|
|
263
|
+
markup that doesn't exist here). Proved out in 12.8 with a disposable app outside both repos; the first consumer
|
|
264
|
+
has since wired it in and migrated to 0.2.0.
|
|
265
|
+
- **The forbidden-layout-utility rule.** Done: `drywall_boundary`, framework-agnostic and property-based (12.7).
|
|
266
|
+
- **Container queries on frames.** Done: every Steel Frame actor is a size container, named after its own id by the
|
|
267
|
+
engine (12.5). The `theme.containers` breakpoint values are Tailwind config and live with the consumer.
|
|
268
|
+
- **Decorative utilities.** Decided in 12.9: deprecated in 0.2.0 (orange `deprecated_decorative`), removal planned,
|
|
269
|
+
with a `drywall_prefix_collision` guard against a Drywall framework compiled with the `qss` prefix. The concern
|
|
270
|
+
that originally raised it was that QSS-native names (`border-slate-800`, `font-black`, `uppercase`) read exactly
|
|
271
|
+
like Tailwind's: since 12.7's mandatory prefix, unprefixed means Drywall and `qss-` means QSS, so the vocabulary
|
|
272
|
+
itself says which layer owns a property.
|
|
273
|
+
- **z-index provisioning.** Retired in 12.9: `qss-z-group[N]`, `qss-depth[key]` and the `depths` config are gone;
|
|
274
|
+
frames stay out of `z-index` entirely, and overlays use the native top layer or the portal (12.9).
|
|
275
|
+
- **The earlier list.** The roughly-32,000-rule responsive-percent bug, the extreme-resolution `--qnt` dampening and
|
|
276
|
+
the "class must resolve" auditor rule were fixed before 0.1.3. `qss-dampen-scaling`, reserved for a per-actor
|
|
277
|
+
dampening opt-in that became a global default instead, was removed in 0.2.0 as vestigial.
|
|
278
|
+
|
|
279
|
+
### 12.7 2026-10-17: every QSS class is now `qss`-prefixed, no exceptions
|
|
280
|
+
|
|
281
|
+
Planning the boundary's enforcement surfaced a second, more mechanical problem alongside the forbidden-layout-utility
|
|
282
|
+
rule: QSS's own vocabulary already used bare, unprefixed names deliberately close to a content framework's own
|
|
283
|
+
(`flex`, `absolute`, `w-full`, `w[200]`) -- some utilities had carried a `qss-` twin alongside the bare spelling
|
|
284
|
+
since Item A (2026-09-23), with one of that work's own comments flagging "prefix decision still open." Left bare,
|
|
285
|
+
these can't be told apart, in source text, from a framework like Tailwind compiling its own unprefixed utilities
|
|
286
|
+
onto the same canvas on purpose (12.2's whole point). Settled: every QSS class is `qss`-prefixed, full stop, with no
|
|
287
|
+
bare synonym kept. This also settled the design for the still-open forbidden-layout-utility rule (12.6): a non-QSS
|
|
288
|
+
rule (found by reading the Drywall framework's own compiled CSS, the same technique as the class-resolution check
|
|
289
|
+
built in 0.1.3) setting a structural property (`display`, `position`, `top`/`right`/`bottom`/`left`,
|
|
290
|
+
`width`/`height`, `flex*`, `grid*`, `gap`, `z-index`, `order`) on a `qss`-marked actor is the violation -- checked by
|
|
291
|
+
CSS property, not by matching a maintained list of one framework's class names. That makes the rule framework
|
|
292
|
+
agnostic for free: it needs no fork for Foundation, Bootstrap, or anything else that might fill Drywall instead of
|
|
293
|
+
Tailwind, and it needs neither side's classes to carry any particular prefix to function (a bracket-syntax
|
|
294
|
+
collision check separately confirmed the risk was already near zero there: QSS's `w[200]` and Tailwind's own
|
|
295
|
+
arbitrary-value syntax `w-[200px]` are different strings on sight). Built as `drywall_boundary` the same day (see
|
|
296
|
+
the Unreleased entry in `CHANGELOG.md`).
|
|
297
|
+
|
|
298
|
+
Picking the canonical word where a bare/`qss-` pair already existed was its own small decision, made explicitly
|
|
299
|
+
rather than by habit: wherever the existing `qss-` twin already matched the underlying CSS mechanism more directly
|
|
300
|
+
than QSS's original invented synonym (`qss-absolute` over `detached`, `qss-flex-row` over `horizontal`,
|
|
301
|
+
`qss-justify-between` over `spaced`, `qss-cursor-pointer` over `clickable`), that twin won. This is not "mirror
|
|
302
|
+
Tailwind's convention" as a goal in itself -- doing that for its own sake would be real coupling to one framework,
|
|
303
|
+
undercutting the agnostic rule above. These particular words (CSS position values, `flex-direction` values,
|
|
304
|
+
`align-items`/`justify-content` shorthand) are close to self-describing CSS regardless of any framework; Tailwind
|
|
305
|
+
happens to use the same shorthand because it's the obvious choice, not because QSS borrowed it. A bare-only
|
|
306
|
+
utility with no existing twin just gained the prefix on its own word, no invention. Bracket-value classes
|
|
307
|
+
(`w[N]`, `p[N]`, `pos-x[N]`, ...) are prefixed the same way, matching `QSS-MANIFESTO.md`'s own original example
|
|
308
|
+
syntax (`.qss-pos-x[500]`) -- the mandatory prefix was the manifesto's stated grammar from the start (section 3),
|
|
309
|
+
and this change restores it rather than introducing it. Identity/relational markers that were never adjectives in
|
|
310
|
+
the manifesto's own sense (`qss` itself, `group`, `foreign`) are unchanged.
|
|
311
|
+
|
|
312
|
+
Consumer migration is mechanical -- QSS's vocabulary is a closed, known list (`config/grammar.yml`) -- but real: a
|
|
313
|
+
consumer's existing markup using the old bare spellings now resolves to no compiled CSS at all, surfaced
|
|
314
|
+
immediately as `unresolved_class` red findings (0.1.3) rather than silently. `qss-engine.js`'s runtime parsing was
|
|
315
|
+
already prefix-tolerant (an optional, not mandatory, `qss-` in its class-parsing regexes) and is now prefix-
|
|
316
|
+
mandatory instead, so CSS-only and engine-assisted behavior stay consistent -- the engine no longer quietly keeps
|
|
317
|
+
an old bare class working after its compiled rule is gone.
|
|
318
|
+
|
|
319
|
+
### 12.8 2026-10-18: a real Tailwind integration spike, outside both repos
|
|
320
|
+
|
|
321
|
+
The first consumer's own migration to 12.7's mandatory prefix surfaced a second, much larger problem: ~308 distinct classes
|
|
322
|
+
across 68 files that read as Tailwind decoration (`qss-gap-2`, `qss-text-[9px]`, `qss-bg-emerald-400`, ...) but have
|
|
323
|
+
never resolved to any compiled CSS at all, in any version of core. Root cause suspected but not yet proven: the first consumer's
|
|
324
|
+
own architecture docs claim a Tailwind build exists (`CLAUDE.md`: "Tailwind CSS, custom prefix: qss-") but no such
|
|
325
|
+
build has ever actually been wired in -- no `tailwind.config`, no `@tailwind`/`@import "tailwindcss"` anywhere in
|
|
326
|
+
the repo.
|
|
327
|
+
|
|
328
|
+
Deciding how to fix that live, in the consumer's session, was explicitly rejected (Kraig, same conversation) as the
|
|
329
|
+
exact failure mode QSS's own gem extraction (2026-09-25) was supposed to end: the first consumer and QSS co-evolving reactively
|
|
330
|
+
in circles, each dictating the other's next move. "QSS needs to become what it will become, independent of all
|
|
331
|
+
other influences" -- so rather than wire Tailwind into the consumer's messy, debt-carrying codebase and read the signal
|
|
332
|
+
through that noise, or wire it into this gem's own repo (which doesn't need Rails/Tailwind build tooling for
|
|
333
|
+
anything **its own** design requires -- the `drywall_boundary` mechanism is already proven framework-agnostic via
|
|
334
|
+
synthetic fixtures, `spec/qss/native_auditor_drywall_boundary_spec.rb`), a third, disposable option: a minimal Rails
|
|
335
|
+
app outside both repos (`qss-tailwind-spike`, not checked into either repo, gem dependency on QSS via the same
|
|
336
|
+
`path:` mechanism the first consumer uses, a real `tailwindcss-rails` build, a handful of fixture views, nothing else) to prove
|
|
337
|
+
the integration pattern in isolation, then report findings back here -- not decide anything live.
|
|
338
|
+
|
|
339
|
+
**Confirmed working, for real, not just in theory**: `drywall_boundary` correctly flagged three deliberate
|
|
340
|
+
structural violations (`grid` sets `display`, `fixed` sets `position`, `inset-0` sets `inset`) on a `qss`-marked
|
|
341
|
+
actor, reading Tailwind v4's actual compiled output (`auditor.drywall_stylesheet_path` pointed at
|
|
342
|
+
`app/assets/builds/tailwind.css`) -- not the synthetic `.flex { display: flex; }`-style strings its own spec suite
|
|
343
|
+
uses. The framework-agnostic, property-based design holds up outside a unit test.
|
|
344
|
+
|
|
345
|
+
**Confirmed working**: a real `tailwindcss-rails` build and this gem's own engine-provided assets
|
|
346
|
+
(`app/assets/stylesheets/qss/qss_core.css`, `app/assets/javascripts/qss/qss-engine.js`) coexist under Propshaft with
|
|
347
|
+
zero friction -- correct digest fingerprinting on both compiled stylesheets, no load-order issue, no configuration
|
|
348
|
+
needed beyond the two Gemfile dependencies. `QSS::Engine`'s asset paths get picked up automatically, as designed.
|
|
349
|
+
|
|
350
|
+
**Confirmed broken -- and very likely the actual root cause of the first consumer's whole dead-class debt, not merely
|
|
351
|
+
"the consumer never built Tailwind"**: `unresolved_class` (`Stylesheet.load`, called with `settings['stylesheet_path']`
|
|
352
|
+
in `audit_file`) only ever checks a class against `auditor.stylesheet_path`, which defaults to core's own
|
|
353
|
+
`qss_core.css`. It has no knowledge of `auditor.drywall_stylesheet_path` at all -- that setting is read only by the
|
|
354
|
+
separate `drywall_boundary` check. Every one of the spike's genuinely real, genuinely compiled Tailwind decoration
|
|
355
|
+
classes (`text-slate-500`, `text-sm`, `font-semibold`, `uppercase`, `tracking-wide`, `p-4`, `bg-slate-100`) came back
|
|
356
|
+
as `unresolved_class` red, exactly as if no Tailwind build existed at all. This means even a consumer that had wired
|
|
357
|
+
up a real Tailwind build from day one would show the identical false-red findings today, because the check never
|
|
358
|
+
looks at the second stylesheet. Needs a real fix, not a consumer-side workaround: `unresolved_class` should check the
|
|
359
|
+
union of `stylesheet_path` and `drywall_stylesheet_path` (when the latter is configured) before calling a class
|
|
360
|
+
dead.
|
|
361
|
+
|
|
362
|
+
**Side finding, reproduced cleanly outside any consumer, confirming it's a gem bug and not a consumer-only artifact**: the
|
|
363
|
+
spike's first fixture draft happened to pick the exact three words used as prose examples in 12.7's own paragraph
|
|
364
|
+
above (`flex`, `absolute`, `w-full`) and in the generated `qss_core.css`'s corresponding comment (around line
|
|
365
|
+
70737: "a content framework compiling its own unprefixed `.flex`/`.absolute`/`.w-full` would..."). All three
|
|
366
|
+
silently registered as "resolved" with no `unresolved_class` finding at all, despite no real rule existing for any
|
|
367
|
+
of them (confirmed: `grep` for `\.flex\b` etc. in the compiled CSS matches only that comment line, never a selector).
|
|
368
|
+
`Stylesheet.classes_in`'s selector-extraction regex (`css_text.scan(/([^{}]+)\{[^{}]*\}/m)`) sweeps up whatever text
|
|
369
|
+
sits between two rules, including comment bodies, and reads it as if it were selector text for the rule that
|
|
370
|
+
follows. Any class name mentioned in a comment, anywhere in core, silently and incorrectly resolves. Narrow fix:
|
|
371
|
+
strip comments before extracting selectors, same as CSS tooling generally does.
|
|
372
|
+
|
|
373
|
+
Nothing here was decided or built against this gem from the spike itself -- findings only, per the above. The spike
|
|
374
|
+
app is disposable and not checked into either repo. Both bugs were fixed in the commit that followed and shipped in
|
|
375
|
+
0.2.0 (the `unresolved_class` check now reads the union of both stylesheets, and comments are stripped before
|
|
376
|
+
selector extraction).
|
|
377
|
+
|
|
378
|
+
### 12.9 2026-10-18: closing out 0.2.0's two open design questions
|
|
379
|
+
|
|
380
|
+
Both questions flagged in 12.6 were decided on QSS's own merits, with the 12.8 spike as the evidence base that
|
|
381
|
+
12.6 said was missing.
|
|
382
|
+
|
|
383
|
+
**z-index provisioning: retired.** `qss-z-group[0..10]` and `qss-depth[key]` contradicted the stated design (frames
|
|
384
|
+
stay out of `z-index`; overlays portal to the document root) and sat outside the Portal Integrity rule, whose regex
|
|
385
|
+
matches neither shape. Both are gone, along with everything that existed only to feed them (`depths` config,
|
|
386
|
+
`$qss-depths`, the generated `DEPTHS` constants, the engine's runtime `z`/`z-index`/`depth` cases). Portal Integrity
|
|
387
|
+
is kept as is: it now has nothing of QSS's own to be bypassed by, and still catches inline `z-index` and
|
|
388
|
+
framework `z-N` classes outside a portal. Breaking for consumers (the first consumer used `depth[...]` widely); that is a
|
|
389
|
+
0.x minor-version break, called out in the changelog.
|
|
390
|
+
|
|
391
|
+
**Decorative utilities: deprecated now, removed later.** The 12.6 worry was two grammars producing identical-looking
|
|
392
|
+
class names. Since 12.7 that is mostly answered by the prefix itself (unprefixed = Drywall, `qss-` = QSS), so the
|
|
393
|
+
remaining hazards are (a) a Drywall framework compiled *with* the `qss` prefix, which makes the two indistinguishable
|
|
394
|
+
again, and (b) QSS still shipping Tailwind-shaped appearance classes (colors, type styling, shadows, filters) that
|
|
395
|
+
Drywall now owns. For (a), the auditor gained a red `drywall_prefix_collision` finding. For (b), the named
|
|
396
|
+
decorative classes are flagged orange `deprecated_decorative` and stay functional for one release, rather than
|
|
397
|
+
being deleted blind: a consumer needs a migration window, and the 12.8 spike proved wiring, not full coverage
|
|
398
|
+
equivalence. QSS's quantized bracket scale (`qss-border[2]`, `qss-rounded[8]`, `qss-text-size[14]`) is kept: it
|
|
399
|
+
scales with `--qnt`, which no Drywall framework can do. Still open for a later release: actually removing the
|
|
400
|
+
deprecated set (and the palette-driven color/alpha/accent generation, which is most of the compiled stylesheet),
|
|
401
|
+
once a consumer has migrated off it.
|
|
402
|
+
|
|
403
|
+
**Overlays: native top layer first, z-index as the fallback.** Retiring QSS's own z-index utilities left the question
|
|
404
|
+
of how overlays get stacked. The rule (Kraig, 2026-10-18): modals, dialogs and popovers use the browser's native top
|
|
405
|
+
layer (`<dialog>` + `showModal()`, or the `popover` attribute) whenever possible, which needs no `z-index` and cannot
|
|
406
|
+
lose a stacking fight with anything in the page. Where that is not possible (a custom overlay, an older browser), the
|
|
407
|
+
element lives in the portal root, rooted at the document body above the QSS canvas, and may carry a `z-index` there.
|
|
408
|
+
Enforced as an orange `top_layer_preferred` finding with an explicit `qss-overlay-fallback` escape hatch, rather
|
|
409
|
+
than a red rule, because whether the fallback is justified is a judgment call. Portal Integrity was also widened to
|
|
410
|
+
allow `z-index` anywhere inside the portal (not only on the portal root element itself), since otherwise the
|
|
411
|
+
fallback path could not stack one overlay above another without being a violation. Portal detection itself was
|
|
412
|
+
scoped at the same time: the old `id.include?('portal')` substring match would have flagged any `customer-portal`-style
|
|
413
|
+
id, so a portal root is now the configured `portal_root` id, or `portal` behind the `qss-` or project id prefix.
|
|
414
|
+
|
|
415
|
+
Also cleared: the vestigial `qss-dampen-scaling` marker (never did anything once dampening became a global
|
|
416
|
+
default) and a stale `qss-bg` grammar entry; `QSS-MANIFESTO.md` (which ships in the gem) was brought in line with
|
|
417
|
+
the current install flow and the boundary.
|
|
418
|
+
|
|
419
|
+
### 12.10 2026-10-18, after 0.2.0 shipped: a locked-down portal root and a positioning diagnostic
|
|
420
|
+
|
|
421
|
+
Two follow-ups to 12.9, built once 0.2.0 was out and the first consumer had migrated to it.
|
|
422
|
+
|
|
423
|
+
**The portal root is matched by exact id.** 0.2.0 recognised the portal root three ways (the configured id,
|
|
424
|
+
`qss-portal`, or `portal` behind the project's own id prefix). Every one of those was a convention an auditor had to
|
|
425
|
+
guess at. Now there is exactly one: `config.portal_root`, a simple `#id` selector the project chooses (default
|
|
426
|
+
`#qss-portal`), validated when the settings load and again in `qss-sync`, and compared exactly. A first version of this
|
|
427
|
+
change also required the id to start with `qss-`; that was dropped because the id names a real element in the
|
|
428
|
+
consumer's own markup, which the consumer owns, so mandating a prefix would only break working installs without making
|
|
429
|
+
the match any more exact. What locks it down is exactness, not the name. It does not cover a second overlay root a
|
|
430
|
+
project invents without registering it, or a portal created by script at runtime; the static auditor cannot know
|
|
431
|
+
about either.
|
|
432
|
+
|
|
433
|
+
**`QSSEngine.auditPositioning()`.** The static auditor catches a Drywall class that sets `position` only when the
|
|
434
|
+
element is a frame (`drywall_boundary`), and only when the class resolves in the Drywall stylesheet. A `fixed` utility
|
|
435
|
+
on any other element is invisible to it. The browser knows the computed position, so the engine gained an opt-in
|
|
436
|
+
diagnostic that reports fixed/sticky elements QSS does not own (frames, anything in the portal, foreign content, and
|
|
437
|
+
the native top layer are exempt). It is deliberately not part of the pulse: it scans the whole document, and it is a
|
|
438
|
+
development aid, not a runtime behavior.
|
|
439
|
+
|
|
440
|
+
### 12.11 2026-10-19: the portal root's own geometry (0.3.1)
|
|
441
|
+
|
|
442
|
+
The first consumer's migration found that a portal root configured as `portal_root` had no base geometry in the
|
|
443
|
+
compiled stylesheet. Every generated utility is scoped `:is(<canvas>, <portal root>) .class`, which can only match a
|
|
444
|
+
descendant of a root, so `qss-absolute qss-inset-0` on the portal root itself matched nothing; the canvas never
|
|
445
|
+
suffers from this because of its separate, unscoped Root Anchor rule, and the portal had no equivalent. The root fell
|
|
446
|
+
into normal flow after the canvas with no size, and everything portaled into it measured against an empty box.
|
|
447
|
+
Neither auditor can see this (the classes look fine statically), which is why the fix came with the first
|
|
448
|
+
real-browser assertion of portal geometry (`qss-core-render-check`), confirmed to fail against the 0.3.0 stylesheet.
|
|
449
|
+
|
|
450
|
+
Decisions: the portal root gets its geometry from a rule by id, not from utility classes (utility scope is a
|
|
451
|
+
deliberate descendant-only design, and widening it would let a utility rewrite the root's own box); it is
|
|
452
|
+
`position: fixed; inset: 0` because an overlay layer is viewport-relative by definition, unlike the canvas's
|
|
453
|
+
parent-relative `position: relative`; it is a size container with `--qnt` resolved on its children so qnt-sized
|
|
454
|
+
overlays work, which equals the canvas unit when the canvas spans the viewport and differs when it does not; and it
|
|
455
|
+
is `pointer-events: none` with `auto` on its direct children so a viewport-sized layer never blocks the page. The
|
|
456
|
+
base actor reset the canvas gives its actors now also applies inside the portal (a shared mixin), since an actor
|
|
457
|
+
portaled into an overlay is still a Steel Frame actor. The default `portal_root` also became `#qss-portal` in the
|
|
458
|
+
grammar, so the stylesheet, auditors and engine share one default.
|
|
459
|
+
|
|
460
|
+
## 13. State of the gem after 0.3.1 (2026-10-19)
|
|
461
|
+
|
|
462
|
+
Where sections 1-12 ended up, in one place.
|
|
463
|
+
|
|
464
|
+
**The split.** QSS owns *structure*: position, size, flow, containment and stacking context. Whatever fills the
|
|
465
|
+
content layer ("Drywall", normally Tailwind) owns *appearance*. Every QSS class is `qss-`-prefixed, so the vocabulary
|
|
466
|
+
alone says which layer owns a property; the native auditor enforces the split from both directions
|
|
467
|
+
(`drywall_boundary` for a Drywall class that sets a structural property on a frame, `drywall_prefix_collision` for a
|
|
468
|
+
Drywall framework compiled with QSS's own prefix).
|
|
469
|
+
|
|
470
|
+
**What QSS provides.**
|
|
471
|
+
- The canvas and `--qnt` (canvas width / 1000, pure CSS, dampened at extreme widths), with bracket classes in qnt for
|
|
472
|
+
position and size (`qss-pos-x[500]`, `qss-w[200]`) and QSS's own quantized scale for borders, radii, text and icons.
|
|
473
|
+
- Steel Frame actors (`qss`): each is a size container named after its own id, so Drywall content can respond to its
|
|
474
|
+
own frame (`@sm/<frame>:`).
|
|
475
|
+
- Two auditors. `QssNativeAuditor` is the live one (identity and registry rules, dimensions, units, class
|
|
476
|
+
resolution against the compiled stylesheet and the Drywall stylesheet, the boundary rules, Portal Integrity and
|
|
477
|
+
top-layer preference); `QssAuditor` is the older class-whitelist auditor. The engine can also report fixed/sticky
|
|
478
|
+
elements QSS does not own (`QSSEngine.auditPositioning()`, opt-in, 12.10).
|
|
479
|
+
- Two real-browser render checks (`rake render_check`, `rake engine_check`), because only a browser is real proof.
|
|
480
|
+
|
|
481
|
+
**Overlays.** Frames never set `z-index`. Modals, dialogs and popovers use the browser's native top layer
|
|
482
|
+
(`<dialog>` + `showModal()`, `popover`); anything else is the fallback path, rooted in the portal outside the
|
|
483
|
+
canvas, where a `z-index` is allowed and a `qss-overlay-fallback` marker says it is deliberate. The portal root is
|
|
484
|
+
identified by one exact configured id (12.10), and gets its own base geometry from core (12.11).
|
|
485
|
+
|
|
486
|
+
**Deprecated, not yet removed.** The decorative named utilities (colors, type styling, shadows, filters). They still
|
|
487
|
+
compile and still work; each use is an orange `deprecated_decorative` finding. Removing them, and the
|
|
488
|
+
palette-driven color classes that make up most of the compiled stylesheet, is the main open item.
|
|
489
|
+
|
|
490
|
+
**No iframe policy (decided 2026-10-18).** An iframe is a separate document the auditors cannot see, and QSS treats
|
|
491
|
+
it like any other element: as a frame it still has to declare its size, and its contents are the embedded page's own
|
|
492
|
+
business. Iframes are still common for third-party embeds (video, maps, payment and captcha widgets), so none is
|
|
493
|
+
forbidden, and a rule would have nothing to check inside one.
|
|
494
|
+
|
|
495
|
+
**State variants: built and held out (noted 2026-10-21).** Checked against `qss_core.scss`, prompted by a stale status
|
|
496
|
+
line in a consumer's own spec that still called most of this "not yet built". State variants are written
|
|
497
|
+
`{state}:{class}` (the state itself is never `qss-` prefixed) and compile to a real rule, scoped under the canvas.
|
|
498
|
+
- *Built, essential* (`$qss-accent-states`, 31 states, additive: palette size adds no rules). `{state}:qss-bg-accent`,
|
|
499
|
+
`-text-accent` and `-border-accent`, plus their group-relational forms (`group-{state}:...`, and each registered
|
|
500
|
+
`group/{name}`), for: hover, focus, focus-within, focus-visible, active; enabled, disabled, checked, indeterminate,
|
|
501
|
+
default, placeholder-shown; valid, invalid, in-range, out-of-range, required, optional; read-only, read-write,
|
|
502
|
+
user-valid, user-invalid, autofill; link, any-link, target; open, popover-open, modal, fullscreen,
|
|
503
|
+
picture-in-picture, defined. The state list is the W3C convention, not consumer config.
|
|
504
|
+
- *Built, palette-driven* (`$qss-state-prefixes-test`, 21 states: the list above up to and including autofill,
|
|
505
|
+
without focus-visible). The self-referential palette colors and alpha steps per state. This is the part of the
|
|
506
|
+
compiled stylesheet that grows with the palette, and it is among what the open item in section 13 would remove.
|
|
507
|
+
- *Held out, with reasons that differ.* `:visited` is held out for privacy restrictions: browsers limit which
|
|
508
|
+
properties a `:visited` rule may set, so it needs its own test and is still a runtime-only carve-out.
|
|
509
|
+
The DOM-position selectors (`:nth-child()` and its family) use a different mechanism, not a state-prefix rule.
|
|
510
|
+
`:local-link` is held out because no browser implements it; that is "nothing to build yet", not a state that merely
|
|
511
|
+
has not been got to.
|
|
512
|
+
|
|
513
|
+
## 14. 2026-10-22: the text scale learns two sizes of "small" (0.5.0, 0.6.0)
|
|
514
|
+
|
|
515
|
+
**The finding.** A consumer (Parapet) set its header title and sub-title to `qss-text-size[7]` and measured both at exactly
|
|
516
|
+
9px in a real browser. Not a coincidence: `text_scale.floor_px` was 9 and the per-size floor is `N * 0.875`, so every N from
|
|
517
|
+
1 to 10 computed below 9 and the flat floor won for the whole range. `[5]`, `[7]` and `[10]` were indistinguishable.
|
|
518
|
+
|
|
519
|
+
**Decision 1: lower the floor to 5px.** The 9px floor was a legibility guard from the 2026-09-21 text-scale decision (item 7).
|
|
520
|
+
It was clamping harder than needed. At 5px, `[5]` still floors flat, and `[7]` and up scale with the canvas (`N * 0.875` is the
|
|
521
|
+
floor from N = 6). The cost, accepted: a small size on a narrow canvas now renders smaller than before (`[9]` is under 8px
|
|
522
|
+
at 390). The Ruby auditor's below-11 check is unchanged, so tiny body text still draws a finding unless waived with
|
|
523
|
+
`qss-legibility-override`. The 5 is a judgment call, not a derived number.
|
|
524
|
+
|
|
525
|
+
**Decision 2: legibility clamping is for body copy, not for branding.** A floor and cap are right for text people read, and wrong
|
|
526
|
+
for a wordmark that should resize like a photo. So the clamp is not removed from `qss-text-size[N]`; a second class is added,
|
|
527
|
+
`qss-text-size-linear[N]` (N 1 to 96): `N * 100vw / 1440`, no floor, no cap, no dampening. It deliberately reuses the existing
|
|
528
|
+
`--u` unit of `qss-icon-size[N]` instead of a new one. The name was a judgment call (alternatives `qss-display-size`, which
|
|
529
|
+
names the use but not the behavior, and `qss-text-fluid`, which clashes with CSS "fluid type" that usually clamps). The
|
|
530
|
+
auditor's legibility check does not read it, on purpose.
|
|
531
|
+
|
|
532
|
+
**Known limit, and the next move.** `--u` is `100vw / 1440`, so it tracks the viewport, not the canvas: fine for a
|
|
533
|
+
full-width header, wrong for text inside a narrower sized container. `--qnt` already solves the same shape of problem: it
|
|
534
|
+
defaults to the viewport and re-resolves with container-query units inside a sized container, as one variable, not two
|
|
535
|
+
systems. The plan for the next minor is for `--u` to learn the same trick, not to add a parallel canvas unit. Not built yet.
|
|
536
|
+
|
|
537
|
+
**Process note.** The floor change shipped as 0.5.0 (an unpublished 0.4.3 was renumbered before release), and the linear
|
|
538
|
+
class followed the same day as 0.6.0. The `v0.4.3` tag is a leftover, not a release.
|
|
539
|
+
|
|
540
|
+
## 15. 2026-11-01: `#qss-wrapper` and the canvas's own aspect ratio — proposal superseded, see section 17
|
|
541
|
+
|
|
542
|
+
**The finding.** `QSS-MANIFESTO.md` names `#qss-wrapper` as the second piece of the four-piece "Deterministic
|
|
543
|
+
Stack" ("Enforces viewport boundaries and provides a Safety Gutter"), but `qss_core.scss` has never had a rule for
|
|
544
|
+
it -- only `#qss-canvas` is styled there. A consumer's `#qss-wrapper` is therefore an ordinary `div` with no size
|
|
545
|
+
of its own, so `#qss-canvas`'s `height: 100%` resolves against nothing unless the consumer gives the wrapper a
|
|
546
|
+
real height by hand, and most reach for `width: 100vw; height: 100vh` -- which stretches the canvas to fill
|
|
547
|
+
whatever shape the real window happens to be, edge to edge, exactly.
|
|
548
|
+
|
|
549
|
+
That stretch is a real problem specifically because of how `--qnt` is defined: one scalar, `canvas width / 1000`,
|
|
550
|
+
used for every bracket class on **both** axes (`qss-pos-y[N]` compiles to `N * qnt`, the same unit as
|
|
551
|
+
`qss-pos-x[N]` -- confirmed in `qss_core.scss`'s bracket loop, one `@for` driving `pos-x`, `pos-y`, `w` and `h`
|
|
552
|
+
off the same `--qnt`). That's deliberate (section 10: a canvas shown at half width "keeps its contents in
|
|
553
|
+
proportion"), and it's the mechanism behind the gemspec's own pitch -- "the same numbers hold at any screen
|
|
554
|
+
size." But the guarantee is conditional: it only holds if the canvas's own *shape* stays fixed. A design has no
|
|
555
|
+
way to declare "my content needs a canvas at least this tall relative to its width" -- the 1000-unit grid has one
|
|
556
|
+
axis, width, with y riding along on the same unit -- so a design that uses y all the way up to 1000 (matching the
|
|
557
|
+
canvas's own 1000-qnt width, the natural reading of "use the whole grid") is implicitly assuming a **square**
|
|
558
|
+
canvas, and a consumer that stretches `#qss-wrapper` to an arbitrary window shape will see that content run past
|
|
559
|
+
the real bottom edge on a wide/short window, with nothing above the auditor layer able to catch it (this is pure
|
|
560
|
+
rendered geometry; the static auditor doesn't simulate layout).
|
|
561
|
+
|
|
562
|
+
**Found twice now, independently**, per Kraig: this is the second real consumer to hit content running off the
|
|
563
|
+
canvas's bottom edge from this exact gap. The most recent and best-documented case: a second PWA app's landing
|
|
564
|
+
page (`qss-pos-y[550]`/`qss-pos-y[800]` elements) computed well past the bottom of a real 2560x1298 browser window --
|
|
565
|
+
1408px and 2048px down against a 1298px-tall, edge-to-edge-stretched canvas -- so the elements weren't just
|
|
566
|
+
clipped, they were rendering entirely outside the visible, `overflow: hidden` canvas. Pixel-measured against a
|
|
567
|
+
screenshot, not just reasoned about: see that app's own dev notes, dated 2026-11-01, for the full measurement.
|
|
568
|
+
|
|
569
|
+
**What that app did, as an app-level stopgap (2026-11-01).** In its own `application.scss`:
|
|
570
|
+
```scss
|
|
571
|
+
#qss-wrapper {
|
|
572
|
+
position: relative;
|
|
573
|
+
width: 100vw;
|
|
574
|
+
height: 100vh;
|
|
575
|
+
display: flex;
|
|
576
|
+
align-items: center;
|
|
577
|
+
justify-content: center;
|
|
578
|
+
background-color: /* the app's own gutter fill */;
|
|
579
|
+
}
|
|
580
|
+
#qss-canvas {
|
|
581
|
+
width: min(100vw, 100vh);
|
|
582
|
+
height: min(100vw, 100vh);
|
|
583
|
+
flex-shrink: 0;
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
This locks the canvas to the largest square that fits the real viewport and centers it, letting the wrapper's own
|
|
587
|
+
background show through as a gutter on whichever axis is wider. It works, and it's a reasonable reference for the
|
|
588
|
+
end behavior, but it's hand-rolled app CSS: hardcoded to a 1:1 ratio, rediscovered from scratch by a consumer who
|
|
589
|
+
had no way to know this was supposed to be core's job. Exactly the kind of thing meant to be solved once, centrally
|
|
590
|
+
(same reasoning as the `qss-` prefix boundary in section 12, or the native auditor existing at all): a second
|
|
591
|
+
consumer hitting the identical bug independently is the signal.
|
|
592
|
+
|
|
593
|
+
**Proposed for the next iteration.**
|
|
594
|
+
1. A new config key -- working name `config.canvas_aspect_ratio` -- read the same way `portal_root` and `palette`
|
|
595
|
+
already are. A number (`1` = square) is enough to start; a `"w:h"` string (`"16:9"`) is the more general shape
|
|
596
|
+
if non-square consumers show up, but adds real width/height math (see the open question below) rather than the
|
|
597
|
+
`min(100vw, 100vh)` shortcut a square ratio allows.
|
|
598
|
+
2. Thread it through `exe/qss-sync`'s `scss_variables` (same place `portal_root` flows today, `exe/qss-sync` lines
|
|
599
|
+
~125-167) into both `_qss_defaults.scss` (core's own neutral default) and a consumer's generated
|
|
600
|
+
`_qss_variables.scss`.
|
|
601
|
+
3. `qss_core.scss`: a real `#qss-wrapper`/`#qss-canvas` rule driven by that variable -- flex-centered wrapper,
|
|
602
|
+
canvas locked to the configured ratio, a gutter fill color (reuse an existing palette entry, or a second new
|
|
603
|
+
config key).
|
|
604
|
+
4. `#qss-portal`: should need no change -- it's already sized to the full viewport independent of the canvas by
|
|
605
|
+
design (section 12.11/CHANGELOG 0.3.1), which is correct; overlays are meant to cover the real viewport, not
|
|
606
|
+
just the letterboxed canvas. Worth confirming explicitly when this is built, not assuming.
|
|
607
|
+
5. `QSS::QssNativeAuditor`: no change anticipated. This is rendered geometry the static auditor has no mechanism
|
|
608
|
+
to see.
|
|
609
|
+
|
|
610
|
+
**Open questions for whoever picks this up.**
|
|
611
|
+
- **Default behavior.** Should an unconfigured consumer keep today's edge-to-edge stretch (fully backward
|
|
612
|
+
compatible, the new behavior opt-in only), or should core default `canvas_aspect_ratio` to `1` given both known
|
|
613
|
+
real consumers have now independently hit the same bug without it? The first is the conservative, semver-safe
|
|
614
|
+
choice; the second is arguably the more honest default, since the MANIFESTO already describes this as the
|
|
615
|
+
wrapper's job rather than an opt-in extra. Either way, the CHANGELOG entry needs to say plainly whether this is
|
|
616
|
+
a visually breaking change for existing consumers, even inside 0.x's "minor versions may break the API" rule.
|
|
617
|
+
- **Non-square math, not yet written or tested.** That app's stopgap only had to handle 1:1, where
|
|
618
|
+
`min(100vw, 100vh)` on both axes is enough. A general ratio needs real width/height math (something like
|
|
619
|
+
`width: min(100vw, 100vh * $ratio); height: min(100vh, 100vw / $ratio);`, or the CSS `aspect-ratio` property
|
|
620
|
+
paired with `max-width`/`max-height` caps) -- sketched here, not built or checked in a real browser.
|
|
621
|
+
- **The existing `qss-safety-gutter` utility.** Core already ships `.qss-safety-gutter { padding: 2px;
|
|
622
|
+
background-color: slate-900 }`, whose name matches the MANIFESTO's "Safety Gutter" wording closely enough that
|
|
623
|
+
it was plausibly meant for this, but it's a decorative padding/background utility class, not a layout rule, and
|
|
624
|
+
nothing applies it to `#qss-wrapper` today. Decide whether the new wrapper rule subsumes it, replaces it, or the
|
|
625
|
+
two stay separate (one for the actual letterbox layout, one for optional decoration on top of it).
|
|
626
|
+
- **Migration for existing consumers.** Once core emits a real `#qss-wrapper`/`#qss-canvas` rule, a consumer that
|
|
627
|
+
already hand-rolled its own (as of 2026-11-01) will get a cascade collision on upgrade and needs to
|
|
628
|
+
delete its local version in favor of the config key. Needs a CHANGELOG migration note when this ships.
|
|
629
|
+
|
|
630
|
+
Not built yet.
|
|
631
|
+
|
|
632
|
+
**2026-11-02 update.** The `canvas_aspect_ratio` + letterbox/pillarbox design sketched above was actually built, as
|
|
633
|
+
that app's own app-level stopgap, then reverted the same day (2026-11-01) after it produced visible pillarboxing
|
|
634
|
+
on a real 2560x1298 window locked to 16:10. Independently ruled out in principle the next day (2026-11-02) as
|
|
635
|
+
incompatible with full-bleed PWA use, where any visible gutter is unacceptable regardless of how it's produced. The
|
|
636
|
+
canvas-ratio-lock approach described in this section is retired — see section 17 for the model that replaced it.
|
|
637
|
+
|
|
638
|
+
**2026-11-02, second update, after a documentation audit.** The fix proposal above being retired is a different
|
|
639
|
+
fact from whether `#qss-wrapper` should still be *claimed* as one of `QSS-MANIFESTO.md`'s required "Deterministic
|
|
640
|
+
Stack" pieces, and the manifesto still listed it as required (item 2 of 4) after the first update above. That's
|
|
641
|
+
now corrected too: `#qss-wrapper` is dropped from the stack entirely (three required pieces, not four), not
|
|
642
|
+
merely marked unbuilt — the finding above already established it never had an implementing rule, and the "Safety
|
|
643
|
+
Gutter" it was described as providing is the exact thing section 17 ruled out on principle, so no fix this project
|
|
644
|
+
would actually ship was ever going to make the original claim true. Recorded here rather than silently deleted, in
|
|
645
|
+
case a real need for a wrapper-level mechanism comes up later — an embed mode that isn't full-bleed, say. If so, it
|
|
646
|
+
should be scoped and named fresh rather than reviving this description verbatim, since "enforces viewport
|
|
647
|
+
boundaries and provides a Safety Gutter" is specifically the behavior section 17 rejected. This also resolves, by
|
|
648
|
+
making moot, this section's own open question above about whether the existing decorative `qss-safety-gutter`
|
|
649
|
+
utility should be subsumed by a future wrapper rule: there is no wrapper rule coming for it to be subsumed by.
|
|
650
|
+
|
|
651
|
+
## 16. 2026-11-01 (same day): the single-axis qnt problem, found again at element scale
|
|
652
|
+
|
|
653
|
+
**The finding.** Section 15 documented the single-axis `--qnt` problem at the scale of the whole canvas
|
|
654
|
+
(`#qss-wrapper` stretching to an arbitrary window shape, so width-relative `qss-pos-y[N]` can run past the
|
|
655
|
+
real bottom edge). The same root cause -- one scalar unit, derived from canvas **width**, used for everything,
|
|
656
|
+
with no corresponding canvas-**height**-relative unit -- turns out to bite at the scale of a single element too,
|
|
657
|
+
independent of whether `#qss-wrapper` itself is ever fixed to a ratio.
|
|
658
|
+
|
|
659
|
+
A second PWA app's landing-page logo (`#landing-logo`) is a `.qss` actor with `qss-w[150]` (width = `150 * qnt`,
|
|
660
|
+
canvas-width-relative) containing an `<img>` styled `width: auto; height: 200%` (height = 200% of the
|
|
661
|
+
container's own height, and the container's height comes from `qss-h-pct[200]`, i.e. **canvas-height**-relative,
|
|
662
|
+
a completely different, uncoupled axis). The image's rendered width is then derived from that
|
|
663
|
+
canvas-height-relative height times the image's own fixed aspect ratio (a 410x520 two-frame hover-reveal
|
|
664
|
+
sprite, 0.7885). Algebraically:
|
|
665
|
+
|
|
666
|
+
```
|
|
667
|
+
container_width = 0.15 * canvasWidth
|
|
668
|
+
image_width = 2 * 0.20 * canvasHeight * 0.7885 = 0.3154 * canvasHeight
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
These only come out equal when `canvasWidth / canvasHeight ≈ 2.10`. Below that ratio -- every ordinary 16:9 or
|
|
672
|
+
16:10 window -- the image renders wider than its own container, and `overflow: hidden` clips the excess evenly
|
|
673
|
+
off both left and right edges. At 16:10 the deficit is roughly 47 qnt (a serious, visible clip); at a window
|
|
674
|
+
near the 2.10 coincidence point it can shrink to a couple of pixels, which is what actually prompted this
|
|
675
|
+
finding -- it first looked like a rounding artifact, and only algebra (not a bigger/smaller test) revealed it
|
|
676
|
+
was the same structural mismatch as section 15, just small by coincidence of one particular window's aspect
|
|
677
|
+
ratio. See that app's own dev notes, dated 2026-11-01, for the pixel-measurement and the full calc.
|
|
678
|
+
|
|
679
|
+
**First stopgap tried, and why it failed (same day).** The obvious fix looks like: drop the element's `qss-w[N]`
|
|
680
|
+
class and let the actor reset's default `width: auto` shrink-wrap the `display: flex !important` container to
|
|
681
|
+
its one child's rendered width, so `container_width` and `image_width` become the same value by construction
|
|
682
|
+
instead of two independent calculations that only coincide near one ratio. **This does not work, and not by
|
|
683
|
+
accident.** Every `.qss` actor is given `container-type: inline-size` (`qss_core.scss` line 76, for the
|
|
684
|
+
container-query engine that resolves `--qnt` and lets consumers key off a container's own name/size). Per the
|
|
685
|
+
CSS Containment spec, `container-type: inline-size` implies inline-size *containment*, which requires the
|
|
686
|
+
browser to compute an `auto` inline size (width, in horizontal writing mode) as if the box has no content --
|
|
687
|
+
sizing can't depend on descendants, because that's what would make container queries circular. The practical
|
|
688
|
+
effect: `width: auto` on any `.qss` actor doesn't shrink-to-fit, it collapses straight to the actor reset's
|
|
689
|
+
floor, `min-width: calc(1 * var(--qnt))` -- a 1px-ish sliver. Confirmed by trying it on that app's logo:
|
|
690
|
+
exactly this collapse, reproducing the *original* "missing positioning quantity" sliver bug from earlier the
|
|
691
|
+
same day (this gem's `min-width: 1 * qnt` reset is exactly what a width-less or auto-width actor collapses to).
|
|
692
|
+
This is a hard architectural fact, not a workaround to refine: **no `.qss` actor can ever use shrink-to-fit/
|
|
693
|
+
content-based auto sizing, in either axis, full stop** -- containment forbids it by spec, in every current and
|
|
694
|
+
future browser that implements container queries correctly. Any fix has to keep every actor's box fully
|
|
695
|
+
explicit.
|
|
696
|
+
|
|
697
|
+
**What that app did instead, as a working app-level stopgap (2026-11-01).** Kept both dimensions explicit,
|
|
698
|
+
but moved the height off the canvas-height-relative axis entirely: `qss-h-pct[200]` (percentage of canvas
|
|
699
|
+
height) became `qss-h[95]` (plain `calc(95 * qnt)`, the *same* canvas-width-relative unit as `qss-w[150]`).
|
|
700
|
+
Solved for 95 by requiring `image_width == container_width` with no canvas-height term anywhere in the
|
|
701
|
+
equation: `containerWidth = 2 * containerHeight * 0.7885` (the sprite's aspect ratio) -->
|
|
702
|
+
`containerHeight = containerWidth / 1.5769` --> `150 / 1.5769 ≈ 95.1`, rounded down to keep the image a hair
|
|
703
|
+
narrower than its box rather than a hair wider (a sub-qnt gap, not a clip). Because both numbers now live
|
|
704
|
+
entirely on the same qnt axis, their ratio is a fixed constant baked into the markup -- it holds at *every*
|
|
705
|
+
`canvasWidth/canvasHeight`, not just one coincidental value. The cost: this element's on-screen size now scales
|
|
706
|
+
with canvas width only (consistent with the rest of the qnt system), no longer with canvas height the way
|
|
707
|
+
`qss-h-pct` intended -- an acceptable tradeoff here since nothing about this particular logo needed
|
|
708
|
+
height-relative scaling in the first place.
|
|
709
|
+
|
|
710
|
+
**Proposed for the next iteration.** This strengthens the case for section 15's fix (a real
|
|
711
|
+
`canvas_aspect_ratio`, so canvas shape is never arbitrary and both qnt and `%`-of-canvas-height stay in a fixed,
|
|
712
|
+
known relationship) but also suggests a second, smaller, independent gap worth considering alongside it: QSS
|
|
713
|
+
currently has no first-class way to say "this element's width and height must stay in a fixed ratio to each
|
|
714
|
+
other," only width-relative (`qss-w`/`qss-pos-x`) and height-relative (`qss-h-pct`/`qss-pos-y-pct`) units that
|
|
715
|
+
are each relative to a *different* canvas dimension, and (per the containment finding above) no way to derive
|
|
716
|
+
one axis from actual rendered content either. A bracket class built on the native CSS `aspect-ratio` property
|
|
717
|
+
(something like `qss-aspect[77:100]`) would let an actor declare its own intrinsic ratio directly, independent
|
|
718
|
+
of canvas shape, and -- importantly -- `aspect-ratio` is an intrinsic-sizing CSS property, not content-based
|
|
719
|
+
auto-sizing, so it is **not** subject to the inline-size containment problem above; it would sidestep the whole
|
|
720
|
+
issue rather than require consumers to hand-solve a qnt pair like this app just did. Worth scoping as a
|
|
721
|
+
follow-on to section 15 rather than folded into it, since it solves a different case (an element's own internal
|
|
722
|
+
ratio, vs. the canvas's own ratio).
|
|
723
|
+
|
|
724
|
+
**Open questions for whoever picks this up.**
|
|
725
|
+
- Should `qss-aspect[W:H]` interact with `qss-w[N]`/`qss-h[N]` (one given, the other derived) or be mutually
|
|
726
|
+
exclusive with explicit width/height classes? CSS `aspect-ratio` already defines this precedence natively
|
|
727
|
+
(an explicit `width` or `height` wins, `aspect-ratio` only fills in the missing one), so the bracket class
|
|
728
|
+
likely just needs to expose that existing behavior rather than invent new rules.
|
|
729
|
+
- Whether this is worth a dedicated audit rule in `QSS::QssNativeAuditor` (flagging an actor that mixes a
|
|
730
|
+
width-relative class with a height-relative one on the same element, the exact pattern that caused this) --
|
|
731
|
+
static, so it could catch this class of bug before it ever reaches a browser, unlike section 15's.
|
|
732
|
+
- Whether the containment finding belongs in `QSS-MANIFESTO.md` on its own, independent of the aspect-ratio
|
|
733
|
+
question -- "no actor may rely on shrink-to-fit/auto sizing from its content, in any axis" is a general rule
|
|
734
|
+
any consumer can trip over (not just this aspect-ratio case), and nothing currently documents it or warns
|
|
735
|
+
against it before a consumer tries it and gets a 1px sliver.
|
|
736
|
+
|
|
737
|
+
Not built yet.
|
|
738
|
+
|
|
739
|
+
**2026-11-02 update.** The `qss-aspect[W:H]` proposal above still stands — it addresses a genuinely separate
|
|
740
|
+
concern (an element's own intrinsic ratio) from the canvas-shape questions resolved in section 17, and remains a
|
|
741
|
+
good complement to that section's "rigid qnt" tool for any actor (sprite, icon, logo) that needs a fixed shape
|
|
742
|
+
independent of its container. The single-axis diagnosis in this section is folded into section 17's broader model.
|
|
743
|
+
The containment-constraint open question above is resolved: written into `QSS-MANIFESTO.md` section 2 on
|
|
744
|
+
2026-11-02.
|
|
745
|
+
|
|
746
|
+
## 17. 2026-11-02: three tools for proportionality (position vs. size vs. shape), retiring the canvas-ratio lock
|
|
747
|
+
|
|
748
|
+
**The question this closes out.** Sections 15 and 16 diagnosed the single-axis `--qnt` problem at the canvas scale
|
|
749
|
+
and the element scale, and section 15 proposed a canvas-shape-level fix: a configured `canvas_aspect_ratio` plus a
|
|
750
|
+
letterboxed/pillarboxed `#qss-wrapper`/`#qss-canvas` rule. Kraig flagged two hard requirements that rule that fix
|
|
751
|
+
out for how the gem is actually meant to be used: it's built primarily for PWA-style apps, where the canvas must
|
|
752
|
+
fill every real window at every resolution with zero dev-side configuration, and a locked ratio's gutters —
|
|
753
|
+
visible pillarboxing/letterboxing — are not an acceptable tradeoff to get there, full stop (2026-11-02). A
|
|
754
|
+
same-day discussion, following a cross-session message from a second PWA app under development (which
|
|
755
|
+
independently hit the same bugs against gem 0.6.0 — see that app's own dev notes), worked through several
|
|
756
|
+
plausible-looking alternatives before landing on the actual fix.
|
|
757
|
+
Recording the rejected paths here so none of them get re-proposed blind.
|
|
758
|
+
|
|
759
|
+
**Rejected approaches, and why.**
|
|
760
|
+
|
|
761
|
+
1. **Splitting `--qnt` into `--qnt-x`/`--qnt-y`** (canvas-width- vs. canvas-height-derived, via CSS's `cqw`/`cqh`
|
|
762
|
+
container query units). Makes `qss-pos-y[N]` finally proportional to the canvas's *actual* height instead of its
|
|
763
|
+
width — but breaks the one guarantee the single scalar exists for: two elements positioned on different axes
|
|
764
|
+
stop holding their relative position to each other as the canvas reshapes, reintroducing "ordinary responsive
|
|
765
|
+
CSS's guess-which-property-moved problem" inside QSS's own unit system. Proposed and rejected twice,
|
|
766
|
+
independently: once in that app's own session on 2026-11-01, once again (unknowingly, by this repo's own
|
|
767
|
+
session) on 2026-11-02, before the first rejection's reasoning surfaced. The gem's existing `qss-*-pct[N]`
|
|
768
|
+
classes (plain CSS percentage of the immediate containing block) are mathematically the identical split, just
|
|
769
|
+
expressed without a second custom property, and carry the same cross-axis drift cost whenever used as a blanket
|
|
770
|
+
replacement for qnt rather than deliberately, per element (see tool 2 below).
|
|
771
|
+
2. **Section 15's `canvas_aspect_ratio` + `min()`-based letterbox/pillarbox.** Actually built, as that app's own
|
|
772
|
+
app-level stopgap (2026-11-01), then reverted the same day after producing visible pillarboxing on a real
|
|
773
|
+
2560x1298 window locked to 16:10. Ruled out in principle the next day for the reason above: any visible gutter
|
|
774
|
+
is unacceptable for full-bleed PWA use, independent of whether the lock is a fixed square or a configurable
|
|
775
|
+
ratio.
|
|
776
|
+
3. **`max()`-based "cover" scaling** (the mirror image of 2: fill the viewport with no gutters, crop whatever
|
|
777
|
+
canvas content overflows instead). Satisfies "full-bleed, no black boxes," but conflates two different kinds of
|
|
778
|
+
content. It's an acceptable tradeoff for a design with a genuine center and only decorative margin near its
|
|
779
|
+
edges (a book-style, centered layout); it is not acceptable for a design where the edges carry load-bearing,
|
|
780
|
+
non-decorative UI (global nav, a file-tree explorer, a toolbar docked to the side) — there's no sacrificial
|
|
781
|
+
margin there to crop. Rejected as a canvas-wide default for that reason; QSS has consumers of both kinds.
|
|
782
|
+
4. **A static `QssNativeAuditor` rule** bounding `pos-y + h` (or resolved `h-pct`) against a configured worst-case
|
|
783
|
+
aspect ratio. Zero CSS cost — pure build-time arithmetic, same category as the gem's existing structural
|
|
784
|
+
checks — and deliberately avoids the stylesheet-bloat risk an equivalent `@media`-breakpoint-based mechanism
|
|
785
|
+
would carry (`qss_core.css` is a real, confirmed 5.16MB today; section 12.1 already flags roughly 32,000
|
|
786
|
+
responsive-breakpoint rules from that same family of mechanism, though most of the compiled weight is the
|
|
787
|
+
palette-driven decorative generation per section 13). Rejected anyway, not for cost but for effectiveness: it's
|
|
788
|
+
advisory only, with a hard, silent cliff at whatever ratio is configured — a window narrower than that is
|
|
789
|
+
simply unchecked, right back to the original off-canvas-bottom failure. Superseded by tool 1 below, which
|
|
790
|
+
removes the overflow risk structurally instead of flagging it after the fact.
|
|
791
|
+
|
|
792
|
+
**The actual fix: three tools, matched to three kinds of content — not one canvas-wide policy.**
|
|
793
|
+
|
|
794
|
+
### 17.1 Rigid qnt — unchanged, for content that must move together as one image
|
|
795
|
+
Exactly today's system, no change: `qss-pos-x[N]`/`qss-pos-y[N]`/`qss-w[N]`/`qss-h[N]`, all off the single
|
|
796
|
+
canvas-width-derived `--qnt`. Use this for any composition where the relative position between elements *is* the
|
|
797
|
+
point — a toolbar's own internal button spacing, a tightly-grouped cluster, a canvas-grid layout meant to tile
|
|
798
|
+
against siblings. This is section 10's original guarantee ("a canvas shown at half width keeps its contents in
|
|
799
|
+
proportion") and it still holds, exactly, for anything that stays entirely on this axis.
|
|
800
|
+
|
|
801
|
+
### 17.2 Clamped percentage-of-parent spacing — for selectively elastic gaps, not a canvas-wide replacement
|
|
802
|
+
For spacing that's allowed to flex gracefully rather than hold rigidly — headroom above/below a cluster, the gap
|
|
803
|
+
between two groups, anything closer to a margin than to a structural relationship — express it as a percentage
|
|
804
|
+
of the immediate containing block's actual rendered size (reusing the existing `qss-pos-y-pct[N]`/`qss-h-pct[N]`
|
|
805
|
+
classes, or a new composite adjective wrapping the value in `clamp()` so the elastic range stays bounded, the same
|
|
806
|
+
`clamp(floor, value, cap)` pattern already shipping for `--qnt`'s own extreme-viewport dampening, section 11). The
|
|
807
|
+
analogy that settled this (Kraig, 2026-11-02): the rule of thirds in video framing — a subject's horizontal
|
|
808
|
+
position stays fixed and deliberate, while the headroom above their head is allowed to vary with the frame's actual
|
|
809
|
+
shape, within a visually sane range, without looking broken. The critical distinction from the rejected qnt-split
|
|
810
|
+
(item 1 above): this is applied *selectively*, to values a designer deliberately marks as elastic, not
|
|
811
|
+
*systemically* to every `pos-y`/`h` in the grammar — the rigid skeleton (17.1) stays exactly as proportional as
|
|
812
|
+
it is today; only the chosen gaps breathe.
|
|
813
|
+
|
|
814
|
+
**Built 2026-11-02.** Not the single 3-param bracket sketched above — that would need every min×pct×max
|
|
815
|
+
combination precompiled, the same combinatorial problem the registry-based `.qss-Button[W|H]` blueprints sidestep
|
|
816
|
+
by being JS-engine-only (confirmed while implementing: `.qss-Button[...]` has no compiled-CSS counterpart at all,
|
|
817
|
+
only a `qss-engine.js` runtime path), which would make a feature this foundational silently do nothing without the
|
|
818
|
+
optional engine loaded. Shipped instead as three composable single-value classes feeding one fixed trigger class,
|
|
819
|
+
each an ordinary `@for`-loop atomic adjective exactly like `qss-pos-y[N]`: `qss-elastic-y-min[N]` and
|
|
820
|
+
`qss-elastic-y-max[N]` set `--qss-ey-min`/`--qss-ey-max` to `calc(N * var(--qnt))`, `qss-elastic-y-pct[N]` sets
|
|
821
|
+
`--qss-ey-pct` to a percentage (same 0.1%-per-unit scale as `*-pct[N]`), and the bare `qss-elastic-y` trigger class
|
|
822
|
+
reads all three into `top: clamp(var(--qss-ey-min, 0px), var(--qss-ey-pct, 0%), var(--qss-ey-max, 0px))`. Fully
|
|
823
|
+
CSS-only, zero JS dependency — the same stack-many-single-purpose-classes idiom the rest of the grammar already
|
|
824
|
+
uses, just applied across four classes on one element instead of one. Verified in a real browser
|
|
825
|
+
(`qss-core-render-check`): a parent resized to 100/300/1000px against `min[20]`/`pct[100]`/`max[80]` produces
|
|
826
|
+
`top` of exactly 20/30/80px — the clamp floors, tracks, and caps against the parent's actual rendered height, not
|
|
827
|
+
just that the math compiles.
|
|
828
|
+
|
|
829
|
+
### 17.3 Viewport-anchored chrome — for edge-docked functional elements that must never crop
|
|
830
|
+
For content that's genuinely edge-docked and load-bearing — global nav, a file-tree explorer, a toolbar pinned
|
|
831
|
+
to the side (Parapet's layout is the motivating case; the second PWA app's current landing page has none) — don't treat
|
|
832
|
+
it as canvas content at all. Root it in a portal-style layer: `#qss-portal` already is exactly this mechanism
|
|
833
|
+
(`position: fixed; inset: 0`, its own `--qnt` resolved from the *viewport's* `cqw` rather than the canvas's, per the
|
|
834
|
+
existing doc comment in `qss_core.scss`), used today for transient overlays; the same properties make it equally
|
|
835
|
+
suited to persistent structural chrome. Whether persistent chrome shares the literal `#qss-portal` root or gets a
|
|
836
|
+
second, parallel root using the identical pattern (cleaner separation of "ephemeral overlay" from "always-present
|
|
837
|
+
dock," at the cost of one more root) is an open question below, not resolved here.
|
|
838
|
+
|
|
839
|
+
Inside that layer, three further sub-rules, confirmed in session discussion:
|
|
840
|
+
- **The chrome root's own placement** uses real CSS edges (`top: 0`, `left: 0`, `height: 100vh`, etc.), never qnt
|
|
841
|
+
position classes — its placement needs no proportional math at all, since it's always exactly at the true edge
|
|
842
|
+
by definition, at any shape.
|
|
843
|
+
- **Each element's own size** (an icon, a button) stays qnt-based, scaling proportionally with the real viewport
|
|
844
|
+
— visually consistent with the rest of the UI's scale at any resolution, exactly as canvas content does.
|
|
845
|
+
- **Position within the chrome root** (e.g., "this button cluster sits 70-80% down the nav bar, vertically
|
|
846
|
+
centered") uses percentage-of-immediate-parent (17.2's mechanism, nested one level deeper) for the band's
|
|
847
|
+
placement, plus ordinary flexbox (`justify-content: center`/`align-items: center`) for centering within it —
|
|
848
|
+
both auto-recalculate against the chrome root's actual current size, with no special-casing needed regardless of
|
|
849
|
+
how tall the real viewport is.
|
|
850
|
+
|
|
851
|
+
**Open questions for whoever picks this up.**
|
|
852
|
+
- ~~Exact syntax for 17.2's clamped elastic classes~~ — resolved and built 2026-11-02; see the "Built" note above.
|
|
853
|
+
- ~~Whether persistent chrome shares `#qss-portal` with transient overlays or gets its own root~~ — resolved
|
|
854
|
+
2026-11-02: it shares `#qss-portal`. A second root would only buy anything for one narrow case — keeping chrome
|
|
855
|
+
visually above the z-index *fallback* overlay path (native top-layer `<dialog>`/`popover` always wins regardless
|
|
856
|
+
of which root anything lives in, so a second root can't outrank those either way) — and Kraig confirmed there's
|
|
857
|
+
no scenario where a toolbar should take visual precedence over a modal/popup, which are designed to take over
|
|
858
|
+
the user's entire attention. So: no second root, no new config key, no new SCSS. The only thing this decision
|
|
859
|
+
requires, not yet written down anywhere consumer-facing, is the DOM-order contract it depends on — overlay-
|
|
860
|
+
mounting code must always *append* to `#qss-portal`, never insert at a fixed index, so later-added overlays keep
|
|
861
|
+
painting over earlier, static chrome (stacking here is pure DOM paint order, no z-index, section 12.9).
|
|
862
|
+
|
|
863
|
+
While verifying this, found and fixed a real, pre-existing gap: `qss-engine.js`'s `applyContainerNames` only ever
|
|
864
|
+
scanned `#qss-canvas`, never `#qss-portal`, so no portal content — existing ephemeral overlays or new persistent
|
|
865
|
+
chrome — got its automatic container-name, silently breaking Tailwind's named container-query variants
|
|
866
|
+
(`@sm/<frame-name>:`) on anything rooted there. Not a deliberate canvas-only design: Kraig's framing (2026-11-02)
|
|
867
|
+
is that the portal is the base for an entire layout's viewport-anchored chrome, not a secondary concern bolted
|
|
868
|
+
onto the canvas — `#qss-canvas` is for internal elements that mimic a browser window of their own, not for the
|
|
869
|
+
whole layout. Fixed by widening `applyContainerNames` to scan both roots; covered by a new
|
|
870
|
+
`qss-engine-render-check` case (an actor inside `#qss-portal` gets its own container-name). The chrome-docking
|
|
871
|
+
convention and its append-only DOM-order contract are now written up in the README ("Persistent chrome: dock it
|
|
872
|
+
in the portal root too"), with a `qss-core-render-check` case proving a docked sidebar stays pinned full-height
|
|
873
|
+
with a qnt-tracking width as the viewport reshapes.
|
|
874
|
+
- Whether `QssNativeAuditor` should gain a rule flagging an actor that mixes qnt position classes with the
|
|
875
|
+
viewport-anchored chrome pattern (i.e., catches someone reaching for `qss-pos-y[N]` on something that should have
|
|
876
|
+
been chrome) — a narrower, more clearly-scoped version of the rejected item 4 above, now checking for a
|
|
877
|
+
category mistake rather than trying to bound an inherently unbounded canvas shape.
|
|
878
|
+
- `qss-aspect[W:H]` (section 16) is unaffected by any of this and remains open on its own merits — it's a
|
|
879
|
+
per-element intrinsic-ratio concern, orthogonal to which of the three tools above positions or sizes that
|
|
880
|
+
element.
|
|
881
|
+
|
|
882
|
+
Not built yet.
|