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.
@@ -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,3 @@
1
+ {
2
+ "blueprints": []
3
+ }
@@ -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