qss 0.1.1 → 0.1.3

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.
@@ -10,11 +10,20 @@
10
10
  /* The QSS unit (qnt): one thousandth of the canvas width, computed by CSS alone. The canvas is a size container
11
11
  and each of its children declares --qnt as 0.1cqw; registered as a <length>, it resolves there once and
12
12
  inherits unchanged, so every descendant measures against its canvas, never a nearer container. Outside a
13
- canvas (e.g. an overlay portal at the document root) the unit approximates from the viewport width. */
13
+ canvas (e.g. an overlay portal at the document root) the unit approximates from the viewport width.
14
+
15
+ Ultra-wide/extreme-resolution dampening (SETTLED 2026-10-17): clamped between the qnt value at a real
16
+ narrow-mobile CSS viewport width (floor) and just past a maximized 1440p/27" monitor (cap), so ordinary
17
+ desktop widths scale freely and only genuine ultra-wide/high-res canvases -- or genuine phone-narrow ones --
18
+ get dampened. Beyond the cap, every structural dimension stays fixed regardless of how much wider the canvas
19
+ gets; extra width becomes more usable space, not bigger borders/gaps/padding. See docs/DESIGN_HISTORY.md #11. */
14
20
  @property --qnt { syntax: "<length>"; inherits: true; initial-value: 1px; }
15
21
 
22
+ $qss-dampen-floor: map-get($qss-dampen-scale, "floor-width") * 0.001 * 1px;
23
+ $qss-dampen-cap: map-get($qss-dampen-scale, "cap-width") * 0.001 * 1px;
24
+
16
25
  :root {
17
- --qnt: 0.1vw;
26
+ --qnt: clamp(#{$qss-dampen-floor}, 0.1vw, #{$qss-dampen-cap});
18
27
  /* Item 3: pure-CSS linear content unit, no JS, no clamp. Only icon-size[N] uses
19
28
  this today -- see the content_scale note in _qss_variables.scss. */
20
29
  --u: calc(100vw / #{map-get($qss-content-scale, "calibration-width")});
@@ -63,7 +72,7 @@ html, body {
63
72
  background-color: transparent;
64
73
  box-sizing: border-box;
65
74
 
66
- > * { --qnt: 0.1cqw; }
75
+ > * { --qnt: clamp(#{$qss-dampen-floor}, 0.1cqw, #{$qss-dampen-cap}); }
67
76
 
68
77
  /* The Base Nature of a Spatial Actor — ONLY inside the stage.
69
78
  :where() keeps this reset at the anchor's weight alone, (1,0,0): below every core utility, which is
@@ -413,9 +422,16 @@ $qss-scope: if($qss-portal-root == "", $qss-anchor, "#{$qss-anchor}, #{$qss-port
413
422
  @include qss-dsl-boost(".contain") { object-fit: contain; }
414
423
 
415
424
  /* Layout containment for structural frames: the class the README documents and the native auditor mandates on
416
- frames whose id names a structural word (header, nav, sidebar, ...). Both spellings the auditor accepts. */
417
- @include qss-dsl-boost(".contain-layout") { contain: layout; }
418
- @include qss-dsl-boost(".qss-contain-layout") { contain: layout; }
425
+ frames whose id names a structural word (header, nav, sidebar, ...). Both spellings the auditor accepts.
426
+ Clips paint by default too (SETTLED 2026-10-17): `contain: layout` alone isolates layout math but not paint, so
427
+ a frame's overflow could still cover its neighbours -- the exact gap DESIGN_HISTORY #11 named. Anything that
428
+ needs to visually escape a frame on purpose (tooltips, dropdowns, modals) already goes through the portal
429
+ (z-index outside it is its own red violation), so this does not reopen that case; qss-paint-override is the
430
+ escape hatch for the rare frame that wants decorative overflow (a shadow, a hover scale) without the portal. */
431
+ @include qss-dsl-boost(".contain-layout") { contain: layout paint; }
432
+ @include qss-dsl-boost(".qss-contain-layout") { contain: layout paint; }
433
+ @include qss-dsl-boost(".contain-layout.qss-paint-override") { contain: layout; }
434
+ @include qss-dsl-boost(".qss-contain-layout.qss-paint-override") { contain: layout; }
419
435
  @include qss-dsl-boost(".cover") { object-fit: cover; }
420
436
 
421
437
  /* Visual Atmosphere */
@@ -598,10 +614,10 @@ $qss-accent-states: (
598
614
  @each $bp-name, $bp-width in $qss-breakpoints {
599
615
  @media (min-width: #{$bp-width}) {
600
616
  @for $i from 0 through map-get($qss-quantization, "units") {
601
- .qss-#{$bp-name}\:w-pct\[#{$i}\] { width: #{$i * 0.1}%; }
602
- .qss-#{$bp-name}\:h-pct\[#{$i}\] { height: #{$i * 0.1}%; }
603
- .qss-#{$bp-name}\:pos-x-pct\[#{$i}\] { left: #{$i * 0.1}%; }
604
- .qss-#{$bp-name}\:pos-y-pct\[#{$i}\] { top: #{$i * 0.1}%; }
617
+ @include qss-dsl-boost(".qss-#{$bp-name}\\:w-pct\\[#{$i}\\]") { width: percentage($i * 0.001); }
618
+ @include qss-dsl-boost(".qss-#{$bp-name}\\:h-pct\\[#{$i}\\]") { height: percentage($i * 0.001); }
619
+ @include qss-dsl-boost(".qss-#{$bp-name}\\:pos-x-pct\\[#{$i}\\]") { left: percentage($i * 0.001); }
620
+ @include qss-dsl-boost(".qss-#{$bp-name}\\:pos-y-pct\\[#{$i}\\]") { top: percentage($i * 0.001); }
605
621
  }
606
622
  }
607
623
  }
@@ -610,10 +626,10 @@ $qss-accent-states: (
610
626
  @each $bp-name, $bp-height in $qss-vertical-breakpoints {
611
627
  @media (min-height: #{$bp-height}) {
612
628
  @for $i from 0 through map-get($qss-quantization, "units") {
613
- .qss-#{$bp-name}\:w-pct\[#{$i}\] { width: #{$i * 0.1}%; }
614
- .qss-#{$bp-name}\:h-pct\[#{$i}\] { height: #{$i * 0.1}%; }
615
- .qss-#{$bp-name}\:pos-x-pct\[#{$i}\] { left: #{$i * 0.1}%; }
616
- .qss-#{$bp-name}\:pos-y-pct\[#{$i}\] { top: #{$i * 0.1}%; }
629
+ @include qss-dsl-boost(".qss-#{$bp-name}\\:w-pct\\[#{$i}\\]") { width: percentage($i * 0.001); }
630
+ @include qss-dsl-boost(".qss-#{$bp-name}\\:h-pct\\[#{$i}\\]") { height: percentage($i * 0.001); }
631
+ @include qss-dsl-boost(".qss-#{$bp-name}\\:pos-x-pct\\[#{$i}\\]") { left: percentage($i * 0.001); }
632
+ @include qss-dsl-boost(".qss-#{$bp-name}\\:pos-y-pct\\[#{$i}\\]") { top: percentage($i * 0.001); }
617
633
  }
618
634
  }
619
635
  }
data/config/grammar.yml CHANGED
@@ -132,6 +132,7 @@ adjectives:
132
132
  - qss-legibility-override
133
133
  - qss-ergonomic-override
134
134
  - qss-payload-override
135
+ - qss-paint-override
135
136
  - qss-dampen-scaling
136
137
  - qss-content-driven
137
138
 
@@ -214,6 +215,18 @@ config:
214
215
  content_scale:
215
216
  calibration_width: 1440
216
217
 
218
+ # Structural unit dampening (SETTLED 2026-10-17). --qnt is 0.1cqw of the canvas with no
219
+ # bound of its own; clamped between the qnt value at floor_width (a real narrow-mobile
220
+ # CSS viewport width -- phones report this regardless of physical/FHD+/QHD+ panel
221
+ # resolution, since cqw/vw only ever see CSS pixels) and at cap_width (just past a
222
+ # maximized 1440p/27" monitor, so ordinary wide desktops scale freely and real ultra-wide
223
+ # monitors, 3440px and up, are the ones dampened). Beyond cap_width, every structural
224
+ # dimension stays fixed regardless of how much wider the canvas gets -- extra width
225
+ # becomes more usable space, not bigger borders/gaps/padding.
226
+ dampen_scale:
227
+ floor_width: 390
228
+ cap_width: 3200
229
+
217
230
  # Named-group registry for group-{state}/{name} relational classes. Consumer-supplied.
218
231
  group_names: []
219
232
 
@@ -253,6 +266,13 @@ auditor:
253
266
  # native auditor. Relative to the process working directory. Empty = no registry (an empty vocabulary).
254
267
  registry_path: ""
255
268
 
269
+ # The compiled stylesheet the native auditor checks every class against (SETTLED 2026-10-17): a class must
270
+ # literally appear in some selector here, not just match a known shape (the 2026-09-21 census found ~29% of
271
+ # real class tokens resolved to no CSS at all while both auditors reported green). Empty = core's own
272
+ # qss_core.css; a consumer normally points this at its own compiled output (core + its overrides), which is
273
+ # what its markup actually renders against.
274
+ stylesheet_path: ""
275
+
256
276
  # Files exe/qss-sync generates into. core_variables, engine, auditor and native_auditor are core's own files,
257
277
  # relative to core's root; variables is the consumer's, relative to the directory the sync is run from, and a consumer
258
278
  # normally sets it in its own config file. (Keeps file locations out of the script itself.)
@@ -32,6 +32,12 @@ HTML = <<~HTML
32
32
  <div id="padded" class="qss p[4]"></div>
33
33
  <div id="contained" class="qss contain-layout"></div>
34
34
  <div id="contained-prefixed" class="qss qss-contain-layout"></div>
35
+ <div id="clip-frame" class="qss w[100] h[50] contain-layout" style="position: relative;">
36
+ <div id="clip-overflow" class="qss" style="position: absolute; left: 150px; top: 0; width: 20px; height: 20px;"></div>
37
+ </div>
38
+ <div id="override-frame" class="qss w[100] h[50] contain-layout qss-paint-override" style="position: relative;">
39
+ <div id="override-overflow" class="qss" style="position: absolute; left: 150px; top: 0; width: 20px; height: 20px;"></div>
40
+ </div>
35
41
  <div id="host" class="qss host-static"></div>
36
42
  <div class="foreign"><div id="in-foreign" class="qss"></div></div>
37
43
  <div id="nearer-container" class="qss w[100] h[10]" style="container-type: inline-size"><div id="deep" class="qss"></div></div>
@@ -51,8 +57,9 @@ CHECKS = [
51
57
  [ "flex-1 beats the reset's flex-shrink", "flex1", "flex", "1 1 0%" ],
52
58
  [ "p[N] beats the reset (4 units on a 1000px canvas)", "padded", "padding", "4px" ],
53
59
  [ "a host app's plain class does not override structure", "host", "position", "absolute" ],
54
- [ "contain-layout gives layout containment", "contained", "contain", "layout" ],
55
- [ "qss-contain-layout gives layout containment", "contained-prefixed", "contain", "layout" ],
60
+ [ "contain-layout gives layout and paint containment", "contained", "contain", "layout paint" ],
61
+ [ "qss-contain-layout gives layout and paint containment", "contained-prefixed", "contain", "layout paint" ],
62
+ [ "qss-paint-override restores layout-only containment", "override-frame", "contain", "layout" ],
56
63
  [ "a bare actor has no containment", "bare", "contain", "none" ],
57
64
  [ "foreign content is left alone", "in-foreign", "position", "static" ]
58
65
  ].freeze
@@ -80,10 +87,34 @@ Dir.mktmpdir do |dir|
80
87
  puts format("%-4s %-56s %s=%s%s", ok ? "ok" : "FAIL", label, prop, actual, ok ? "" : " (expected #{expected})")
81
88
  end
82
89
 
90
+ # Real visual proof that paint containment actually clips, not just that getComputedStyle says "paint": the
91
+ # overflow child sits past its frame's right edge (frame is 100px wide; the child is at left:150px), so
92
+ # elementFromPoint at that spot hits the clipped-away child only when painting isn't actually contained.
93
+ hit = lambda do |frame_id, overflow_id|
94
+ driver.execute_script(<<~JS, frame_id, overflow_id)
95
+ const rect = document.getElementById(arguments[0]).getBoundingClientRect();
96
+ const el = document.elementFromPoint(rect.left + 160, rect.top + 10);
97
+ return !!el && el.id === arguments[1];
98
+ JS
99
+ end
100
+ [
101
+ [ "contain-layout actually clips an overflowing child's paint", "clip-frame", "clip-overflow", false ],
102
+ [ "qss-paint-override actually lets an overflowing child paint through", "override-frame", "override-overflow", true ]
103
+ ].each do |label, frame_id, overflow_id, expected|
104
+ actual = hit.(frame_id, overflow_id)
105
+ ok = actual == expected
106
+ total += 1
107
+ failures += 1 unless ok
108
+ puts format("%-4s %-56s hit=%s%s", ok ? "ok" : "FAIL", label, actual, ok ? "" : " (expected #{expected})")
109
+ end
110
+
83
111
  # The unit: one thousandth of the canvas width, from CSS alone, measured against the canvas even inside a nearer
84
112
  # size container; outside a canvas it approximates from the viewport.
85
113
  qnt = ->(id) { driver.execute_script("return getComputedStyle(arguments[0] ? document.getElementById(arguments[0]) : document.documentElement).getPropertyValue('--qnt').trim()", id) }
86
- [ [ 1000, "1px" ], [ 1500, "1.5px" ] ].each do |width, expected|
114
+ # 300px and 5000px fall outside the dampening clamp's 390px-3200px range (DESIGN_HISTORY #11): unclamped they'd
115
+ # be 0.3px and 5px, so seeing 0.39px/3.2px instead proves the floor/cap actually engage, not just that the
116
+ # unclamped math is right.
117
+ [ [ 1000, "1px" ], [ 1500, "1.5px" ], [ 300, "0.39px" ], [ 5000, "3.2px" ] ].each do |width, expected|
87
118
  driver.execute_script("document.getElementById('qss-canvas').style.width = arguments[0] + 'px'", width)
88
119
  [ [ "canvas child", "bare" ], [ "element inside a nearer container", "deep" ] ].each do |where, id|
89
120
  actual = qnt.(id)
data/exe/qss-sync CHANGED
@@ -142,6 +142,11 @@ def scss_variables(config, header, default)
142
142
  #{scss_scale.(config['content_scale'])}
143
143
  )#{d};
144
144
 
145
+ /* Structural unit dampening (SETTLED 2026-10-17). */
146
+ $qss-dampen-scale: (
147
+ #{scss_scale.(config['dampen_scale'])}
148
+ )#{d};
149
+
145
150
  /* Named-group registry (consumer-supplied). */
146
151
  $qss-group-names: (#{(config['group_names'] || []).map { |n| "\"#{n}\"" }.join(", ")})#{d};
147
152
 
@@ -53,6 +53,7 @@ module QSS
53
53
  portal = data.dig("config", "portal_root")
54
54
  settings["portal_root"] = portal.to_s unless portal.nil?
55
55
  settings["registry_path"] = resolve(settings["registry_path"])
56
+ settings["stylesheet_path"] = resolve(settings["stylesheet_path"])
56
57
  settings.freeze
57
58
  end
58
59
 
@@ -81,6 +81,7 @@ module QSS
81
81
  'qss-dampen-scaling',
82
82
  'qss-ergonomic-override',
83
83
  'qss-legibility-override',
84
+ 'qss-paint-override',
84
85
  'qss-payload-override',
85
86
  'relative',
86
87
  'resize',
@@ -121,7 +122,7 @@ module QSS
121
122
 
122
123
  PAYLOAD_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "blockquote", "li"].freeze
123
124
 
124
- ADJECTIVE_CATEGORIES = {"positional"=>["detached", "floating", "stuck", "absolute", "pos-x", "pos-y", "pos-r", "pos-b", "pos"], "flow_relative"=>["anchored", "relative"], "layout"=>["vertical", "horizontal", "centered", "v-mid", "h-mid", "v-start", "v-end", "h-start", "h-end", "spaced", "around", "evenly", "flex", "flex-col", "flex-row", "flex-wrap", "flex-nowrap", "flex-1", "flex-auto", "flex-none", "items-center", "justify-center", "justify-between", "grid", "table-fixed", "contain-layout", "w-full", "h-full", "w-f", "h-f"], "typographic"=>["italic", "uppercase", "lowercase", "underline", "nowrap", "truncate", "font-mono", "font-sans", "font-medium", "font-bold", "font-black", "text-size", "leading", "tracking", "tracking-tighter", "tracking-wide", "tracking-wider", "tracking-widest", "icon-size"], "visual"=>["bg", "border", "border-t", "border-b", "border-l", "border-r", "rounded", "rounded-full", "shadow", "opacity", "blur", "bright", "contrast", "grayscale", "hue", "invert", "saturate", "sepia", "blend", "contain", "cover", "visible", "invisible", "bg-alpha", "border-alpha", "text-alpha"], "interactive"=>["clickable", "smooth", "busy", "ghost", "unselectable", "scroll-smooth", "scroll", "cursor-col-resize", "cursor-row-resize", "transition-all", "transition-colors", "transition-none", "ring", "resize", "outline-none"], "internal"=>["qss", "dev-border", "group", "foreign", "qss-legibility-override", "qss-ergonomic-override", "qss-payload-override", "qss-dampen-scaling", "qss-content-driven"]}.freeze
125
+ ADJECTIVE_CATEGORIES = {"positional"=>["detached", "floating", "stuck", "absolute", "pos-x", "pos-y", "pos-r", "pos-b", "pos"], "flow_relative"=>["anchored", "relative"], "layout"=>["vertical", "horizontal", "centered", "v-mid", "h-mid", "v-start", "v-end", "h-start", "h-end", "spaced", "around", "evenly", "flex", "flex-col", "flex-row", "flex-wrap", "flex-nowrap", "flex-1", "flex-auto", "flex-none", "items-center", "justify-center", "justify-between", "grid", "table-fixed", "contain-layout", "w-full", "h-full", "w-f", "h-f"], "typographic"=>["italic", "uppercase", "lowercase", "underline", "nowrap", "truncate", "font-mono", "font-sans", "font-medium", "font-bold", "font-black", "text-size", "leading", "tracking", "tracking-tighter", "tracking-wide", "tracking-wider", "tracking-widest", "icon-size"], "visual"=>["bg", "border", "border-t", "border-b", "border-l", "border-r", "rounded", "rounded-full", "shadow", "opacity", "blur", "bright", "contrast", "grayscale", "hue", "invert", "saturate", "sepia", "blend", "contain", "cover", "visible", "invisible", "bg-alpha", "border-alpha", "text-alpha"], "interactive"=>["clickable", "smooth", "busy", "ghost", "unselectable", "scroll-smooth", "scroll", "cursor-col-resize", "cursor-row-resize", "transition-all", "transition-colors", "transition-none", "ring", "resize", "outline-none"], "internal"=>["qss", "dev-border", "group", "foreign", "qss-legibility-override", "qss-ergonomic-override", "qss-payload-override", "qss-paint-override", "qss-dampen-scaling", "qss-content-driven"]}.freeze
125
126
 
126
127
  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
127
128
 
@@ -133,7 +134,7 @@ module QSS
133
134
 
134
135
  DEPTHS = {"canvas"=>0, "content"=>100, "ui"=>500, "overlay"=>900, "modal"=>1000}.freeze
135
136
 
136
- AUDITOR_CONFIG = {"id_prefix"=>"", "structural_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "narrow_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "icon_prefixes"=>[], "registry_path"=>"", "portal_root"=>""}.freeze
137
+ AUDITOR_CONFIG = {"id_prefix"=>"", "structural_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "narrow_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "icon_prefixes"=>[], "registry_path"=>"", "stylesheet_path"=>"", "portal_root"=>""}.freeze
137
138
  # QSS-GENERATED-END
138
139
  # rubocop:enable all
139
140
 
@@ -1,5 +1,6 @@
1
1
  require 'nokogiri'
2
2
  require 'yaml'
3
+ require 'set'
3
4
 
4
5
  module QSS
5
6
  class QssNativeAuditor
@@ -8,7 +9,7 @@ module QSS
8
9
  # GENERATED FROM grammar.yml - DO NOT EDIT
9
10
  PAYLOAD_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "blockquote", "li"].freeze
10
11
 
11
- AUDITOR_CONFIG = {"id_prefix"=>"", "structural_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "narrow_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "icon_prefixes"=>[], "registry_path"=>"", "portal_root"=>""}.freeze
12
+ AUDITOR_CONFIG = {"id_prefix"=>"", "structural_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "narrow_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "icon_prefixes"=>[], "registry_path"=>"", "stylesheet_path"=>"", "portal_root"=>""}.freeze
12
13
  # QSS-GENERATED-END
13
14
  # rubocop:enable all
14
15
 
@@ -273,10 +274,34 @@ module QSS
273
274
  found
274
275
  end
275
276
 
277
+ # Two opposite insets on a positioned element pin both its edges on that axis, fixing its size exactly even
278
+ # with no width/height class at all (SETTLED 2026-10-17, revisiting the 2026-09-24 deferral now that flex and
279
+ # Tailwind's boundary are settled -- DESIGN_HISTORY #12). pos-x[N]+pos-r[N] (left+right) fix width;
280
+ # pos-y[N]+pos-b[N] (top+bottom) fix height. Bare native and qss- forms both count, same normalization as
281
+ # from_classes above.
282
+ INSET_SIDES = { 'width' => %w[pos-x pos-r], 'height' => %w[pos-y pos-b] }.freeze
283
+
284
+ def self.inset_declared?(classes, axis)
285
+ bases = classes.map { |c| c.sub(/\Aqss-/, '')[/\A[a-z-]+(?=\[)/] }.compact
286
+ INSET_SIDES.fetch(axis).all? { |side| bases.include?(side) }
287
+ end
288
+
289
+ # flex-1/flex-auto (flex: 1 1 0%/1 1 auto) fixes a flex item's size exactly, but only on the flex
290
+ # container's MAIN axis -- the cross axis is untouched by it and still needs its own answer. Which axis is
291
+ # main depends on the PARENT's own classes: .vertical is flex-direction: column (main = height); .flex or
292
+ # .horizontal, with no .vertical, is row, CSS's own default (main = width). No flex parent at all means
293
+ # flex-1 fixes nothing here: never guess which axis it would have covered.
294
+ def self.main_axis(parent_classes)
295
+ return nil unless parent_classes.any? { |c| %w[flex horizontal vertical].include?(c) }
296
+ parent_classes.include?('vertical') ? 'height' : 'width'
297
+ end
298
+
276
299
  # The axes an element's classes leave undeclared, width before height (empty when both are declared).
277
- def self.missing_axes(classes)
300
+ # parent_classes is the flex container's own classes, if any, used only to resolve flex-1/flex-auto.
301
+ def self.missing_axes(classes, parent_classes: [])
278
302
  found = from_classes(classes)
279
- %w[width height].reject { |axis| found.key?(axis) }
303
+ flexed = (classes.include?('flex-1') || classes.include?('flex-auto')) ? main_axis(parent_classes) : nil
304
+ %w[width height].reject { |axis| found.key?(axis) || inset_declared?(classes, axis) || axis == flexed }
280
305
  end
281
306
 
282
307
  def self.describe(fact)
@@ -378,6 +403,54 @@ module QSS
378
403
  end
379
404
  end
380
405
 
406
+ # The class-must-resolve check (item 4, SETTLED 2026-10-17): the 2026-09-21 census found about 29 percent of
407
+ # real class tokens resolved to no CSS at all while both auditors reported green, because they only checked a
408
+ # class's SHAPE (does it look like a known pattern), never whether a rule for it actually exists. This checks
409
+ # the compiled stylesheet directly: a class must literally appear in some selector in it.
410
+ module Stylesheet
411
+ CACHE = {}
412
+ # Core's own compiled CSS, used when a consumer configures no stylesheet_path (auditor.stylesheet_path).
413
+ CORE_CSS_PATH = File.expand_path('../../app/assets/stylesheets/qss/qss_core.css', __dir__)
414
+
415
+ # Deliberately CSS-free: behavioral flags this auditor (or the older whitelist auditor) reads from the class
416
+ # list itself, never meant to carry a rule. qss and foreign happen to appear inside compound selectors in
417
+ # core's own reset (:where(.qss:not(.foreign *))), but that is an accident of how the reset is written, not
418
+ # a guarantee -- a consumer's own compiled stylesheet may not repeat it verbatim, so both are listed
419
+ # explicitly rather than left to resolve by coincidence. group and dev-border are not here: group is real
420
+ # selector vocabulary (the ancestor half of every group-variant rule, .group:hover ...), and dev-border has
421
+ # its own standalone rule -- both are supposed to be found in the stylesheet, not exempted from looking.
422
+ MARKER_CLASSES = %w[qss foreign qss-legibility-override qss-ergonomic-override qss-payload-override
423
+ qss-dampen-scaling qss-content-driven].freeze
424
+
425
+ # One class token: a run of escaped-pair (\X, however Sass escaped it: \:, \[, \], \/, \.) or plain identifier
426
+ # characters. Stops at the first unescaped character outside that set -- a real pseudo-class like :hover
427
+ # that follows a QSS class in the same compound selector, never part of the class name itself.
428
+ CLASS_TOKEN = /\.((?:\\.|[A-Za-z0-9_-])+)/.freeze
429
+
430
+ # Every class token referenced by any selector anywhere in the stylesheet, unescaped back to the literal
431
+ # form an author writes in `class="..."`. Scoped to selector text only (everything before a "{"): a
432
+ # declaration's VALUES can contain "." too (decimals, calc()), which must never be mistaken for a class.
433
+ def self.classes_in(css_text)
434
+ selectors = css_text.scan(/([^{}]+)\{[^{}]*\}/m).flatten
435
+ tokens = selectors.flat_map { |s| s.scan(CLASS_TOKEN).flatten }
436
+ tokens.map { |t| t.gsub(/\\(.)/, '\1') }.to_set
437
+ end
438
+
439
+ def self.load(path)
440
+ path = path.to_s.empty? ? CORE_CSS_PATH : path
441
+ return Set.new unless File.exist?(path)
442
+ mtime = File.mtime(path)
443
+ cached = CACHE[path]
444
+ return cached.last if cached && cached.first == mtime
445
+ known = classes_in(File.read(path).force_encoding('UTF-8').scrub)
446
+ CACHE[path] = [ mtime, known ]
447
+ known
448
+ end
449
+
450
+ def self.resolves?(class_name, known)
451
+ MARKER_CLASSES.include?(class_name) || known.include?(class_name)
452
+ end
453
+ end
381
454
 
382
455
  # A consumer's vocabulary, read at runtime from a YAML file (decision 3):
383
456
  #
@@ -670,7 +743,10 @@ module QSS
670
743
  def self.register_or_similar(result, id, known, registry)
671
744
  near = NameRules.similar(id, known, allow_digits: registry.allow_digits)
672
745
  if near.empty?
673
- kind = result.branch == :custom ? 'declare it as a type' : "register what #{result.qualifier.inspect} adds to #{result.base.inspect}, or drop the qualifier"
746
+ # Native-branch advice never offers "drop the qualifier": a bare native tag is itself a red
747
+ # missing_qualifier violation (see check_native above), so dropping it would just trade one red
748
+ # finding for another, not resolve anything.
749
+ kind = result.branch == :custom ? 'declare it as a type' : "register what #{result.qualifier.inspect} adds to #{result.base.inspect}"
674
750
  result.findings << finding(result.branch == :custom ? :undeclared_type : :unregistered_qualifier, 'red', "#{id.inspect} is not in the registry: #{kind}")
675
751
  else
676
752
  list = near.map { |name, why| "#{name.inspect} (#{why})" }.join(', ')
@@ -744,9 +820,11 @@ module QSS
744
820
  narrow_pattern = narrow_structural_pattern(settings)
745
821
  portal_label = portal_label(settings)
746
822
  registry = Registry.load(settings['registry_path'])
823
+ known_classes = Stylesheet.load(settings['stylesheet_path'])
747
824
  exempt_prefixes = [ prefix, IdRules::FRAMEWORK_PREFIX ]
748
825
  seen_ids = {}
749
826
  seen_attributes = {}
827
+ seen_unresolved = {}
750
828
  unless registry.errors.empty?
751
829
  issues << { type: 'identity', code: :registry_problem, message: "Registry problem (#{registry.errors.size}): #{registry.errors.first[:message]}", severity: 'red' }
752
830
  end
@@ -788,6 +866,18 @@ module QSS
788
866
 
789
867
  next if is_foreign && !is_structural # Skip internal payload of foreign components
790
868
 
869
+ # Class resolution (item 4): every class must literally appear in the compiled stylesheet, not just match
870
+ # a known shape (the 2026-09-21 census: ~29% didn't, with both auditors green). Foreign content is the
871
+ # host app's own classes, never QSS's to check; ERB-built class lists can't be known statically.
872
+ if !is_foreign && !node['class'].to_s.include?('<!--ERB-->')
873
+ classes.each do |cls|
874
+ next if Stylesheet.resolves?(cls, known_classes)
875
+ next if seen_unresolved[cls]
876
+ seen_unresolved[cls] = true
877
+ issues << { type: 'protocol', code: :unresolved_class, message: "Unresolved Class: #{cls.inspect} is not in the compiled stylesheet (#{settings['stylesheet_path'].to_s.empty? ? "core's qss_core.css" : settings['stylesheet_path']}); it will render as if the class were never written", severity: 'red' }
878
+ end
879
+ end
880
+
791
881
  # Identity findings (item B), each id reported once per file.
792
882
  if identity && !seen_ids[id]
793
883
  seen_ids[id] = true
@@ -835,7 +925,8 @@ module QSS
835
925
  # built in ERB are skipped: their classes cannot be known from the template.
836
926
  actor_dimensions_checked = qss_actor && !is_foreign && !is_content_driven && !node['class'].include?('<!--ERB-->')
837
927
  if actor_dimensions_checked
838
- missing = Dimensions.missing_axes(classes)
928
+ parent_classes = node.parent&.element? ? (node.parent['class']&.split(/\s+/) || []) : []
929
+ missing = Dimensions.missing_axes(classes, parent_classes: parent_classes)
839
930
  unless missing.empty?
840
931
  label = id.empty? ? "<#{node.name}>" : "##{id}"
841
932
  issues << { type: 'protocol', code: :implicit_dimension, message: "Implicit Dimension Violation: QSS actor #{label} declares no #{missing.join(' and no ')}; declare it with a QSS size class (w[N]/h[N], w-pct[N]/h-pct[N], w-full/h-full) or opt out with qss-content-driven", severity: 'red' }
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.1.1"
6
+ STRING = "0.1.3"
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.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kraig Fairhurst
@@ -93,10 +93,16 @@ dependencies:
93
93
  - - ">="
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
- description: QSS sizes and places layout on a 1000-unit canvas grid (1 qnt = canvas
97
- width / 1000, pure CSS), ships compiled and SCSS sources, and audits views for rule
98
- violations. Includes a Rails engine for the asset pipeline; the auditors also run
99
- without Rails.
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.
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.
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.
102
+
103
+ QSS was extracted from Parapet, 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.
104
+
105
+ Status: 0.1.x. Expect names and APIs to change before 1.0.
100
106
  email:
101
107
  - kmfairhurst@gmail.com
102
108
  executables:
@@ -132,6 +138,7 @@ metadata:
132
138
  source_code_uri: https://bitbucket.org/kmf2/qss
133
139
  bug_tracker_uri: https://bitbucket.org/kmf2/qss/issues
134
140
  changelog_uri: https://bitbucket.org/kmf2/qss/src/main/CHANGELOG.md
141
+ documentation_uri: https://bitbucket.org/kmf2/qss/src/main/docs/DESIGN_HISTORY.md
135
142
  allowed_push_host: https://rubygems.org
136
143
  rubygems_mfa_required: 'true'
137
144
  rdoc_options: []
@@ -150,5 +157,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
150
157
  requirements: []
151
158
  rubygems_version: 4.0.13
152
159
  specification_version: 4
153
- summary: 'Quantized Spatial Styling: an agent-aware CSS layout framework, with auditors'
160
+ summary: 'Quantized Spatial Styling: CSS layout that AI agents can read, edit and
161
+ check from source'
154
162
  test_files: []