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/docs/HOWTO.md ADDED
@@ -0,0 +1,269 @@
1
+ # How to use QSS
2
+
3
+ A step-by-step walkthrough from installing the gem to a fully audited layout with overlays and persistent chrome.
4
+ For the pitch and a 60-second taste, see [README.md](../README.md). For *why* QSS looks the way it does — including
5
+ the approaches that were tried and rejected — see [DESIGN_HISTORY.md](DESIGN_HISTORY.md).
6
+
7
+ ## Step 1: Install
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "qss"
12
+ ```
13
+
14
+ The gem's Rails engine puts QSS's assets on your asset pipeline (`qss/qss_core.css`, `qss/qss_core.scss`,
15
+ `qss/qss-engine.js`). Everything else in QSS — the auditors, configuration — works in plain Ruby too; Rails is only
16
+ needed for the asset pipeline.
17
+
18
+ ## Step 2: Load the stylesheet
19
+
20
+ The simplest path, QSS's neutral defaults:
21
+
22
+ ```erb
23
+ <%= stylesheet_link_tag "qss/qss_core" %>
24
+ ```
25
+
26
+ To use your own settings (your palette, a different portal id, and so on — see Step 5), set your values first and
27
+ import core after; core's settings are `!default`, so yours win. Put `QSS.stylesheets_path` on your Sass load path
28
+ so `qss/qss_core` resolves:
29
+
30
+ ```scss
31
+ // app/assets/stylesheets/application.scss
32
+ @import "qss_variables"; // generated from your config by qss-sync -- see Step 5
33
+ @import "qss/qss_core";
34
+ ```
35
+
36
+ ```ruby
37
+ Sass.compile("app/assets/stylesheets/application.scss",
38
+ load_paths: ["app/assets/stylesheets", QSS.stylesheets_path])
39
+ ```
40
+
41
+ ## Step 3: Write your first layout
42
+
43
+ The canvas id is fixed: `#qss-canvas` is not configurable, so a document has exactly one top-level QSS canvas. Two
44
+ independent QSS instances nested in the same page would collide on it; isolate them in separate documents (an
45
+ `<iframe>`) instead. This is a known constraint, not something QSS will build support for.
46
+
47
+ ### The canvas, `<body>`, and everything else
48
+
49
+ QSS resets `html, body` globally (`margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden;`), so the
50
+ canvas's own `height: 100%` has something real, non-zero to measure against. That reset only reaches as far as
51
+ `<body>` itself, though: if you nest the canvas inside other wrapper elements — a layout's own container divs,
52
+ say — size those wrappers too (`height: 100%` down the chain), or just put `#qss-canvas` directly inside `<body>`,
53
+ which is the common case and needs nothing extra. Both `<body>` and the canvas clip rather than scroll by
54
+ default (`overflow: hidden`) — QSS assumes a full-bleed, app-style viewport, not a scrolling document. Opt into
55
+ scrolling deliberately with `qss-scroll` or `qss-scroll-smooth` wherever you actually want it.
56
+
57
+ Everything *outside* `#qss-canvas` (and outside the optional overlay portal, Step 7) is completely untouched by
58
+ QSS. Every single rule QSS generates is scoped to being a descendant of one of those two roots, so anything else
59
+ you put in `<body>` — a cookie banner, an analytics widget, a completely separate part of the page — renders under
60
+ ordinary CSS exactly as if QSS weren't installed at all.
61
+
62
+ The same is true one level down, *inside* the canvas: only elements that actually carry a QSS class (`qss`, or any
63
+ `qss-` prefixed utility) are ever touched. QSS's reset specifically targets `.qss`-classed elements
64
+ (`:where(.qss:not(.foreign *))` in the compiled CSS) — nothing broader — so a plain `<div>` with no QSS classes at
65
+ all, dropped anywhere inside the canvas, renders however your own CSS or content framework says it should. For
66
+ content you want to *guarantee* stays untouched even if it happens to collide with a `qss-`-prefixed class name
67
+ from some third-party library, wrap it in `class="foreign"` (or mark the one offending element
68
+ `qss-payload-override`) — QSS's explicit escape hatch, rather than relying on it simply never reaching there.
69
+
70
+ Every element QSS positions carries class `qss` (an **actor**) and lives inside the canvas. An actor's id is its
71
+ **noun** — its identity — and follows `{qualifier}-{baseObject}`: the base object is an HTML tag, the qualifier
72
+ says which one. Declare each qualifier once, in a registry, recording what it adds to its base:
73
+
74
+ ```erb
75
+ <div id="qss-canvas">
76
+ <nav id="main-nav" class="qss qss-relative qss-contain-layout qss-w[200] qss-h-full"> ... </nav>
77
+ <section id="content-section" class="qss qss-relative qss-contain-layout qss-w[800] qss-h-full"> ... </section>
78
+ </div>
79
+ ```
80
+
81
+ ```yaml
82
+ # config/qss_registry.yml (auditor.registry_path in your config, Step 5)
83
+ types:
84
+ main-nav:
85
+ extends: nav
86
+ content-section:
87
+ extends: section
88
+ ```
89
+
90
+ Structural frames (`nav`, `main`, and similar) carry `qss-contain-layout`, which both isolates their internal
91
+ layout and clips their own overflow by default — a frame's content can't spill out and cover its neighbors. This
92
+ example audits fully green (Step 4).
93
+
94
+ Everything an actor needs to be addressable is a single explicit bracket value: `qss-w[200]` is 200 qnt wide —
95
+ 20% of the canvas, at any screen size, since 1 qnt is always `canvas width / 1000`, computed in pure CSS. The same
96
+ pattern covers position (`qss-pos-x[N]`, `qss-pos-y[N]`, `qss-pos-r[N]`, `qss-pos-b[N]`), size (`qss-w[N]`,
97
+ `qss-h[N]`, `qss-min-w[N]`, `qss-min-h[N]`), spacing (`qss-gap[N]`, `qss-p[N]`, `qss-px[N]`, `qss-py[N]`) and
98
+ borders/radius. Percentage-of-parent variants exist too (`qss-w-pct[N]`, `qss-pos-y-pct[N]`, ...) for when you want
99
+ a fraction of the *immediate* containing block instead of the canvas.
100
+
101
+ An actor must always declare both its width and height, in one of these units — never left to shrink-to-fit. Every
102
+ actor is also a CSS size container (`container-type: inline-size`, needed for qnt itself to resolve), and per the
103
+ CSS Containment spec that rules out content-based `auto` sizing in either axis in any browser that implements
104
+ container queries correctly. An actor with no size opts into this deliberately with `qss-content-driven`. See
105
+ [DESIGN_HISTORY.md](DESIGN_HISTORY.md) #16 for why.
106
+
107
+ ## Step 4: Audit your views
108
+
109
+ ```ruby
110
+ result = QSS::QssNativeAuditor.audit_file("app/views/layouts/application.html.erb")
111
+ result[:status] # => "green", "orange" or "red"
112
+ result[:issues] # => [{ type:, message:, severity: }, ...] (plus code: on most identity and unit findings)
113
+ ```
114
+
115
+ RED means a rule is violated: fix the markup, or reconsider the rule. ORANGE is a judgment call. Rules include:
116
+ every actor declares its width and height (or opts out with `qss-content-driven`); structure carries no raw CSS
117
+ lengths (sizes are in qnt or canvas percentages); no `!important`; ids follow the `{qualifier}-{baseObject}`
118
+ grammar; every class literally resolves to a rule in the compiled stylesheet, not just a known shape; a `qss`-marked
119
+ actor carries no class (from a content framework such as Tailwind) that resolves, in `auditor.drywall_stylesheet_path`,
120
+ to a rule setting a structural property (`display`, `position`, sizing, `flex*`/`grid*`, `gap`, `z-index`, `order`)
121
+ — checked by CSS property, so it works for any content framework with no fork.
122
+
123
+ `QSS::QssAuditor` is the older class-whitelist auditor: it predates the bracket vocabulary (it reports `qss-w[200]`
124
+ as unknown) and is kept only for projects that still use its whitelist. Use `QssNativeAuditor`.
125
+
126
+ **Three tiers of stylesheet.** Class resolution reads core's own `qss_core.css` (always, verified against the
127
+ manifest shipped with the gem, raising `QSS::CoreIntegrityError` on a mismatch), any sealed tool stylesheets
128
+ (`auditor.tool_stylesheets`, sealed by the tool's own build with `qss-seal`, raising `QSS::ToolIntegrityError` if
129
+ unsealed or changed), and your own compiled output (`auditor.host_stylesheets`, `stylesheet_path`, or
130
+ `QSS.configure(host_stylesheets: -> { ... })`; a Rails host defaults to `app/assets/builds/*.css`).
131
+
132
+ Appearance classes (colors, type styling, shadows, filters) are **deprecated**: the auditor reports each as orange
133
+ `deprecated_decorative`, since appearance belongs to whatever fills Drywall — your content framework, not QSS.
134
+ QSS's own quantized bracket scale (`qss-border[2]`, `qss-text-size[14]`) is unaffected, since those are structural
135
+ (a border's width, text's footprint), not decorative. A Drywall stylesheet compiled with the `qss` prefix is a red
136
+ `drywall_prefix_collision` — every QSS class is prefixed `qss-` specifically so no content framework sharing the
137
+ canvas can mistake its own unprefixed utility for QSS's.
138
+
139
+ ## Step 5: Configure QSS for your project
140
+
141
+ QSS starts neutral. Declare your own settings in one YAML file and tell QSS where it is:
142
+
143
+ ```ruby
144
+ # config/initializers/qss.rb
145
+ QSS.configure(root: Rails.root, config: "config/qss_config.yml")
146
+ ```
147
+
148
+ ```yaml
149
+ # config/qss_config.yml -- every key is optional; see the gem's config/grammar.yml for the defaults
150
+ config:
151
+ portal_root: "#qss-portal" # overlays and chrome render here (any one #id; default #qss-portal); "" = rules scoped to the canvas only
152
+ palette:
153
+ brand-main: "#0f1115"
154
+ group_names: [menu] # named groups for group-*:hover style classes
155
+ auditor:
156
+ id_prefix: "app-" # optional project id prefix
157
+ registry_path: "config/qss_registry.yml"
158
+ sync_targets:
159
+ variables: app/assets/stylesheets/_qss_variables.scss
160
+ ```
161
+
162
+ Relative paths resolve against the declared `root`, never the working directory.
163
+
164
+ Generate your SCSS settings from that config whenever it changes:
165
+
166
+ ```sh
167
+ QSS_CONSUMER_CONFIG=config/qss_config.yml bundle exec qss-sync
168
+ ```
169
+
170
+ This writes `_qss_variables.scss` (the `sync_targets.variables` path, imported in Step 2). Without
171
+ `QSS_CONSUMER_CONFIG` it reads `config/qss_config.yml` from the current directory.
172
+
173
+ ## Step 6: Add the optional JS engine (if you want it)
174
+
175
+ The compiled CSS from Steps 2–3 is already fully functional with zero JavaScript. The engine adds three runtime
176
+ conveniences on top:
177
+
178
+ - Rules for bracket classes the compiled CSS doesn't precompute (there's no combinatorial way to precompile every
179
+ possible value of a composite blueprint like `qss-Button[200|80]` — see Step 8's note on why `qss-elastic-y`
180
+ took a different shape instead).
181
+ - Exporting measured element edges as CSS variables.
182
+ - Naming each actor's CSS container after its own id (`container-name`, inside both the canvas and the portal), so
183
+ Drywall content inside it can use a named container-query variant — Tailwind's `@sm/<frame-name>:`, for example
184
+ — to react to that specific frame's own width, independent of the canvas or any sibling.
185
+
186
+ ```erb
187
+ <%= javascript_include_tag "qss/qss-engine", defer: true %>
188
+ <script>
189
+ document.addEventListener("DOMContentLoaded", () => { QSSEngine.startPulse(); QSSEngine.startObserver(); });
190
+ </script>
191
+ ```
192
+
193
+ While developing, `QSSEngine.auditPositioning()` lists every element that is `position: fixed` or `sticky` without
194
+ QSS owning it — a loophole no static check can see, since a Drywall `fixed` utility only becomes a position once a
195
+ stylesheet resolves it. Frames, the overlay portal, `foreign` content and the browser's native top layer are
196
+ exempt. It runs only when you call it; `QSSEngine.setPortal("#qss-overlays")` tells it where your portal root is if
197
+ it isn't `#qss-portal`.
198
+
199
+ ## Step 7: Overlays — modals, dropdowns, toasts
200
+
201
+ A modal, dialog, menu or popover belongs outside the canvas entirely, in the **overlay portal root**: the element
202
+ whose id is your configured `portal_root` (any one `#id` selector; `#qss-portal` when none is set), matched
203
+ exactly — no prefix or substring guessing. Core gives it its own geometry automatically (a fixed, viewport-sized
204
+ layer, `pointer-events: none` on the root and `auto` on its direct children, its own `--qnt` resolved the same way
205
+ as the canvas's), so write no QSS classes on the root element itself — they match nothing there — and place it
206
+ after the canvas in the document so it paints above it with no `z-index` at all.
207
+
208
+ Prefer the browser's native top layer: a `<dialog>` opened with `showModal()`, or any element carrying the
209
+ `popover` attribute. The browser then renders it above everything, genuinely with no `z-index`. A plain element
210
+ placed directly in the portal root (the fallback path) is allowed, but the auditor reports it as orange
211
+ `top_layer_preferred` until you mark it `qss-overlay-fallback`. A `z-index` is permitted only on the portal root
212
+ and inside it (to order one fallback overlay above another, or rely on DOM order alone); anywhere else, Portal
213
+ Integrity makes it red.
214
+
215
+ ## Step 8: Persistent chrome — nav bars, toolbars, sidebars
216
+
217
+ Global nav, a file-tree explorer, a toolbar pinned to a screen edge — anything genuinely edge-docked and
218
+ load-bearing that must never crop as the viewport reshapes — isn't canvas content, and shouldn't use canvas
219
+ position classes (`qss-pos-y[N]` and friends). Dock it as a direct child of the same portal root from Step 7:
220
+
221
+ ```html
222
+ <div id="qss-portal">
223
+ <nav id="chrome-sidebar" class="qss qss-w[64]" style="left: 0; top: 0; height: 100vh;"> ... </nav>
224
+ </div>
225
+ ```
226
+
227
+ Three rules, used together:
228
+
229
+ 1. **The chrome element's own placement uses real CSS edges** (`top: 0; left: 0; height: 100vh;`), never a qnt
230
+ position class — it's always exactly at the true edge by definition, at any viewport shape.
231
+ 2. **Each element's own size still uses qnt** (`qss-w[N]`/`qss-h[N]`), scaling proportionally with the real
232
+ viewport, visually consistent with the rest of the UI at any resolution.
233
+ 3. **Position *within* the chrome** (a button cluster sitting 70–80% down a nav bar) uses `qss-elastic-y-*`
234
+ (Step 9) or `*-pct[N]` plus ordinary flexbox, nested one level deeper.
235
+
236
+ There is deliberately no second, separate root for this: persistent chrome and ephemeral overlays share
237
+ `#qss-portal`, because stacking there is pure DOM paint order with no `z-index` (Step 7) — a native top-layer
238
+ overlay always paints above everything regardless of which root anything lives in, and a modal or popover is
239
+ meant to take over the user's entire attention, so chrome floating above it is never the right default. The one
240
+ contract this depends on: **always append** new overlays to the portal, never insert at a fixed index, so
241
+ later-added overlays keep painting over earlier, static chrome. See
242
+ [DESIGN_HISTORY.md](DESIGN_HISTORY.md) #17.3.
243
+
244
+ ## Step 9: Elastic spacing — gaps that flex, without breaking proportion
245
+
246
+ Most spacing should stay rigidly proportional to the canvas (`qss-pos-y[N]`, Step 3) — that's the default, and
247
+ it's what keeps a whole composition moving together as one image at any screen size. But some gaps are more like
248
+ a margin than a structural relationship: the headroom above a centered cluster, say, which can reasonably grow or
249
+ shrink a bit as the frame's shape changes, the way a video's headroom above a subject's head is allowed to vary
250
+ with the frame shape while the subject's own position stays fixed (the rule-of-thirds analogy this is named for).
251
+
252
+ For those, stack four classes on one element — three value-setters plus a trigger:
253
+
254
+ ```html
255
+ <div class="qss qss-elastic-y-min[20] qss-elastic-y-pct[100] qss-elastic-y-max[80] qss-elastic-y"> ... </div>
256
+ ```
257
+
258
+ This computes `top: clamp(20 * qnt, 10%, 80 * qnt)` against the element's immediate containing block: it tracks
259
+ 10% of the parent's actual height, but never drops below 20 qnt or rises above 80 qnt, however that parent
260
+ reshapes. (It's four classes rather than one `qss-elastic-y[min|pct|max]` bracket because a three-parameter
261
+ bracket can't be precompiled — it would need every min×pct×max combination generated in advance. Composing single-
262
+ value classes, the same idiom the rest of the grammar already uses, keeps this working with zero JavaScript.) See
263
+ [DESIGN_HISTORY.md](DESIGN_HISTORY.md) #17.2 for the full reasoning.
264
+
265
+ ## Where to go from here
266
+
267
+ - [QSS-MANIFESTO.md](../QSS-MANIFESTO.md) — the full grammar: nouns, adjectives, parametric blueprints.
268
+ - [DESIGN_HISTORY.md](DESIGN_HISTORY.md) — why QSS looks the way it does, including what was tried and discarded.
269
+ - [CHANGELOG.md](../CHANGELOG.md) — what changed, release by release.
@@ -41,10 +41,14 @@ HTML = <<~HTML
41
41
  <div id="host" class="qss host-static"></div>
42
42
  <div class="foreign"><div id="in-foreign" class="qss"></div></div>
43
43
  <div id="nearer-container" class="qss qss-w[100] qss-h[10]" style="container-type: inline-size"><div id="deep" class="qss"></div></div>
44
+ <div id="elastic-parent" class="qss qss-w[400] qss-h[300]" style="position: relative;">
45
+ <div id="elastic-actor" class="qss qss-w[50] qss-h[50] qss-elastic-y-min[20] qss-elastic-y-pct[100] qss-elastic-y-max[80] qss-elastic-y"></div>
46
+ </div>
44
47
  </div>
45
48
  <div id="qss-portal" class="qss-absolute qss-inset-0">
46
49
  <div id="portal-actor" class="qss qss-w[640] qss-h[420] qss-pos-r[16] qss-pos-b[52]"></div>
47
50
  <div id="portal-plain" class="host-static"></div>
51
+ <div id="chrome-sidebar" class="qss qss-w[64]" style="left: 0; top: 0; height: 100vh;"></div>
48
52
  </div>
49
53
  </body></html>
50
54
  HTML
@@ -145,6 +149,25 @@ Dir.mktmpdir do |dir|
145
149
  puts format("%-4s %-56s %s", ok ? "ok" : "FAIL", label, detail)
146
150
  end
147
151
 
152
+ # Persistent chrome docked in the portal (DESIGN_HISTORY #17.3, the README "Persistent chrome" convention):
153
+ # real-edge placement (left:0, top:0, height:100vh), qnt-sized width, at two very different viewport heights --
154
+ # proving it stays pinned with zero cropping and its width tracks the real viewport, not a fixed root.
155
+ [ [ 1400, 1000 ], [ 1400, 500 ] ].each do |w, h|
156
+ driver.manage.window.resize_to(w, h)
157
+ sidebar = driver.execute_script(<<~JS)
158
+ const el = document.getElementById('chrome-sidebar');
159
+ const b = el.getBoundingClientRect();
160
+ return { l: b.left, t: b.top, w: b.width, h: b.height,
161
+ qnt: parseFloat(getComputedStyle(el).getPropertyValue('--qnt')),
162
+ vh: window.innerHeight };
163
+ JS
164
+ ok = near.(sidebar["l"], 0) && near.(sidebar["t"], 0) && near.(sidebar["h"], sidebar["vh"]) && near.(sidebar["w"], 64 * sidebar["qnt"])
165
+ total += 1
166
+ failures += 1 unless ok
167
+ puts format("%-4s %-56s %s", ok ? "ok" : "FAIL", "docked chrome stays pinned full-height at viewport #{h}px",
168
+ "l=#{sidebar['l'].round(1)} t=#{sidebar['t'].round(1)} h=#{sidebar['h'].round(1)} w=#{sidebar['w'].round(1)} (qnt #{sidebar['qnt']})")
169
+ end
170
+
148
171
  # The unit: one thousandth of the canvas width, from CSS alone, measured against the canvas even inside a nearer
149
172
  # size container; outside a canvas it approximates from the viewport.
150
173
  qnt = ->(id) { driver.execute_script("return getComputedStyle(arguments[0] ? document.getElementById(arguments[0]) : document.documentElement).getPropertyValue('--qnt').trim()", id) }
@@ -171,6 +194,21 @@ Dir.mktmpdir do |dir|
171
194
  failures += 1 unless ok
172
195
  puts format("%-4s %-56s %s", ok ? "ok" : "FAIL", "text-size-linear[3]/[96] scale linearly at #{vw}px", sizes.map { |v| v.round(3) }.inspect)
173
196
  end
197
+ # Clamped percentage-of-parent spacing (DESIGN_HISTORY #17.2): qss-elastic-y-min[20]/pct[100]/max[80] on a
198
+ # 400x300 actor's parent, canvas at 1000px (qnt=1px) so min=20px/max=80px exactly; pct[100] is 10% of the
199
+ # parent's actual height. Resizing the parent alone (not the canvas) proves the clamp tracks the immediate
200
+ # containing block's real size, floors below it, and caps above it -- not just that the middle value computes.
201
+ # Reset the canvas width first: the qnt sweep above leaves it at 5000px (qnt dampened to 3.2px).
202
+ driver.execute_script("document.getElementById('qss-canvas').style.width = '1000px'")
203
+ [ [ 100, 20 ], [ 300, 30 ], [ 1000, 80 ] ].each do |parent_h, expected_top|
204
+ driver.execute_script("document.getElementById('elastic-parent').style.height = arguments[0] + 'px'", parent_h)
205
+ actual = driver.execute_script("return parseFloat(getComputedStyle(document.getElementById('elastic-actor')).top)")
206
+ ok = near.(actual, expected_top)
207
+ total += 1
208
+ failures += 1 unless ok
209
+ puts format("%-4s %-56s top=%s%s", ok ? "ok" : "FAIL", "elastic-y clamp at parent height #{parent_h}px", actual, ok ? "" : " (expected #{expected_top})")
210
+ end
211
+
174
212
  viewport = driver.execute_script("return window.innerWidth")
175
213
  expected = "#{(viewport * 0.001).round(4).to_s.sub(/\.0\z/, '')}px"
176
214
  actual = qnt.(nil)
@@ -29,7 +29,10 @@ HTML = <<~HTML
29
29
  <div class="foreign"><div id="foreign-fixed" style="position: fixed"></div></div>
30
30
  </div>
31
31
  <div id="body-fixed" style="position: fixed"></div>
32
- <div id="qss-portal"><div id="portal-fixed" style="position: fixed"></div></div>
32
+ <div id="qss-portal">
33
+ <div id="portal-fixed" style="position: fixed"></div>
34
+ <div id="portal-auto-name" class="qss qss-w[200] qss-h[100]"></div>
35
+ </div>
33
36
  <dialog id="top-layer-modal"></dialog>
34
37
  </body></html>
35
38
  HTML
@@ -69,7 +72,10 @@ Dir.mktmpdir do |dir|
69
72
 
70
73
  [
71
74
  [ "the engine names an actor's container after its own id", "auto-name", "auto-name" ],
72
- [ "the engine never overwrites a container-name already set by anything", "explicit-name", "custom-name" ]
75
+ [ "the engine never overwrites a container-name already set by anything", "explicit-name", "custom-name" ],
76
+ # DESIGN_HISTORY #17.3: the portal is where an entire layout's viewport-anchored chrome lives, not a
77
+ # secondary concern -- its actors need the same named container-query support canvas frames get.
78
+ [ "the engine also names an actor's container inside the portal root", "portal-auto-name", "portal-auto-name" ]
73
79
  ].each do |label, id, expected|
74
80
  actual = computed_name.(id)
75
81
  ok = actual == expected
@@ -33,6 +33,10 @@ module QSS
33
33
  'qss-cursor-pointer',
34
34
  'qss-cursor-row-resize',
35
35
  'qss-dev-border',
36
+ 'qss-elastic-y',
37
+ 'qss-elastic-y-max',
38
+ 'qss-elastic-y-min',
39
+ 'qss-elastic-y-pct',
36
40
  'qss-ergonomic-override',
37
41
  'qss-evenly',
38
42
  'qss-fixed',
@@ -114,7 +118,7 @@ module QSS
114
118
 
115
119
  PAYLOAD_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "blockquote", "li"].freeze
116
120
 
117
- ADJECTIVE_CATEGORIES = {"positional"=>["qss-absolute", "qss-fixed", "qss-sticky", "qss-pos-x", "qss-pos-y", "qss-pos-r", "qss-pos-b", "qss-pos"], "flow_relative"=>["qss-relative"], "layout"=>["qss-flex-col", "qss-flex-row", "qss-centered", "qss-v-start", "qss-items-center", "qss-v-end", "qss-h-start", "qss-justify-center", "qss-h-end", "qss-justify-between", "qss-around", "qss-evenly", "qss-flex", "qss-flex-wrap", "qss-flex-nowrap", "qss-flex-1", "qss-flex-auto", "qss-flex-none", "qss-grid", "qss-table-fixed", "qss-contain-layout", "qss-w-full", "qss-h-full", "qss-w-f", "qss-h-f"], "typographic"=>["qss-italic", "qss-uppercase", "qss-lowercase", "qss-underline", "qss-nowrap", "qss-truncate", "qss-font-mono", "qss-font-sans", "qss-font-medium", "qss-font-bold", "qss-font-black", "qss-text-size", "qss-text-size-linear", "qss-leading", "qss-tracking", "qss-tracking-tighter", "qss-tracking-wide", "qss-tracking-wider", "qss-tracking-widest", "qss-icon-size"], "visual"=>["qss-border", "qss-border-t", "qss-border-b", "qss-border-l", "qss-border-r", "qss-rounded", "qss-rounded-full", "qss-shadow", "qss-opacity", "qss-blur", "qss-bright", "qss-contrast", "qss-grayscale", "qss-hue", "qss-invert", "qss-saturate", "qss-sepia", "qss-blend", "qss-contain", "qss-cover", "qss-visible", "qss-invisible", "qss-bg-alpha", "qss-border-alpha", "qss-text-alpha"], "interactive"=>["qss-cursor-pointer", "qss-transition-all", "qss-busy", "qss-ghost", "qss-unselectable", "qss-scroll-smooth", "qss-scroll", "qss-cursor-col-resize", "qss-cursor-row-resize", "qss-transition-colors", "qss-transition-none", "qss-ring", "qss-resize", "qss-outline-none"], "internal"=>["qss", "qss-dev-border", "group", "foreign", "qss-legibility-override", "qss-ergonomic-override", "qss-payload-override", "qss-paint-override", "qss-content-driven", "qss-overlay-fallback"]}.freeze
121
+ ADJECTIVE_CATEGORIES = {"positional"=>["qss-absolute", "qss-fixed", "qss-sticky", "qss-pos-x", "qss-pos-y", "qss-pos-r", "qss-pos-b", "qss-pos", "qss-elastic-y-min", "qss-elastic-y-pct", "qss-elastic-y-max", "qss-elastic-y"], "flow_relative"=>["qss-relative"], "layout"=>["qss-flex-col", "qss-flex-row", "qss-centered", "qss-v-start", "qss-items-center", "qss-v-end", "qss-h-start", "qss-justify-center", "qss-h-end", "qss-justify-between", "qss-around", "qss-evenly", "qss-flex", "qss-flex-wrap", "qss-flex-nowrap", "qss-flex-1", "qss-flex-auto", "qss-flex-none", "qss-grid", "qss-table-fixed", "qss-contain-layout", "qss-w-full", "qss-h-full", "qss-w-f", "qss-h-f"], "typographic"=>["qss-italic", "qss-uppercase", "qss-lowercase", "qss-underline", "qss-nowrap", "qss-truncate", "qss-font-mono", "qss-font-sans", "qss-font-medium", "qss-font-bold", "qss-font-black", "qss-text-size", "qss-text-size-linear", "qss-leading", "qss-tracking", "qss-tracking-tighter", "qss-tracking-wide", "qss-tracking-wider", "qss-tracking-widest", "qss-icon-size"], "visual"=>["qss-border", "qss-border-t", "qss-border-b", "qss-border-l", "qss-border-r", "qss-rounded", "qss-rounded-full", "qss-shadow", "qss-opacity", "qss-blur", "qss-bright", "qss-contrast", "qss-grayscale", "qss-hue", "qss-invert", "qss-saturate", "qss-sepia", "qss-blend", "qss-contain", "qss-cover", "qss-visible", "qss-invisible", "qss-bg-alpha", "qss-border-alpha", "qss-text-alpha"], "interactive"=>["qss-cursor-pointer", "qss-transition-all", "qss-busy", "qss-ghost", "qss-unselectable", "qss-scroll-smooth", "qss-scroll", "qss-cursor-col-resize", "qss-cursor-row-resize", "qss-transition-colors", "qss-transition-none", "qss-ring", "qss-resize", "qss-outline-none"], "internal"=>["qss", "qss-dev-border", "group", "foreign", "qss-legibility-override", "qss-ergonomic-override", "qss-payload-override", "qss-paint-override", "qss-content-driven", "qss-overlay-fallback"]}.freeze
118
122
 
119
123
  ILLEGAL_COMBOS = [{"tag_in"=>"payload_tags", "forbid_category"=>"positional", "severity"=>"red", "message"=>"Protocol Violation: Payload tags (<tag>) cannot carry flow-breaking positional adjectives. Use a wrapping <div> Frame.", "escape_hatch"=>"qss-payload-override"}].freeze
120
124
 
data/lib/qss/version.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module QSS
4
4
  # The gem's version. QSS::VERSION is the usual spelling; QSS::Version::STRING the same value.
5
5
  module Version
6
- STRING = "0.6.0"
6
+ STRING = "0.7.0"
7
7
  end
8
8
 
9
9
  VERSION = Version::STRING
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kraig Fairhurst
@@ -94,13 +94,15 @@ dependencies:
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  description: |-
97
- AI coding agents struggle with CSS. Asked to move a sidebar 10px, an agent edits a margin, then an order, then a flex-basis, and something else moves. It then reports the change as done while the page is broken. Flex and grid have no single "left" property, so the agent has to guess which one to touch.
97
+ Ask an AI coding agent to move a button 10 pixels left, and watch it struggle. CSS gives it five or six different ways to control position: margin, padding, a flex order, a grid column, a transform. Nothing says which one is actually in charge on this particular page, so the agent guesses, edits the wrong one, and something else quietly shifts. It reports the change as done. The page is now broken.
98
98
 
99
- QSS makes layout addressable. Every structural value is an explicit, single-purpose quantity on a 1000-unit canvas grid (1 qnt = canvas width / 1000, computed in pure CSS), so each edit has one target and the same numbers hold at any screen size. Frames sit flat on one canvas, isolate their layout, and overlays portal to the document root.
99
+ QSS removes the guessing by removing the choice. Every element's position and size is one explicit number, not a tug-of-war between properties. Those numbers are written in a single unit, a "qnt" (1/1000th of the layout's own width), so the same edit holds its exact proportion at any screen size -- a phone, a laptop, a 4K display -- with no breakpoints, no media queries, and no separate rules to keep in sync. Move something once and it stays put, everywhere.
100
100
 
101
- QSS also audits what can be checked statically. Two auditors flag violations such as !important, inline layout styles, undeclared sizes and raw viewport units. RED marks a rule violation and ORANGE a judgment call. Static checks can't prove a page renders, so QSS includes a render check for the compiled CSS alone, and its design history is explicit that only a real browser is real proof.
101
+ That is the whole idea, and it helps humans exactly as much as it helps agents: fewer rules to maintain, one obvious place to make a change, and layouts that stay consistent across devices by construction instead of by discipline.
102
102
 
103
- QSS was extracted from a Rails development tool, which was its first consumer. Its early custom parser was a bug farm, and an invented syntax costs agents more than it buys. Structure moved to plain CSS custom properties; bracket values remain for content such as spacing and sizes. What was tried and dropped is in docs/DESIGN_HISTORY.md.
103
+ QSS backs that up with two automatic auditors that catch mistakes a human reviewer might miss (a RED finding is a broken rule, ORANGE is a judgment call) and a real-browser render check, because a passing lint rule is not proof a page actually displays correctly -- only watching it render is. It ships compiled, ready-to-use CSS plus its SCSS source, works with zero JavaScript, and offers an optional JS engine for a few conveniences on top. It stays out of the way of whatever you use for colors, type and visual polish (Tailwind or anything else) -- QSS only ever owns position, size and structure.
104
+
105
+ QSS was extracted from a real Rails development tool that still runs on it today, not built as a theoretical exercise. See docs/HOWTO.md to get started and docs/DESIGN_HISTORY.md for the full story of what was tried, what broke, and why each decision landed where it did.
104
106
 
105
107
  Status: 0.x. Expect names and APIs to change before 1.0.
106
108
  email:
@@ -125,6 +127,8 @@ files:
125
127
  - config/grammar.yml
126
128
  - config/integrity.json
127
129
  - config/qss-objects.json
130
+ - docs/DESIGN_HISTORY.md
131
+ - docs/HOWTO.md
128
132
  - exe/qss-core-render-check
129
133
  - exe/qss-engine-render-check
130
134
  - exe/qss-seal
@@ -144,7 +148,7 @@ metadata:
144
148
  source_code_uri: https://bitbucket.org/kmf2/qss
145
149
  bug_tracker_uri: https://bitbucket.org/kmf2/qss/issues
146
150
  changelog_uri: https://bitbucket.org/kmf2/qss/src/main/CHANGELOG.md
147
- documentation_uri: https://bitbucket.org/kmf2/qss/src/main/docs/DESIGN_HISTORY.md
151
+ documentation_uri: https://bitbucket.org/kmf2/qss/src/main/docs/HOWTO.md
148
152
  allowed_push_host: https://rubygems.org
149
153
  rubygems_mfa_required: 'true'
150
154
  rdoc_options: []
@@ -163,6 +167,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
163
167
  requirements: []
164
168
  rubygems_version: 4.0.13
165
169
  specification_version: 4
166
- summary: 'Quantized Spatial Styling: CSS layout that AI agents can read, edit and
167
- check from source'
170
+ summary: A CSS layout system built so AI agents (and the humans reviewing them) never
171
+ have to guess
168
172
  test_files: []