qss 0.1.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/app/assets/javascripts/qss/qss-engine.js +1 -0
- data/app/assets/stylesheets/qss/_qss_defaults.scss +6 -0
- data/app/assets/stylesheets/qss/qss_core.css +64089 -64069
- data/app/assets/stylesheets/qss/qss_core.scss +30 -14
- data/config/grammar.yml +20 -0
- data/exe/qss-core-render-check +34 -3
- data/exe/qss-sync +5 -0
- data/lib/qss/configuration.rb +1 -0
- data/lib/qss/qss_auditor.rb +3 -2
- data/lib/qss/qss_native_auditor.rb +96 -5
- data/lib/qss/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
-
|
|
418
|
-
|
|
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}
|
|
602
|
-
.qss-#{$bp-name}
|
|
603
|
-
.qss-#{$bp-name}
|
|
604
|
-
.qss-#{$bp-name}
|
|
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}
|
|
614
|
-
.qss-#{$bp-name}
|
|
615
|
-
.qss-#{$bp-name}
|
|
616
|
-
.qss-#{$bp-name}
|
|
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.)
|
data/exe/qss-core-render-check
CHANGED
|
@@ -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",
|
|
55
|
-
[ "qss-contain-layout gives layout containment",
|
|
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
|
-
|
|
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
|
|
data/lib/qss/configuration.rb
CHANGED
|
@@ -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
|
|
data/lib/qss/qss_auditor.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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