qss 0.2.0 → 0.3.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.
@@ -1,86 +1,104 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Renders QSS core's compiled CSS WITH qss-engine.js in headless Chrome and checks engine-only, runtime behavior --
5
- # the opposite guard from qss-core-render-check, which aborts if the engine is present. Exit 0 when every check
6
- # passes, 1 otherwise.
7
- #
8
- # qss-engine-render-check [path/to/qss_core.css] [path/to/qss-engine.js]
9
-
10
- require "selenium-webdriver"
11
- require "tmpdir"
12
-
13
- css_path = File.expand_path(ARGV[0] || File.join(__dir__, "..", "app", "assets", "stylesheets", "qss", "qss_core.css"))
14
- js_path = File.expand_path(ARGV[1] || File.join(__dir__, "..", "app", "assets", "javascripts", "qss", "qss-engine.js"))
15
- abort "qss-engine-render-check: no stylesheet at #{css_path}" unless File.exist?(css_path)
16
- abort "qss-engine-render-check: no engine at #{js_path}" unless File.exist?(js_path)
17
-
18
- HTML = <<~HTML
19
- <!doctype html><html><head>
20
- <link rel="stylesheet" href="file:///#{css_path.tr('\\', '/')}">
21
- <script src="file:///#{js_path.tr('\\', '/')}"></script>
22
- </head><body><div id="qss-canvas" style="width: 1000px">
23
- <div id="auto-name" class="qss qss-w[200] qss-h[100]"></div>
24
- <div id="explicit-name" class="qss qss-w[200] qss-h[100]" style="container-name: custom-name"></div>
25
- <div id="no-id-here"></div>
26
- </div></body></html>
27
- HTML
28
-
29
- failures = 0
30
- total = 0
31
- Dir.mktmpdir do |dir|
32
- page = File.join(dir, "qss_engine_render_check.html")
33
- File.write(page, HTML)
34
- options = Selenium::WebDriver::Chrome::Options.new
35
- %w[--headless --disable-gpu --window-size=1400,1000 --allow-file-access-from-files].each { |a| options.add_argument(a) }
36
- %w[--no-sandbox --disable-dev-shm-usage].each { |a| options.add_argument(a) } if ENV["CI"]
37
- options.binary = ENV["CHROME_BIN"] if ENV["CHROME_BIN"]
38
- service = ENV["CHROMEDRIVER_PATH"] ? Selenium::WebDriver::Service.chrome(path: ENV["CHROMEDRIVER_PATH"]) : nil
39
- driver = service ? Selenium::WebDriver.for(:chrome, options: options, service: service) : Selenium::WebDriver.for(:chrome, options: options)
40
- begin
41
- driver.navigate.to("file:///#{page.tr('\\', '/')}")
42
- # qss-engine.js declares `const QSSEngine = ...` at top-level script scope: a top-level const/let binding (unlike
43
- # var) is never a property of window, so the only reliable presence check is the bare identifier.
44
- engine = driver.execute_script("return typeof QSSEngine !== 'undefined'")
45
- abort "qss-engine-render-check: qss-engine.js did not load" unless engine
46
-
47
- # The documented usage (README "The JavaScript engine"): startObserver too, not just startPulse -- scanAndTranspile
48
- # needs its style tag, which only startObserver creates.
49
- driver.execute_script("QSSEngine.setAnchor('#qss-canvas'); QSSEngine.startPulse(); QSSEngine.startObserver();")
50
-
51
- # startPulse's own two nested requestAnimationFrame calls make this genuinely async; poll real elapsed time
52
- # (not another assumed frame count) until applyContainerNames has visibly run, or fail outright on timeout.
53
- wait = Selenium::WebDriver::Wait.new(timeout: 2)
54
- begin
55
- wait.until { driver.execute_script("return getComputedStyle(document.getElementById('auto-name')).containerName") != "none" }
56
- rescue Selenium::WebDriver::Error::TimeoutError
57
- # Let the checks below report the actual (failing) state rather than raising here.
58
- end
59
-
60
- computed_name = ->(id) { driver.execute_script("return getComputedStyle(document.getElementById(arguments[0])).containerName", id) }
61
-
62
- [
63
- [ "the engine names an actor's container after its own id", "auto-name", "auto-name" ],
64
- [ "the engine never overwrites a container-name already set by anything", "explicit-name", "custom-name" ]
65
- ].each do |label, id, expected|
66
- actual = computed_name.(id)
67
- ok = actual == expected
68
- total += 1
69
- failures += 1 unless ok
70
- puts format("%-4s %-56s container-name=%s%s", ok ? "ok" : "FAIL", label, actual, ok ? "" : " (expected #{expected})")
71
- end
72
-
73
- # An element with no id at all is never a candidate (nothing to derive a name from) -- confirms the engine
74
- # skips it outright rather than, say, setting an empty/undefined name.
75
- no_id_name = computed_name.("no-id-here")
76
- ok = no_id_name == "none"
77
- total += 1
78
- failures += 1 unless ok
79
- puts format("%-4s %-56s container-name=%s%s", ok ? "ok" : "FAIL", "an id-less element is never named", no_id_name, ok ? "" : " (expected none)")
80
- ensure
81
- driver.quit
82
- end
83
- end
84
-
85
- puts failures.zero? ? "qss-engine-render-check: #{total} of #{total} passed" : "qss-engine-render-check: #{failures} of #{total} FAILED"
86
- exit(failures.zero? ? 0 : 1)
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Renders QSS core's compiled CSS WITH qss-engine.js in headless Chrome and checks engine-only, runtime behavior --
5
+ # the opposite guard from qss-core-render-check, which aborts if the engine is present. Exit 0 when every check
6
+ # passes, 1 otherwise.
7
+ #
8
+ # qss-engine-render-check [path/to/qss_core.css] [path/to/qss-engine.js]
9
+
10
+ require "selenium-webdriver"
11
+ require "tmpdir"
12
+
13
+ css_path = File.expand_path(ARGV[0] || File.join(__dir__, "..", "app", "assets", "stylesheets", "qss", "qss_core.css"))
14
+ js_path = File.expand_path(ARGV[1] || File.join(__dir__, "..", "app", "assets", "javascripts", "qss", "qss-engine.js"))
15
+ abort "qss-engine-render-check: no stylesheet at #{css_path}" unless File.exist?(css_path)
16
+ abort "qss-engine-render-check: no engine at #{js_path}" unless File.exist?(js_path)
17
+
18
+ HTML = <<~HTML
19
+ <!doctype html><html><head>
20
+ <link rel="stylesheet" href="file:///#{css_path.tr('\\', '/')}">
21
+ <script src="file:///#{js_path.tr('\\', '/')}"></script>
22
+ </head><body><div id="qss-canvas" style="width: 1000px">
23
+ <div id="auto-name" class="qss qss-w[200] qss-h[100]"></div>
24
+ <div id="explicit-name" class="qss qss-w[200] qss-h[100]" style="container-name: custom-name"></div>
25
+ <div id="no-id-here"></div>
26
+ <div id="frame-fixed" class="qss qss-fixed qss-w[200] qss-h[100]"></div>
27
+ <div id="inner-fixed" style="position: fixed"></div>
28
+ <div id="inner-sticky" style="position: sticky"></div>
29
+ <div class="foreign"><div id="foreign-fixed" style="position: fixed"></div></div>
30
+ </div>
31
+ <div id="body-fixed" style="position: fixed"></div>
32
+ <div id="qss-portal"><div id="portal-fixed" style="position: fixed"></div></div>
33
+ <dialog id="top-layer-modal"></dialog>
34
+ </body></html>
35
+ HTML
36
+
37
+ failures = 0
38
+ total = 0
39
+ Dir.mktmpdir do |dir|
40
+ page = File.join(dir, "qss_engine_render_check.html")
41
+ File.write(page, HTML)
42
+ options = Selenium::WebDriver::Chrome::Options.new
43
+ %w[--headless --disable-gpu --window-size=1400,1000 --allow-file-access-from-files].each { |a| options.add_argument(a) }
44
+ %w[--no-sandbox --disable-dev-shm-usage].each { |a| options.add_argument(a) } if ENV["CI"]
45
+ options.binary = ENV["CHROME_BIN"] if ENV["CHROME_BIN"]
46
+ service = ENV["CHROMEDRIVER_PATH"] ? Selenium::WebDriver::Service.chrome(path: ENV["CHROMEDRIVER_PATH"]) : nil
47
+ driver = service ? Selenium::WebDriver.for(:chrome, options: options, service: service) : Selenium::WebDriver.for(:chrome, options: options)
48
+ begin
49
+ driver.navigate.to("file:///#{page.tr('\\', '/')}")
50
+ # qss-engine.js declares `const QSSEngine = ...` at top-level script scope: a top-level const/let binding (unlike
51
+ # var) is never a property of window, so the only reliable presence check is the bare identifier.
52
+ engine = driver.execute_script("return typeof QSSEngine !== 'undefined'")
53
+ abort "qss-engine-render-check: qss-engine.js did not load" unless engine
54
+
55
+ # The documented usage (README "The JavaScript engine"): startObserver too, not just startPulse -- scanAndTranspile
56
+ # needs its style tag, which only startObserver creates.
57
+ driver.execute_script("QSSEngine.setAnchor('#qss-canvas'); QSSEngine.startPulse(); QSSEngine.startObserver();")
58
+
59
+ # startPulse's own two nested requestAnimationFrame calls make this genuinely async; poll real elapsed time
60
+ # (not another assumed frame count) until applyContainerNames has visibly run, or fail outright on timeout.
61
+ wait = Selenium::WebDriver::Wait.new(timeout: 2)
62
+ begin
63
+ wait.until { driver.execute_script("return getComputedStyle(document.getElementById('auto-name')).containerName") != "none" }
64
+ rescue Selenium::WebDriver::Error::TimeoutError
65
+ # Let the checks below report the actual (failing) state rather than raising here.
66
+ end
67
+
68
+ computed_name = ->(id) { driver.execute_script("return getComputedStyle(document.getElementById(arguments[0])).containerName", id) }
69
+
70
+ [
71
+ [ "the engine names an actor's container after its own id", "auto-name", "auto-name" ],
72
+ [ "the engine never overwrites a container-name already set by anything", "explicit-name", "custom-name" ]
73
+ ].each do |label, id, expected|
74
+ actual = computed_name.(id)
75
+ ok = actual == expected
76
+ total += 1
77
+ failures += 1 unless ok
78
+ puts format("%-4s %-56s container-name=%s%s", ok ? "ok" : "FAIL", label, actual, ok ? "" : " (expected #{expected})")
79
+ end
80
+
81
+ # An element with no id at all is never a candidate (nothing to derive a name from) -- confirms the engine
82
+ # skips it outright rather than, say, setting an empty/undefined name.
83
+ no_id_name = computed_name.("no-id-here")
84
+ ok = no_id_name == "none"
85
+ total += 1
86
+ failures += 1 unless ok
87
+ puts format("%-4s %-56s container-name=%s%s", ok ? "ok" : "FAIL", "an id-less element is never named", no_id_name, ok ? "" : " (expected none)")
88
+
89
+ # The opt-in positioning diagnostic: fixed/sticky positioning that QSS does not own is reported; a Steel Frame,
90
+ # the portal, foreign content and a modal <dialog> in the native top layer are not.
91
+ driver.execute_script("document.getElementById('top-layer-modal').showModal()")
92
+ flagged = driver.execute_script("return QSSEngine.auditPositioning({ warn: false }).map(o => o.id)").sort
93
+ expected = %w[body-fixed inner-fixed inner-sticky]
94
+ ok = flagged == expected
95
+ total += 1
96
+ failures += 1 unless ok
97
+ puts format("%-4s %-56s flagged=%s%s", ok ? "ok" : "FAIL", "auditPositioning reports only non-QSS fixed/sticky", flagged.inspect, ok ? "" : " (expected #{expected.inspect})")
98
+ ensure
99
+ driver.quit
100
+ end
101
+ end
102
+
103
+ puts failures.zero? ? "qss-engine-render-check: #{total} of #{total} passed" : "qss-engine-render-check: #{failures} of #{total} FAILED"
104
+ exit(failures.zero? ? 0 : 1)
data/exe/qss-sync CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  require 'json'
5
5
  require 'yaml'
6
+ require_relative '../lib/qss/configuration'
6
7
 
7
8
  # Generates QSS's derived files from two inputs:
8
9
  # config/grammar.yml -- core grammar + neutral defaults (source of truth), found relative to this script
@@ -38,6 +39,13 @@ end
38
39
 
39
40
  config = deep_merge(grammar['config'], consumer['config'] || {})
40
41
 
42
+ # The portal root is QSS's own id: "" or "#qss-...". Fail here, before generating anything from a bad value.
43
+ begin
44
+ [ grammar['config'], config ].each { |c| QSS.validate_portal_root!(c['portal_root']) }
45
+ rescue QSS::ConfigurationError => e
46
+ abort e.message
47
+ end
48
+
41
49
  # A consumer's grammar_extensions and auditor: settings are NOT written anywhere by this script: the auditors read
42
50
  # them at runtime (lib/qss/configuration.rb). Only the consumer's SCSS settings file is generated from them.
43
51
 
@@ -161,7 +169,7 @@ def scss_variables(config, header, default)
161
169
  end
162
170
 
163
171
  if CORE_MODE
164
- File.write(targets['core_variables'], scss_variables(grammar['config'], 'QSS core defaults, generated from grammar.yml alone', true))
172
+ File.binwrite(targets['core_variables'], scss_variables(grammar['config'], 'QSS core defaults, generated from grammar.yml alone', true).gsub(/\r?\n/, "\r\n")) # repo files are CRLF
165
173
  puts "Generated #{targets['core_variables']}"
166
174
  end
167
175
 
@@ -3,6 +3,33 @@
3
3
  require "yaml"
4
4
 
5
5
  module QSS
6
+ # A configuration the gem cannot work with (raised loudly rather than audited around).
7
+ class ConfigurationError < StandardError; end
8
+
9
+ # The overlay portal root is the one element overlays are rooted in, outside the canvas. It is identified by an
10
+ # exact id, never guessed from substrings or prefixes: config.portal_root, a simple #id selector the project
11
+ # chooses. Unset, it is `#qss-portal`.
12
+ DEFAULT_PORTAL_ID = "qss-portal"
13
+ PORTAL_ROOT_FORMAT = /\A#-?[A-Za-z_][A-Za-z0-9_-]*\z/
14
+
15
+ class << self
16
+ # config.portal_root must be "" (unset) or a simple #id selector.
17
+ def validate_portal_root!(value)
18
+ value = value.to_s
19
+ return value if value.empty? || value.match?(PORTAL_ROOT_FORMAT)
20
+
21
+ raise ConfigurationError, "config.portal_root must be a simple #id selector (for example " \
22
+ "\"#qss-portal\"), got #{value.inspect}"
23
+ end
24
+
25
+ # Is this id the overlay portal root? Exactly the configured id, or qss-portal when none is configured.
26
+ def portal_id?(id, portal_root = "")
27
+ return false if id.to_s.empty?
28
+
29
+ id.to_s == (portal_root.to_s.empty? ? DEFAULT_PORTAL_ID : portal_root.to_s.delete_prefix("#"))
30
+ end
31
+ end
32
+
6
33
  # The consuming project's QSS settings, read at runtime. QSS core ships only neutral defaults (generated from
7
34
  # grammar.yml into each auditor's AUDITOR_CONFIG); a consumer declares its own root and config file once:
8
35
  #
@@ -52,6 +79,7 @@ module QSS
52
79
  settings = deep_merge(defaults, data["auditor"] || {})
53
80
  portal = data.dig("config", "portal_root")
54
81
  settings["portal_root"] = portal.to_s unless portal.nil?
82
+ QSS.validate_portal_root!(settings["portal_root"])
55
83
  settings["registry_path"] = resolve(settings["registry_path"])
56
84
  settings["stylesheet_path"] = resolve(settings["stylesheet_path"])
57
85
  settings["drywall_stylesheet_path"] = resolve(settings["drywall_stylesheet_path"])
@@ -215,7 +215,7 @@ def self.settings
215
215
  end
216
216
 
217
217
  # Everything derived from the settings, built once per audited file.
218
- Rules = Struct.new(:id_prefix, :structural_id_pattern, :narrow_id_pattern, :icon_prefix_pattern, :portal_label, :whitelist)
218
+ Rules = Struct.new(:id_prefix, :structural_id_pattern, :narrow_id_pattern, :icon_prefix_pattern, :portal_label, :whitelist, :portal_root)
219
219
 
220
220
  def self.rules(settings = self.settings)
221
221
  prefix = settings['id_prefix'].to_s
@@ -227,7 +227,8 @@ def self.rules(settings = self.settings)
227
227
  /^#{Regexp.escape(prefix)}.*(#{words.('narrow_words')})/i,
228
228
  settings['icon_prefixes'].empty? ? nil : /^(#{words.('icon_prefixes')})/,
229
229
  settings['portal_root'].to_s.empty? ? 'the overlay portal root' : settings['portal_root'],
230
- extensions.empty? ? DSL_WHITELIST : (DSL_WHITELIST + extensions).uniq
230
+ extensions.empty? ? DSL_WHITELIST : (DSL_WHITELIST + extensions).uniq,
231
+ settings['portal_root'].to_s
231
232
  )
232
233
  end
233
234
 
@@ -333,13 +334,13 @@ end
333
334
 
334
335
  # 2.3 Containment Enforcement
335
336
  if active_thin_protocol && id.match?(r.narrow_id_pattern)
336
- unless styles.include?('contain: layout') || id.include?('portal') || is_genuinely_foreign
337
+ unless styles.include?('contain: layout') || QSS.portal_id?(id, r.portal_root) || is_genuinely_foreign
337
338
  issues << { type: 'protocol', message: "Thin Wrapper Violation: Structural element ##{id} must carry 'contain: layout'", severity: 'red' }
338
339
  end
339
340
  end
340
341
 
341
342
  # 2.4 Stacking Context Warning
342
- if active_thin_protocol && prefixed_id?(id, r.id_prefix) && styles.include?('z-index') && !id.include?('portal')
343
+ if active_thin_protocol && prefixed_id?(id, r.id_prefix) && styles.include?('z-index') && !QSS.portal_id?(id, r.portal_root)
343
344
  z_val = styles.match(/z-index\s*:\s*(\d+)/i)&.captures&.first
344
345
  issues << { type: 'protocol', message: "Stacking Context Warning: z-index:#{z_val} on ##{id} is inside a contained frame. Use #{r.portal_label}.", severity: 'orange' }
345
346
  end
@@ -375,7 +376,7 @@ end
375
376
  end
376
377
 
377
378
  # 3.3 Explicit Dimension Rule
378
- if active_thin_protocol && id.match?(r.narrow_id_pattern) && !id.include?('portal')
379
+ if active_thin_protocol && id.match?(r.narrow_id_pattern) && !QSS.portal_id?(id, r.portal_root)
379
380
  has_width = styles.match(/\bwidth\s*:\s*[^;]+/i) || classes.any? { |c| c.match?(/^(qss-w\[|qss-w-full|qss-w-f|w-\d+)/) || c.match?(/^[A-Z][a-zA-Z0-9]+$/) } || (styles.include?('left:') && styles.include?('right:'))
380
381
  has_height = styles.match(/\bheight\s*:\s*[^;]+/i) || classes.any? { |c| c.match?(/^(qss-h\[|qss-h-full|qss-h-f|h-\d+)/) || c.match?(/^[A-Z][a-zA-Z0-9]+$/) } || (styles.include?('top:') && styles.include?('bottom:'))
381
382
 
@@ -29,16 +29,10 @@ module QSS
29
29
  /\b(#{settings['narrow_words'].map { |w| Regexp.escape(w) }.join('|')})\b/i
30
30
  end
31
31
 
32
- # Is this id the overlay portal root? Scoped, not a substring match (an id merely containing "portal" -- a
33
- # "portal-nav", a "customer-portal" -- is not an overlay root): the id the project configured as `portal_root`
34
- # (a simple #id selector), or `portal` behind the framework prefix (qss-portal, qss-portal-overlays) or the
35
- # project's own id prefix (app-portal).
32
+ # Is this id the overlay portal root? The id configured as `portal_root` (a simple #id selector, see
33
+ # QSS.validate_portal_root!), or `qss-portal` when none is configured -- exact, never a substring match.
36
34
  def self.portal_id?(id, settings = self.settings)
37
- return false if id.to_s.empty?
38
- configured = settings['portal_root'].to_s
39
- return true if configured.match?(/\A#[\w-]+\z/) && id == configured.delete_prefix('#')
40
- prefixes = [ 'qss-', id_prefix(settings) ].reject(&:empty?)
41
- prefixes.any? { |p| id.match?(/\A#{Regexp.escape(p)}portal(?:-|\z)/) }
35
+ QSS.portal_id?(id, settings['portal_root'])
42
36
  end
43
37
 
44
38
  def self.portal_label(settings = self.settings)
data/lib/qss/version.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module QSS
4
4
  # The gem's version. QSS::VERSION is the usual spelling; QSS::Version::STRING the same value.
5
5
  module Version
6
- STRING = "0.2.0"
6
+ STRING = "0.3.0"
7
7
  end
8
8
 
9
9
  VERSION = Version::STRING
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kraig Fairhurst
@@ -100,7 +100,7 @@ description: |-
100
100
 
101
101
  QSS also audits what can be checked statically. Two auditors flag violations such as !important, inline layout styles, undeclared sizes and raw viewport units. RED marks a rule violation and ORANGE a judgment call. Static checks can't prove a page renders, so QSS includes a render check for the compiled CSS alone, and its design history is explicit that only a real browser is real proof.
102
102
 
103
- QSS was extracted from Parapet, a Rails development tool, which was its first consumer. Its early custom parser was a bug farm, and an invented syntax costs agents more than it buys. Structure moved to plain CSS custom properties; bracket values remain for content such as spacing and sizes. What was tried and dropped is in docs/DESIGN_HISTORY.md.
103
+ QSS was extracted from a Rails development tool, which was its first consumer. Its early custom parser was a bug farm, and an invented syntax costs agents more than it buys. Structure moved to plain CSS custom properties; bracket values remain for content such as spacing and sizes. What was tried and dropped is in docs/DESIGN_HISTORY.md.
104
104
 
105
105
  Status: 0.x. Expect names and APIs to change before 1.0.
106
106
  email: