qss 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/CONTRIBUTING.md +26 -0
- data/MIT-LICENSE +20 -0
- data/QSS-MANIFESTO.md +61 -0
- data/README.md +159 -0
- data/app/assets/javascripts/qss/qss-engine.js +1054 -0
- data/app/assets/stylesheets/qss/_qss_defaults.scss +74 -0
- data/app/assets/stylesheets/qss/qss_core.css +205042 -0
- data/app/assets/stylesheets/qss/qss_core.scss +614 -0
- data/config/grammar.yml +265 -0
- data/config/qss-objects.json +3 -0
- data/exe/qss-core-render-check +104 -0
- data/exe/qss-sync +205 -0
- data/lib/qss/blueprints.rb +106 -0
- data/lib/qss/configuration.rb +82 -0
- data/lib/qss/engine.rb +9 -0
- data/lib/qss/qss_auditor.rb +412 -0
- data/lib/qss/qss_native_auditor.rb +908 -0
- data/lib/qss/version.rb +10 -0
- data/lib/qss.rb +26 -0
- metadata +154 -0
data/config/grammar.yml
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# QSS Grammar Registry
|
|
2
|
+
# Single Source of Truth for the QSS DSL
|
|
3
|
+
# -------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
adjectives:
|
|
6
|
+
# 1. Positional: Elements that define the "Steel Frame" (Coordinates/Flow-breaking)
|
|
7
|
+
# These are forbidden on "Payload" tags unless overridden.
|
|
8
|
+
positional:
|
|
9
|
+
- detached
|
|
10
|
+
- floating
|
|
11
|
+
- stuck
|
|
12
|
+
- absolute
|
|
13
|
+
- pos-x
|
|
14
|
+
- pos-y
|
|
15
|
+
- pos-r
|
|
16
|
+
- pos-b
|
|
17
|
+
- pos
|
|
18
|
+
|
|
19
|
+
# 1b. Flow-Relative: Positioning that doesn't break document flow
|
|
20
|
+
flow_relative:
|
|
21
|
+
- anchored
|
|
22
|
+
- relative
|
|
23
|
+
|
|
24
|
+
# 2. Layout: Deterministic container logic (Flex/Grid)
|
|
25
|
+
layout:
|
|
26
|
+
- vertical
|
|
27
|
+
- horizontal
|
|
28
|
+
- centered
|
|
29
|
+
- v-mid
|
|
30
|
+
- h-mid
|
|
31
|
+
- v-start
|
|
32
|
+
- v-end
|
|
33
|
+
- h-start
|
|
34
|
+
- h-end
|
|
35
|
+
- spaced
|
|
36
|
+
- around
|
|
37
|
+
- evenly
|
|
38
|
+
- flex
|
|
39
|
+
- flex-col
|
|
40
|
+
- flex-row
|
|
41
|
+
- flex-wrap
|
|
42
|
+
- flex-nowrap
|
|
43
|
+
- flex-1
|
|
44
|
+
- flex-auto
|
|
45
|
+
- flex-none
|
|
46
|
+
- items-center
|
|
47
|
+
- justify-center
|
|
48
|
+
- justify-between
|
|
49
|
+
- grid
|
|
50
|
+
- table-fixed
|
|
51
|
+
- w-full
|
|
52
|
+
- h-full
|
|
53
|
+
- w-f
|
|
54
|
+
- h-f
|
|
55
|
+
|
|
56
|
+
# 3. Typographic: Flow-safe styling for "Drywall" (Content)
|
|
57
|
+
typographic:
|
|
58
|
+
- italic
|
|
59
|
+
- uppercase
|
|
60
|
+
- lowercase
|
|
61
|
+
- underline
|
|
62
|
+
- nowrap
|
|
63
|
+
- truncate
|
|
64
|
+
- font-mono
|
|
65
|
+
- font-sans
|
|
66
|
+
- font-medium
|
|
67
|
+
- font-bold
|
|
68
|
+
- font-black
|
|
69
|
+
- text-size
|
|
70
|
+
- leading
|
|
71
|
+
- tracking
|
|
72
|
+
- tracking-tighter
|
|
73
|
+
- tracking-wide
|
|
74
|
+
- tracking-wider
|
|
75
|
+
- tracking-widest
|
|
76
|
+
- icon-size
|
|
77
|
+
|
|
78
|
+
# 4. Visual: Surface-level aesthetics
|
|
79
|
+
visual:
|
|
80
|
+
- bg
|
|
81
|
+
- border
|
|
82
|
+
- border-t
|
|
83
|
+
- border-b
|
|
84
|
+
- border-l
|
|
85
|
+
- border-r
|
|
86
|
+
- rounded
|
|
87
|
+
- rounded-full
|
|
88
|
+
- shadow
|
|
89
|
+
- opacity
|
|
90
|
+
- blur
|
|
91
|
+
- bright
|
|
92
|
+
- contrast
|
|
93
|
+
- grayscale
|
|
94
|
+
- hue
|
|
95
|
+
- invert
|
|
96
|
+
- saturate
|
|
97
|
+
- sepia
|
|
98
|
+
- blend
|
|
99
|
+
- contain
|
|
100
|
+
- cover
|
|
101
|
+
- visible
|
|
102
|
+
- invisible
|
|
103
|
+
- bg-alpha
|
|
104
|
+
- border-alpha
|
|
105
|
+
- text-alpha
|
|
106
|
+
|
|
107
|
+
# 5. Interactive: State and behavior
|
|
108
|
+
interactive:
|
|
109
|
+
- clickable
|
|
110
|
+
- smooth
|
|
111
|
+
- busy
|
|
112
|
+
- ghost
|
|
113
|
+
- unselectable
|
|
114
|
+
- scroll-smooth
|
|
115
|
+
- scroll
|
|
116
|
+
- cursor-col-resize
|
|
117
|
+
- cursor-row-resize
|
|
118
|
+
- transition-all
|
|
119
|
+
- transition-colors
|
|
120
|
+
- transition-none
|
|
121
|
+
- ring
|
|
122
|
+
- resize
|
|
123
|
+
- outline-none
|
|
124
|
+
|
|
125
|
+
# 6. Internal: Framework-specific markers
|
|
126
|
+
internal:
|
|
127
|
+
- qss
|
|
128
|
+
- dev-border
|
|
129
|
+
- group
|
|
130
|
+
- foreign
|
|
131
|
+
- qss-legibility-override
|
|
132
|
+
- qss-ergonomic-override
|
|
133
|
+
- qss-payload-override
|
|
134
|
+
- qss-dampen-scaling
|
|
135
|
+
- qss-content-driven
|
|
136
|
+
|
|
137
|
+
# Tags that represent variable-length human content (Payload)
|
|
138
|
+
payload_tags:
|
|
139
|
+
- p
|
|
140
|
+
- h1
|
|
141
|
+
- h2
|
|
142
|
+
- h3
|
|
143
|
+
- h4
|
|
144
|
+
- h5
|
|
145
|
+
- h6
|
|
146
|
+
- span
|
|
147
|
+
- label
|
|
148
|
+
- blockquote
|
|
149
|
+
- li
|
|
150
|
+
|
|
151
|
+
# Enforcement Rules for the QssAuditor
|
|
152
|
+
illegal_combos:
|
|
153
|
+
- tag_in: payload_tags
|
|
154
|
+
forbid_category: positional
|
|
155
|
+
severity: red
|
|
156
|
+
message: "Protocol Violation: Payload tags (<tag>) cannot carry flow-breaking positional adjectives. Use a wrapping <div> Frame."
|
|
157
|
+
escape_hatch: qss-payload-override
|
|
158
|
+
|
|
159
|
+
# Configuration -- neutral QSS DEFAULTS. Feeds exe/qss-sync -> core's _qss_defaults.scss (every setting !default).
|
|
160
|
+
# A consuming project overrides/extends these in its own config file; exe/qss-sync deep-merges that file over this
|
|
161
|
+
# one (hashes merge by key, lists replace) into the consumer's SCSS settings, and the auditors read it at runtime
|
|
162
|
+
# (lib/qss/configuration.rb). Nothing project-specific belongs here.
|
|
163
|
+
config:
|
|
164
|
+
unit: "1px"
|
|
165
|
+
anchor: "#qss-canvas"
|
|
166
|
+
|
|
167
|
+
# Overlay portal root. Empty = the DSL rules are scoped to the anchor only. A consumer
|
|
168
|
+
# that portals overlays out of the anchor sets its portal element's selector here.
|
|
169
|
+
portal_root: ""
|
|
170
|
+
|
|
171
|
+
# Minimal default palette: only what qss_core.scss itself references (slate-800 border
|
|
172
|
+
# default, slate-900 safety gutter, rose-500 dev border) plus the neutral scale.
|
|
173
|
+
# Consumers add their own colors; the generated color/alpha/accent rules follow.
|
|
174
|
+
palette:
|
|
175
|
+
black: "#000000"
|
|
176
|
+
white: "#ffffff"
|
|
177
|
+
transparent: "transparent"
|
|
178
|
+
none: "none"
|
|
179
|
+
slate-50: "#f8fafc"
|
|
180
|
+
slate-100: "#f1f5f9"
|
|
181
|
+
slate-200: "#e2e8f0"
|
|
182
|
+
slate-300: "#cbd5e1"
|
|
183
|
+
slate-400: "#94a3b8"
|
|
184
|
+
slate-500: "#64748b"
|
|
185
|
+
slate-600: "#475569"
|
|
186
|
+
slate-700: "#334155"
|
|
187
|
+
slate-800: "#1e293b"
|
|
188
|
+
slate-900: "#0f172a"
|
|
189
|
+
slate-950: "#020617"
|
|
190
|
+
rose-500: "#f43f5e"
|
|
191
|
+
|
|
192
|
+
depths:
|
|
193
|
+
canvas: 0
|
|
194
|
+
content: 100
|
|
195
|
+
ui: 500
|
|
196
|
+
overlay: 900
|
|
197
|
+
modal: 1000
|
|
198
|
+
|
|
199
|
+
quantization:
|
|
200
|
+
units: 1000
|
|
201
|
+
borders: 50
|
|
202
|
+
|
|
203
|
+
# Text scale (SETTLED 2026-09-21). text-size[N] = N px at calibration_width, scaling
|
|
204
|
+
# with --qnt, bounded by a floor/cap that are themselves functions of N.
|
|
205
|
+
text_scale:
|
|
206
|
+
calibration_width: 1440
|
|
207
|
+
floor_px: 9
|
|
208
|
+
floor_multiplier: 0.875
|
|
209
|
+
cap_multiplier: 1.5
|
|
210
|
+
|
|
211
|
+
# Non-text content scale (SETTLED 2026-09-21, option C). Only new tokens (icon-size)
|
|
212
|
+
# use it until the Phase B converter activates it for w/h/px/gap/border/rounded.
|
|
213
|
+
content_scale:
|
|
214
|
+
calibration_width: 1440
|
|
215
|
+
|
|
216
|
+
# Named-group registry for group-{state}/{name} relational classes. Consumer-supplied.
|
|
217
|
+
group_names: []
|
|
218
|
+
|
|
219
|
+
breakpoints:
|
|
220
|
+
sm: "640px"
|
|
221
|
+
md: "768px"
|
|
222
|
+
lg: "1024px"
|
|
223
|
+
xl: "1280px"
|
|
224
|
+
|
|
225
|
+
vertical_breakpoints:
|
|
226
|
+
v-sm: "400px"
|
|
227
|
+
v-md: "600px"
|
|
228
|
+
v-lg: "800px"
|
|
229
|
+
v-xl: "1000px"
|
|
230
|
+
|
|
231
|
+
fonts:
|
|
232
|
+
sans: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
|
|
233
|
+
mono: "'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"
|
|
234
|
+
|
|
235
|
+
# Auditor settings -- neutral defaults (QssAuditor / QssNativeAuditor). A consuming
|
|
236
|
+
# project overrides these in its own config file. Portal root comes from config.portal_root.
|
|
237
|
+
auditor:
|
|
238
|
+
# Elements whose id starts with this prefix count as first-party structural frames.
|
|
239
|
+
# Empty = no prefix convention: structural elements are recognised by the words below alone.
|
|
240
|
+
id_prefix: ""
|
|
241
|
+
|
|
242
|
+
# Words that mark an id as a structural frame (drives the inline-style and explicit-dimension checks).
|
|
243
|
+
structural_words: [header, footer, sidebar, workspace, main, panel, frame, wrapper, nav, aside]
|
|
244
|
+
|
|
245
|
+
# Subset of those words that MANDATE `contain: layout`.
|
|
246
|
+
narrow_words: [header, footer, sidebar, workspace, main, panel, frame, wrapper, nav, aside]
|
|
247
|
+
|
|
248
|
+
# Class prefixes of third-party icon sets that the whitelist check skips.
|
|
249
|
+
icon_prefixes: []
|
|
250
|
+
|
|
251
|
+
# The consumer's vocabulary registry (types, functions, allow_digits), a YAML file read at runtime by the
|
|
252
|
+
# native auditor. Relative to the process working directory. Empty = no registry (an empty vocabulary).
|
|
253
|
+
registry_path: ""
|
|
254
|
+
|
|
255
|
+
# Files exe/qss-sync generates into. core_variables, engine, auditor and native_auditor are core's own files,
|
|
256
|
+
# relative to core's root; variables is the consumer's, relative to the directory the sync is run from, and a consumer
|
|
257
|
+
# normally sets it in its own config file. (Keeps file locations out of the script itself.)
|
|
258
|
+
sync_targets:
|
|
259
|
+
# Core's own SCSS defaults (every setting !default), generated from this file alone; core imports it.
|
|
260
|
+
core_variables: app/assets/stylesheets/qss/_qss_defaults.scss
|
|
261
|
+
# The consumer's SCSS settings (this file merged with the consumer's); the consumer imports it before core.
|
|
262
|
+
variables: app/assets/stylesheets/_qss_variables.scss
|
|
263
|
+
engine: app/assets/javascripts/qss/qss-engine.js
|
|
264
|
+
auditor: lib/qss/qss_auditor.rb
|
|
265
|
+
native_auditor: lib/qss/qss_native_auditor.rb
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Renders QSS core's compiled CSS on its own -- no consumer stylesheet, no qss-engine.js -- in headless Chrome and
|
|
5
|
+
# checks that core's utilities apply. Guards against the core reset outranking core utilities (found 2026-09-24:
|
|
6
|
+
# `.qss:not(.foreign *)` weighed (1,2,0) against the utilities' (1,1,0), so compiled utilities only worked when the
|
|
7
|
+
# JS engine re-emitted them). Exit 0 when every check passes, 1 otherwise.
|
|
8
|
+
#
|
|
9
|
+
# qss-core-render-check [path/to/qss_core.css]
|
|
10
|
+
#
|
|
11
|
+
# The test canvas is 1000px wide, so one QSS unit is exactly 1px unless a check resizes it.
|
|
12
|
+
|
|
13
|
+
require "selenium-webdriver"
|
|
14
|
+
require "tmpdir"
|
|
15
|
+
|
|
16
|
+
css_path = File.expand_path(ARGV[0] || File.join(__dir__, "..", "app", "assets", "stylesheets", "qss", "qss_core.css"))
|
|
17
|
+
abort "qss-core-render-check: no stylesheet at #{css_path}" unless File.exist?(css_path)
|
|
18
|
+
|
|
19
|
+
HTML = <<~HTML
|
|
20
|
+
<!doctype html><html><head>
|
|
21
|
+
<link rel="stylesheet" href="file:///#{css_path.tr('\\', '/')}">
|
|
22
|
+
<style>.host-static { position: static; display: inline; }</style>
|
|
23
|
+
</head><body><div id="qss-canvas" style="width: 1000px">
|
|
24
|
+
<div id="bare" class="qss"></div>
|
|
25
|
+
<div id="hidden" class="qss hidden"></div>
|
|
26
|
+
<div id="qss-relative" class="qss qss-relative"></div>
|
|
27
|
+
<div id="anchored" class="qss anchored"></div>
|
|
28
|
+
<div id="sized" class="qss w[200] h[100]"><div id="full" class="qss w-full h-full"></div></div>
|
|
29
|
+
<div id="flex" class="qss flex"></div>
|
|
30
|
+
<div id="vertical" class="qss vertical"></div>
|
|
31
|
+
<div id="flex1" class="qss flex-1"></div>
|
|
32
|
+
<div id="padded" class="qss p[4]"></div>
|
|
33
|
+
<div id="host" class="qss host-static"></div>
|
|
34
|
+
<div class="foreign"><div id="in-foreign" class="qss"></div></div>
|
|
35
|
+
<div id="nearer-container" class="qss w[100] h[10]" style="container-type: inline-size"><div id="deep" class="qss"></div></div>
|
|
36
|
+
</div></body></html>
|
|
37
|
+
HTML
|
|
38
|
+
|
|
39
|
+
CHECKS = [
|
|
40
|
+
[ "bare actor gets the reset: position", "bare", "position", "absolute" ],
|
|
41
|
+
[ "bare actor gets the reset: flex-shrink", "bare", "flexShrink", "0" ],
|
|
42
|
+
[ "hidden beats the reset", "hidden", "display", "none" ],
|
|
43
|
+
[ "qss-relative beats the reset", "qss-relative", "position", "relative" ],
|
|
44
|
+
[ "anchored beats the reset", "anchored", "position", "relative" ],
|
|
45
|
+
[ "w-full beats the reset", "full", "width", "200px" ],
|
|
46
|
+
[ "h-full beats the reset", "full", "height", "100px" ],
|
|
47
|
+
[ "flex beats the reset", "flex", "display", "flex" ],
|
|
48
|
+
[ "vertical beats the reset", "vertical", "flexDirection", "column" ],
|
|
49
|
+
[ "flex-1 beats the reset's flex-shrink", "flex1", "flex", "1 1 0%" ],
|
|
50
|
+
[ "p[N] beats the reset (4 units on a 1000px canvas)", "padded", "padding", "4px" ],
|
|
51
|
+
[ "a host app's plain class does not override structure", "host", "position", "absolute" ],
|
|
52
|
+
[ "foreign content is left alone", "in-foreign", "position", "static" ]
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
failures = 0
|
|
56
|
+
total = CHECKS.size
|
|
57
|
+
Dir.mktmpdir do |dir|
|
|
58
|
+
page = File.join(dir, "qss_core_render_check.html")
|
|
59
|
+
File.write(page, HTML)
|
|
60
|
+
options = Selenium::WebDriver::Chrome::Options.new
|
|
61
|
+
%w[--headless --disable-gpu --window-size=1400,1000 --allow-file-access-from-files].each { |a| options.add_argument(a) }
|
|
62
|
+
# CI containers run Chrome as root with a small /dev/shm; CHROME_BIN / CHROMEDRIVER_PATH point at a packaged Chromium.
|
|
63
|
+
%w[--no-sandbox --disable-dev-shm-usage].each { |a| options.add_argument(a) } if ENV["CI"]
|
|
64
|
+
options.binary = ENV["CHROME_BIN"] if ENV["CHROME_BIN"]
|
|
65
|
+
service = ENV["CHROMEDRIVER_PATH"] ? Selenium::WebDriver::Service.chrome(path: ENV["CHROMEDRIVER_PATH"]) : nil
|
|
66
|
+
driver = service ? Selenium::WebDriver.for(:chrome, options: options, service: service) : Selenium::WebDriver.for(:chrome, options: options)
|
|
67
|
+
begin
|
|
68
|
+
driver.navigate.to("file:///#{page.tr('\\', '/')}")
|
|
69
|
+
engine = driver.execute_script("return typeof window.QSSEngine !== 'undefined'")
|
|
70
|
+
abort "qss-core-render-check: the JS engine is present; this check must run on CSS alone" if engine
|
|
71
|
+
CHECKS.each do |label, id, prop, expected|
|
|
72
|
+
actual = driver.execute_script("return getComputedStyle(document.getElementById(arguments[0]))[arguments[1]]", id, prop)
|
|
73
|
+
ok = actual == expected
|
|
74
|
+
failures += 1 unless ok
|
|
75
|
+
puts format("%-4s %-56s %s=%s%s", ok ? "ok" : "FAIL", label, prop, actual, ok ? "" : " (expected #{expected})")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The unit: one thousandth of the canvas width, from CSS alone, measured against the canvas even inside a nearer
|
|
79
|
+
# size container; outside a canvas it approximates from the viewport.
|
|
80
|
+
qnt = ->(id) { driver.execute_script("return getComputedStyle(arguments[0] ? document.getElementById(arguments[0]) : document.documentElement).getPropertyValue('--qnt').trim()", id) }
|
|
81
|
+
[ [ 1000, "1px" ], [ 1500, "1.5px" ] ].each do |width, expected|
|
|
82
|
+
driver.execute_script("document.getElementById('qss-canvas').style.width = arguments[0] + 'px'", width)
|
|
83
|
+
[ [ "canvas child", "bare" ], [ "element inside a nearer container", "deep" ] ].each do |where, id|
|
|
84
|
+
actual = qnt.(id)
|
|
85
|
+
ok = actual == expected
|
|
86
|
+
total += 1
|
|
87
|
+
failures += 1 unless ok
|
|
88
|
+
puts format("%-4s %-56s --qnt=%s%s", ok ? "ok" : "FAIL", "canvas #{width}px: unit on the #{where}", actual, ok ? "" : " (expected #{expected})")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
viewport = driver.execute_script("return window.innerWidth")
|
|
92
|
+
expected = "#{(viewport * 0.001).round(4).to_s.sub(/\.0\z/, '')}px"
|
|
93
|
+
actual = qnt.(nil)
|
|
94
|
+
ok = actual == expected
|
|
95
|
+
total += 1
|
|
96
|
+
failures += 1 unless ok
|
|
97
|
+
puts format("%-4s %-56s --qnt=%s%s", ok ? "ok" : "FAIL", "outside a canvas: viewport approximation (#{viewport}px)", actual, ok ? "" : " (expected #{expected})")
|
|
98
|
+
ensure
|
|
99
|
+
driver.quit
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
puts failures.zero? ? "qss-core-render-check: #{total} of #{total} passed" : "qss-core-render-check: #{failures} of #{total} FAILED"
|
|
104
|
+
exit(failures.zero? ? 0 : 1)
|
data/exe/qss-sync
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: false
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
# Generates QSS's derived files from two inputs:
|
|
8
|
+
# config/grammar.yml -- core grammar + neutral defaults (source of truth), found relative to this script
|
|
9
|
+
# config/qss_config.yml -- the consuming project's settings (optional), relative to the directory the sync is
|
|
10
|
+
# run from; another path via QSS_CONSUMER_CONFIG (e.g.
|
|
11
|
+
# QSS_CONSUMER_CONFIG=config/myapp/qss_config.yml qss-sync)
|
|
12
|
+
# Two modes. By default (a consuming project) it writes ONLY the consumer's SCSS settings file. With --core (QSS's
|
|
13
|
+
# own maintainers: `rake sync` in the gem) it also regenerates core's generated files, which live in the gem and
|
|
14
|
+
# must never be rewritten from an app. Core's outputs (engine, auditors, core SCSS defaults) resolve from core's
|
|
15
|
+
# root; the consumer's SCSS settings file
|
|
16
|
+
# resolves from the directory the sync is run from. Merge rule: hashes merge by key, lists and scalars replace.
|
|
17
|
+
|
|
18
|
+
CORE_ROOT = File.expand_path('..', __dir__)
|
|
19
|
+
grammar_path = File.join(CORE_ROOT, 'config/grammar.yml')
|
|
20
|
+
unless File.exist?(grammar_path)
|
|
21
|
+
puts "Error: #{grammar_path} not found"
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def deep_merge(base, over)
|
|
26
|
+
return over unless base.is_a?(Hash) && over.is_a?(Hash)
|
|
27
|
+
base.merge(over) { |_k, a, b| deep_merge(a, b) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
grammar = YAML.load_file(grammar_path)
|
|
31
|
+
consumer_path = ENV.fetch('QSS_CONSUMER_CONFIG', 'config/qss_config.yml')
|
|
32
|
+
consumer = File.exist?(consumer_path) ? (YAML.load_file(consumer_path) || {}) : {}
|
|
33
|
+
puts "Consumer config: #{File.exist?(consumer_path) ? consumer_path : 'none (core defaults only)'}"
|
|
34
|
+
CORE_MODE = ARGV.include?('--core')
|
|
35
|
+
unless CORE_MODE || File.exist?(consumer_path)
|
|
36
|
+
abort "Nothing to do: no consumer config at #{consumer_path} (set QSS_CONSUMER_CONFIG), and --core not given."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
config = deep_merge(grammar['config'], consumer['config'] || {})
|
|
40
|
+
|
|
41
|
+
# A consumer's grammar_extensions and auditor: settings are NOT written anywhere by this script: the auditors read
|
|
42
|
+
# them at runtime (lib/qss/configuration.rb). Only the consumer's SCSS settings file is generated from them.
|
|
43
|
+
|
|
44
|
+
# Output locations (grammar.yml `sync_targets:`, overridable by the consumer).
|
|
45
|
+
targets = deep_merge(grammar['sync_targets'] || {}, consumer['sync_targets'] || {})
|
|
46
|
+
%w[core_variables variables engine auditor native_auditor].each { |k| abort "sync_targets.#{k} is not configured" unless targets[k] }
|
|
47
|
+
# Core's files live in core; only the consumer's SCSS settings file belongs to the consumer.
|
|
48
|
+
%w[core_variables engine auditor native_auditor].each { |k| targets[k] = File.expand_path(targets[k], CORE_ROOT) }
|
|
49
|
+
|
|
50
|
+
# --- Generated regions -------------------------------------------------------------------
|
|
51
|
+
# Each generated target carries ONE marked region:
|
|
52
|
+
# <comment> QSS-GENERATED-BEGIN ...
|
|
53
|
+
# <comment> QSS-GENERATED-END
|
|
54
|
+
# The sync rewrites everything between the markers and touches nothing else. A missing,
|
|
55
|
+
# duplicated or misordered marker aborts the run BEFORE anything is written: there is no
|
|
56
|
+
# fallback placement. (An earlier version anchored on the class name and, when nothing matched,
|
|
57
|
+
# silently prepended the constants to the top of the file.)
|
|
58
|
+
GENERATED_WARNING = 'GENERATED FROM grammar.yml - DO NOT EDIT'.freeze # qss_auditor.rb checks the engine for this exact text
|
|
59
|
+
GEN_INDENT = ' '.freeze
|
|
60
|
+
|
|
61
|
+
def region_markers(text)
|
|
62
|
+
begins = text.to_enum(:scan, /^[ \t]*(?:#|\/\/) QSS-GENERATED-BEGIN\b.*$/).map { Regexp.last_match }
|
|
63
|
+
ends = text.to_enum(:scan, /^[ \t]*(?:#|\/\/) QSS-GENERATED-END\b.*$/).map { Regexp.last_match }
|
|
64
|
+
[ begins, ends ]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_region!(path, text)
|
|
68
|
+
begins, ends = region_markers(text)
|
|
69
|
+
return [ begins[0], ends[0] ] if begins.size == 1 && ends.size == 1 && begins[0].end(0) <= ends[0].begin(0)
|
|
70
|
+
abort "#{path}: expected exactly one QSS-GENERATED-BEGIN line followed by one QSS-GENERATED-END line " \
|
|
71
|
+
"(found #{begins.size} BEGIN, #{ends.size} END#{begins.size == 1 && ends.size == 1 ? ', END before BEGIN' : ''}). " \
|
|
72
|
+
"Add the pair where the generated constants should live. Nothing was written."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def read_target(path)
|
|
76
|
+
raw = File.binread(path).force_encoding('UTF-8')
|
|
77
|
+
[ raw.include?("\r\n"), raw.gsub("\r\n", "\n") ]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def indent_block(text)
|
|
81
|
+
lines = text.lines
|
|
82
|
+
base = (lines.find { |l| !l.strip.empty? } || '')[/[ \t]*/]
|
|
83
|
+
lines.map { |l| l.strip.empty? ? '' : "#{GEN_INDENT}#{l.start_with?(base) ? l[base.length..] : l.lstrip}".chomp }.join("\n")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Preflight: validate every target's markers before anything is generated or written.
|
|
87
|
+
%w[engine auditor native_auditor].each do |k|
|
|
88
|
+
next unless CORE_MODE && File.exist?(targets[k])
|
|
89
|
+
check_region!(targets[k], read_target(targets[k]).last)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write_region(path, blocks, comment)
|
|
93
|
+
unless File.exist?(path)
|
|
94
|
+
puts "Warning: #{path} not found, skipping."
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
crlf, text = read_target(path)
|
|
98
|
+
first, last = check_region!(path, text)
|
|
99
|
+
body = "\n#{GEN_INDENT}#{comment} #{GENERATED_WARNING}\n" + blocks.map { |b| indent_block(b) }.join("\n\n") + "\n"
|
|
100
|
+
text = text[0...first.end(0)] + body + text[last.begin(0)..]
|
|
101
|
+
File.binwrite(path, crlf ? text.gsub("\n", "\r\n") : text)
|
|
102
|
+
puts "Updated #{path}"
|
|
103
|
+
end
|
|
104
|
+
objects_path = File.join(CORE_ROOT, 'config/qss-objects.json')
|
|
105
|
+
objects = File.exist?(objects_path) ? JSON.parse(File.read(objects_path)) : { "blueprints" => [] }
|
|
106
|
+
|
|
107
|
+
# 1. SCSS settings, twice. Core's defaults file (grammar.yml alone, every setting !default) is imported by core;
|
|
108
|
+
# the consumer's file (grammar.yml + consumer config, plain assignments) is imported by the consumer BEFORE core,
|
|
109
|
+
# so its values win over the defaults. Core's compiled CSS therefore carries no consumer's settings.
|
|
110
|
+
def scss_variables(config, header, default)
|
|
111
|
+
d = default ? ' !default' : ''
|
|
112
|
+
scss_map = ->(h) { h.map { |k, v| " \"#{k}\": #{v}" }.join(",\n") }
|
|
113
|
+
scss_scale = ->(h) { h.map { |k, v| " \"#{k.to_s.tr('_', '-')}\": #{v}" }.join(",\n") }
|
|
114
|
+
<<~SCSS
|
|
115
|
+
/* #{header} - DO NOT EDIT MANUALLY */
|
|
116
|
+
|
|
117
|
+
$qss-unit: #{config['unit']}#{d};
|
|
118
|
+
$qss-anchor: "#{config['anchor']}"#{d};
|
|
119
|
+
|
|
120
|
+
/* Overlay portal root; "" = rules scoped to the anchor only. */
|
|
121
|
+
$qss-portal-root: "#{config['portal_root']}"#{d};
|
|
122
|
+
|
|
123
|
+
$qss-palette: (
|
|
124
|
+
#{scss_map.(config['palette'])}
|
|
125
|
+
)#{d};
|
|
126
|
+
|
|
127
|
+
$qss-depths: (
|
|
128
|
+
#{scss_map.(config['depths'])}
|
|
129
|
+
)#{d};
|
|
130
|
+
|
|
131
|
+
$qss-quantization: (
|
|
132
|
+
#{scss_scale.(config['quantization'])}
|
|
133
|
+
)#{d};
|
|
134
|
+
|
|
135
|
+
/* Text scale (item 2, SETTLED 2026-09-21). */
|
|
136
|
+
$qss-text-scale: (
|
|
137
|
+
#{scss_scale.(config['text_scale'])}
|
|
138
|
+
)#{d};
|
|
139
|
+
|
|
140
|
+
/* Non-text content scale (item 3, SETTLED 2026-09-21). */
|
|
141
|
+
$qss-content-scale: (
|
|
142
|
+
#{scss_scale.(config['content_scale'])}
|
|
143
|
+
)#{d};
|
|
144
|
+
|
|
145
|
+
/* Named-group registry (consumer-supplied). */
|
|
146
|
+
$qss-group-names: (#{(config['group_names'] || []).map { |n| "\"#{n}\"" }.join(", ")})#{d};
|
|
147
|
+
|
|
148
|
+
$qss-breakpoints: (
|
|
149
|
+
#{scss_map.(config['breakpoints'])}
|
|
150
|
+
)#{d};
|
|
151
|
+
|
|
152
|
+
$qss-vertical-breakpoints: (
|
|
153
|
+
#{scss_map.(config['vertical_breakpoints'])}
|
|
154
|
+
)#{d};
|
|
155
|
+
|
|
156
|
+
$qss-fonts: (
|
|
157
|
+
#{scss_map.(config['fonts'].transform_values { |v| "\"#{v}\"" })}
|
|
158
|
+
)#{d};
|
|
159
|
+
SCSS
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if CORE_MODE
|
|
163
|
+
File.write(targets['core_variables'], scss_variables(grammar['config'], 'QSS core defaults, generated from grammar.yml alone', true))
|
|
164
|
+
puts "Generated #{targets['core_variables']}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if File.exist?(consumer_path)
|
|
168
|
+
File.write(targets['variables'], scss_variables(config, "Generated from grammar.yml + #{consumer_path}", false))
|
|
169
|
+
puts "Generated #{targets['variables']}"
|
|
170
|
+
else
|
|
171
|
+
puts "No consumer config: #{targets['variables']} not written"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Core-only inputs for everything that ships in the gem (engine, auditors).
|
|
175
|
+
core_config = grammar['config']
|
|
176
|
+
core_adjectives = grammar['adjectives']
|
|
177
|
+
|
|
178
|
+
# 2. qss-engine.js -- CORE ONLY (grammar.yml alone; the engine ships in the gem).
|
|
179
|
+
palette_js = "const BRAND_COLORS = {\n" + core_config['palette'].map { |k, v| " '#{k}': '#{v}'" }.join(",\n") + "\n};"
|
|
180
|
+
depths_js = "const DEPTHS = {\n" + core_config['depths'].map { |k, v| " '#{k}': '#{v}'" }.join(",\n") + "\n};"
|
|
181
|
+
blueprints_js = "let objectRegistry = " + JSON.pretty_generate(objects['blueprints'].each_with_object({}) { |b, h| h[b['name']] = b }) + ";"
|
|
182
|
+
fonts_js = "const FONTS = {\n" + core_config['fonts'].map { |k, v| " '#{k}': \"#{v}\"" }.join(",\n") + "\n};"
|
|
183
|
+
whitelist_js = "const DSL_WHITELIST = new Set([\n" + core_adjectives.values.flatten.uniq.sort.map { |a| " '#{a}'" }.join(",\n") + "\n]);"
|
|
184
|
+
write_region(targets['engine'], [ palette_js, depths_js, blueprints_js, fonts_js, whitelist_js ], '//') if CORE_MODE
|
|
185
|
+
|
|
186
|
+
# 3. QSS::QssAuditor -- CORE ONLY. The auditors ship in the gem, so their generated constants carry grammar.yml's
|
|
187
|
+
# neutral defaults and nothing from a consumer; a consumer's settings reach them at runtime (lib/qss/configuration.rb).
|
|
188
|
+
core_auditor_cfg = (grammar['auditor'] || {}).merge('portal_root' => core_config['portal_root'].to_s)
|
|
189
|
+
whitelist_rb = "DSL_WHITELIST = [\n " + core_adjectives.values.flatten.uniq.sort.map { |a| "'#{a}'" }.join(",\n ") + "\n].freeze"
|
|
190
|
+
payload_tags_rb = "PAYLOAD_TAGS = " + grammar['payload_tags'].inspect + ".freeze"
|
|
191
|
+
categories_rb = "ADJECTIVE_CATEGORIES = " + core_adjectives.inspect + ".freeze"
|
|
192
|
+
combos_rb = "ILLEGAL_COMBOS = " + grammar['illegal_combos'].inspect + ".freeze"
|
|
193
|
+
blueprints_rb = "BLUEPRINT_NAMES = " + objects['blueprints'].map { |b| b['name'] }.inspect + ".freeze"
|
|
194
|
+
blueprint_slots = objects['blueprints'].each_with_object({}) { |b, h| h[b['name']] = (b['slots'] || []).map { |s| s['name'] } }
|
|
195
|
+
slots_rb = "BLUEPRINT_SLOTS = " + blueprint_slots.inspect + ".freeze"
|
|
196
|
+
palette_rb = "BRAND_COLORS = " + core_config['palette'].inspect + ".freeze"
|
|
197
|
+
depths_rb = "DEPTHS = " + core_config['depths'].inspect + ".freeze"
|
|
198
|
+
auditor_config_rb = "AUDITOR_CONFIG = " + core_auditor_cfg.inspect + ".freeze"
|
|
199
|
+
if CORE_MODE
|
|
200
|
+
write_region(targets['auditor'],
|
|
201
|
+
[ whitelist_rb, payload_tags_rb, categories_rb, combos_rb, blueprints_rb, slots_rb, palette_rb, depths_rb, auditor_config_rb ], '#')
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# 4. QSS::QssNativeAuditor
|
|
205
|
+
write_region(targets['native_auditor'], [ payload_tags_rb, auditor_config_rb ], '#') if CORE_MODE
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "ostruct"
|
|
3
|
+
|
|
4
|
+
module QSS
|
|
5
|
+
# Core helpers for layout arithmetic and object blueprints (config/qss-objects.json), available as QSS.grid,
|
|
6
|
+
# QSS.object, QSS.resolve_parametric and QSS.resolve_height.
|
|
7
|
+
module Blueprints
|
|
8
|
+
OBJECTS_PATH = File.expand_path("../../config/qss-objects.json", __dir__)
|
|
9
|
+
|
|
10
|
+
# Deterministic Grid Calculation
|
|
11
|
+
# Returns an array of OpenStructs with x, y, w, h, and index
|
|
12
|
+
def grid(count, cols:, w:, h:, gap_x: 0, gap_y: 0)
|
|
13
|
+
(0...count).map do |i|
|
|
14
|
+
row = (i / cols).to_i
|
|
15
|
+
col = i % cols
|
|
16
|
+
|
|
17
|
+
OpenStruct.new(
|
|
18
|
+
index: i + 1,
|
|
19
|
+
x: col * (w + gap_x),
|
|
20
|
+
y: row * (h + gap_y),
|
|
21
|
+
w: w,
|
|
22
|
+
h: h
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Object Blueprint Resolver
|
|
28
|
+
def object(name)
|
|
29
|
+
@objects ||= load_objects
|
|
30
|
+
@objects[name.to_s]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resolves parameters for an object based on its blueprint and optional overrides
|
|
34
|
+
# Returns a hash of resolved dimensions { w: num, h: num }
|
|
35
|
+
def resolve_parametric(blueprint, param_string = nil)
|
|
36
|
+
return blueprint['intrinsic'] unless param_string
|
|
37
|
+
|
|
38
|
+
params = param_string.split('|')
|
|
39
|
+
param_map = blueprint['params'] || []
|
|
40
|
+
dimensions = blueprint['intrinsic'].dup
|
|
41
|
+
|
|
42
|
+
params.each_with_index do |val, idx|
|
|
43
|
+
key = param_map[idx]
|
|
44
|
+
if key && val
|
|
45
|
+
num_val = val.to_f
|
|
46
|
+
|
|
47
|
+
# Clamping logic
|
|
48
|
+
if blueprint["min-#{key}"]
|
|
49
|
+
num_val = [ num_val, blueprint["min-#{key}"].to_f ].max
|
|
50
|
+
end
|
|
51
|
+
if blueprint["max-#{key}"]
|
|
52
|
+
num_val = [ num_val, blueprint["max-#{key}"].to_f ].min
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
dimensions[key] = num_val
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
dimensions
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Resolved Height for Variable Objects
|
|
63
|
+
def resolve_height(blueprint, slot_name = nil, vars = {})
|
|
64
|
+
return 0 unless blueprint
|
|
65
|
+
|
|
66
|
+
intrinsic = if slot_name
|
|
67
|
+
slots = blueprint['slots'] || []
|
|
68
|
+
slot = slots.find { |s| s['name'] == slot_name.to_s }
|
|
69
|
+
slot ? slot['intrinsic'] : nil
|
|
70
|
+
else
|
|
71
|
+
blueprint['intrinsic']
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
unless intrinsic
|
|
75
|
+
# Default to 100% of parent height (1000 units) if missing
|
|
76
|
+
return 1000
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
return intrinsic['h'] if intrinsic['h'].is_a?(Numeric)
|
|
80
|
+
|
|
81
|
+
if intrinsic['h'].to_s == 'computed' || intrinsic['h'].to_s == 'variable'
|
|
82
|
+
variables = blueprint['variables'] || {}
|
|
83
|
+
max_rows = (vars[:max_rows] || variables['max_rows'] || variables[:max_rows] || 100).to_i
|
|
84
|
+
rows = [ (vars[:rows] || 0).to_i, max_rows ].min
|
|
85
|
+
row_height = (vars[:row_height] || variables['row_height'] || variables[:row_height] || 32).to_i
|
|
86
|
+
|
|
87
|
+
return rows * row_height
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Default to 100% inheritance if 'inherit' or missing
|
|
91
|
+
1000
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Core's own blueprint file, found relative to this file (not Rails.root or the working directory).
|
|
97
|
+
def load_objects
|
|
98
|
+
return {} unless File.exist?(OBJECTS_PATH)
|
|
99
|
+
|
|
100
|
+
data = JSON.parse(File.read(OBJECTS_PATH))
|
|
101
|
+
data['blueprints'].each_with_object({}) { |b, h| h[b['name']] = b }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
extend Blueprints
|
|
106
|
+
end
|