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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/QSS-MANIFESTO.md +1 -1
- data/README.md +13 -5
- data/app/assets/javascripts/qss/qss-engine.js +43 -0
- data/app/assets/stylesheets/qss/qss_core.scss +537 -537
- data/exe/qss-engine-render-check +104 -86
- data/exe/qss-sync +9 -1
- data/lib/qss/configuration.rb +28 -0
- data/lib/qss/qss_auditor.rb +6 -5
- data/lib/qss/qss_native_auditor.rb +3 -9
- data/lib/qss/version.rb +1 -1
- metadata +2 -2
data/exe/qss-engine-render-check
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
driver.
|
|
50
|
-
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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
|
|
data/lib/qss/configuration.rb
CHANGED
|
@@ -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"])
|
data/lib/qss/qss_auditor.rb
CHANGED
|
@@ -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') ||
|
|
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') && !
|
|
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) && !
|
|
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?
|
|
33
|
-
#
|
|
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
|
-
|
|
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
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.
|
|
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
|
|
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:
|