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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module QSS
|
|
6
|
+
# The consuming project's QSS settings, read at runtime. QSS core ships only neutral defaults (generated from
|
|
7
|
+
# grammar.yml into each auditor's AUDITOR_CONFIG); a consumer declares its own root and config file once:
|
|
8
|
+
#
|
|
9
|
+
# QSS.configure(root: "/path/to/project", config: "config/myapp/qss_config.yml")
|
|
10
|
+
#
|
|
11
|
+
# Relative paths (the config file, and paths inside it such as auditor.registry_path) resolve against that
|
|
12
|
+
# root, never the process working directory. Without QSS.configure, the root is Rails.root when a Rails app
|
|
13
|
+
# is loaded; with neither, relative paths fall back to the working directory (a last resort, not a contract).
|
|
14
|
+
class Configuration
|
|
15
|
+
attr_reader :root, :config_path
|
|
16
|
+
|
|
17
|
+
def initialize(root: nil, config: nil)
|
|
18
|
+
@root = root && File.expand_path(root.to_s)
|
|
19
|
+
@config_path = config && resolve(config.to_s)
|
|
20
|
+
@cache = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def root_or_default
|
|
24
|
+
return @root if @root
|
|
25
|
+
return File.expand_path(Rails.root.to_s) if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
26
|
+
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A relative path is resolved against the project root (File.expand_path keeps an absolute one as it is).
|
|
31
|
+
def resolve(path)
|
|
32
|
+
path = path.to_s
|
|
33
|
+
return "" if path.empty?
|
|
34
|
+
base = root_or_default
|
|
35
|
+
base ? File.expand_path(path, base) : File.expand_path(path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The consumer's config file as a Hash ({} when none is configured or the file is missing). Re-read when the
|
|
39
|
+
# file changes.
|
|
40
|
+
def consumer
|
|
41
|
+
return {} unless @config_path && File.exist?(@config_path)
|
|
42
|
+
|
|
43
|
+
mtime = File.mtime(@config_path)
|
|
44
|
+
@cache = { mtime: mtime, data: (YAML.load_file(@config_path) || {}) } unless @cache[:mtime] == mtime
|
|
45
|
+
@cache[:data]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Auditor settings: the core defaults with the consumer's `auditor:` block over them. The overlay portal root
|
|
49
|
+
# comes from the consumer's `config.portal_root` (shared with the SCSS side); registry_path is resolved.
|
|
50
|
+
def auditor_settings(defaults)
|
|
51
|
+
data = consumer
|
|
52
|
+
settings = deep_merge(defaults, data["auditor"] || {})
|
|
53
|
+
portal = data.dig("config", "portal_root")
|
|
54
|
+
settings["portal_root"] = portal.to_s unless portal.nil?
|
|
55
|
+
settings["registry_path"] = resolve(settings["registry_path"])
|
|
56
|
+
settings.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The consumer's additions to the adjective categories (grammar_extensions: { category => [names] }).
|
|
60
|
+
def grammar_extensions
|
|
61
|
+
consumer["grammar_extensions"] || {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def deep_merge(base, over)
|
|
67
|
+
return over unless base.is_a?(Hash) && over.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
base.merge(over) { |_key, a, b| deep_merge(a, b) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class << self
|
|
74
|
+
def configuration
|
|
75
|
+
@configuration ||= Configuration.new
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def configure(root:, config: nil)
|
|
79
|
+
@configuration = Configuration.new(root: root, config: config)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/qss/engine.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QSS
|
|
4
|
+
# The Rails integration: a plain engine (no routes, controllers or models) so a Rails app finds QSS's assets
|
|
5
|
+
# (app/assets/stylesheets/qss/*, app/assets/javascripts/qss/*) through its asset pipeline. lib/qss.rb loads this
|
|
6
|
+
# file only when Rails is present; the auditors and configuration work without Rails.
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
require 'nokogiri'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module QSS
|
|
5
|
+
class QssAuditor
|
|
6
|
+
# rubocop:disable all -- generated by exe/qss-sync; its layout is the generator's, not the house style
|
|
7
|
+
# QSS-GENERATED-BEGIN (exe/qss-sync rewrites everything below, up to the closing marker)
|
|
8
|
+
# GENERATED FROM grammar.yml - DO NOT EDIT
|
|
9
|
+
DSL_WHITELIST = [
|
|
10
|
+
'absolute',
|
|
11
|
+
'anchored',
|
|
12
|
+
'around',
|
|
13
|
+
'bg',
|
|
14
|
+
'bg-alpha',
|
|
15
|
+
'blend',
|
|
16
|
+
'blur',
|
|
17
|
+
'border',
|
|
18
|
+
'border-alpha',
|
|
19
|
+
'border-b',
|
|
20
|
+
'border-l',
|
|
21
|
+
'border-r',
|
|
22
|
+
'border-t',
|
|
23
|
+
'bright',
|
|
24
|
+
'busy',
|
|
25
|
+
'centered',
|
|
26
|
+
'clickable',
|
|
27
|
+
'contain',
|
|
28
|
+
'contrast',
|
|
29
|
+
'cover',
|
|
30
|
+
'cursor-col-resize',
|
|
31
|
+
'cursor-row-resize',
|
|
32
|
+
'detached',
|
|
33
|
+
'dev-border',
|
|
34
|
+
'evenly',
|
|
35
|
+
'flex',
|
|
36
|
+
'flex-1',
|
|
37
|
+
'flex-auto',
|
|
38
|
+
'flex-col',
|
|
39
|
+
'flex-none',
|
|
40
|
+
'flex-nowrap',
|
|
41
|
+
'flex-row',
|
|
42
|
+
'flex-wrap',
|
|
43
|
+
'floating',
|
|
44
|
+
'font-black',
|
|
45
|
+
'font-bold',
|
|
46
|
+
'font-medium',
|
|
47
|
+
'font-mono',
|
|
48
|
+
'font-sans',
|
|
49
|
+
'foreign',
|
|
50
|
+
'ghost',
|
|
51
|
+
'grayscale',
|
|
52
|
+
'grid',
|
|
53
|
+
'group',
|
|
54
|
+
'h-end',
|
|
55
|
+
'h-f',
|
|
56
|
+
'h-full',
|
|
57
|
+
'h-mid',
|
|
58
|
+
'h-start',
|
|
59
|
+
'horizontal',
|
|
60
|
+
'hue',
|
|
61
|
+
'icon-size',
|
|
62
|
+
'invert',
|
|
63
|
+
'invisible',
|
|
64
|
+
'italic',
|
|
65
|
+
'items-center',
|
|
66
|
+
'justify-between',
|
|
67
|
+
'justify-center',
|
|
68
|
+
'leading',
|
|
69
|
+
'lowercase',
|
|
70
|
+
'nowrap',
|
|
71
|
+
'opacity',
|
|
72
|
+
'outline-none',
|
|
73
|
+
'pos',
|
|
74
|
+
'pos-b',
|
|
75
|
+
'pos-r',
|
|
76
|
+
'pos-x',
|
|
77
|
+
'pos-y',
|
|
78
|
+
'qss',
|
|
79
|
+
'qss-content-driven',
|
|
80
|
+
'qss-dampen-scaling',
|
|
81
|
+
'qss-ergonomic-override',
|
|
82
|
+
'qss-legibility-override',
|
|
83
|
+
'qss-payload-override',
|
|
84
|
+
'relative',
|
|
85
|
+
'resize',
|
|
86
|
+
'ring',
|
|
87
|
+
'rounded',
|
|
88
|
+
'rounded-full',
|
|
89
|
+
'saturate',
|
|
90
|
+
'scroll',
|
|
91
|
+
'scroll-smooth',
|
|
92
|
+
'sepia',
|
|
93
|
+
'shadow',
|
|
94
|
+
'smooth',
|
|
95
|
+
'spaced',
|
|
96
|
+
'stuck',
|
|
97
|
+
'table-fixed',
|
|
98
|
+
'text-alpha',
|
|
99
|
+
'text-size',
|
|
100
|
+
'tracking',
|
|
101
|
+
'tracking-tighter',
|
|
102
|
+
'tracking-wide',
|
|
103
|
+
'tracking-wider',
|
|
104
|
+
'tracking-widest',
|
|
105
|
+
'transition-all',
|
|
106
|
+
'transition-colors',
|
|
107
|
+
'transition-none',
|
|
108
|
+
'truncate',
|
|
109
|
+
'underline',
|
|
110
|
+
'unselectable',
|
|
111
|
+
'uppercase',
|
|
112
|
+
'v-end',
|
|
113
|
+
'v-mid',
|
|
114
|
+
'v-start',
|
|
115
|
+
'vertical',
|
|
116
|
+
'visible',
|
|
117
|
+
'w-f',
|
|
118
|
+
'w-full'
|
|
119
|
+
].freeze
|
|
120
|
+
|
|
121
|
+
PAYLOAD_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "blockquote", "li"].freeze
|
|
122
|
+
|
|
123
|
+
ADJECTIVE_CATEGORIES = {"positional"=>["detached", "floating", "stuck", "absolute", "pos-x", "pos-y", "pos-r", "pos-b", "pos"], "flow_relative"=>["anchored", "relative"], "layout"=>["vertical", "horizontal", "centered", "v-mid", "h-mid", "v-start", "v-end", "h-start", "h-end", "spaced", "around", "evenly", "flex", "flex-col", "flex-row", "flex-wrap", "flex-nowrap", "flex-1", "flex-auto", "flex-none", "items-center", "justify-center", "justify-between", "grid", "table-fixed", "w-full", "h-full", "w-f", "h-f"], "typographic"=>["italic", "uppercase", "lowercase", "underline", "nowrap", "truncate", "font-mono", "font-sans", "font-medium", "font-bold", "font-black", "text-size", "leading", "tracking", "tracking-tighter", "tracking-wide", "tracking-wider", "tracking-widest", "icon-size"], "visual"=>["bg", "border", "border-t", "border-b", "border-l", "border-r", "rounded", "rounded-full", "shadow", "opacity", "blur", "bright", "contrast", "grayscale", "hue", "invert", "saturate", "sepia", "blend", "contain", "cover", "visible", "invisible", "bg-alpha", "border-alpha", "text-alpha"], "interactive"=>["clickable", "smooth", "busy", "ghost", "unselectable", "scroll-smooth", "scroll", "cursor-col-resize", "cursor-row-resize", "transition-all", "transition-colors", "transition-none", "ring", "resize", "outline-none"], "internal"=>["qss", "dev-border", "group", "foreign", "qss-legibility-override", "qss-ergonomic-override", "qss-payload-override", "qss-dampen-scaling", "qss-content-driven"]}.freeze
|
|
124
|
+
|
|
125
|
+
ILLEGAL_COMBOS = [{"tag_in"=>"payload_tags", "forbid_category"=>"positional", "severity"=>"red", "message"=>"Protocol Violation: Payload tags (<tag>) cannot carry flow-breaking positional adjectives. Use a wrapping <div> Frame.", "escape_hatch"=>"qss-payload-override"}].freeze
|
|
126
|
+
|
|
127
|
+
BLUEPRINT_NAMES = [].freeze
|
|
128
|
+
|
|
129
|
+
BLUEPRINT_SLOTS = {}.freeze
|
|
130
|
+
|
|
131
|
+
BRAND_COLORS = {"black"=>"#000000", "white"=>"#ffffff", "transparent"=>"transparent", "none"=>"none", "slate-50"=>"#f8fafc", "slate-100"=>"#f1f5f9", "slate-200"=>"#e2e8f0", "slate-300"=>"#cbd5e1", "slate-400"=>"#94a3b8", "slate-500"=>"#64748b", "slate-600"=>"#475569", "slate-700"=>"#334155", "slate-800"=>"#1e293b", "slate-900"=>"#0f172a", "slate-950"=>"#020617", "rose-500"=>"#f43f5e"}.freeze
|
|
132
|
+
|
|
133
|
+
DEPTHS = {"canvas"=>0, "content"=>100, "ui"=>500, "overlay"=>900, "modal"=>1000}.freeze
|
|
134
|
+
|
|
135
|
+
AUDITOR_CONFIG = {"id_prefix"=>"", "structural_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "narrow_words"=>["header", "footer", "sidebar", "workspace", "main", "panel", "frame", "wrapper", "nav", "aside"], "icon_prefixes"=>[], "registry_path"=>"", "portal_root"=>""}.freeze
|
|
136
|
+
# QSS-GENERATED-END
|
|
137
|
+
# rubocop:enable all
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# Thresholds based on QSS Spec
|
|
214
|
+
LEGIBILITY_FLOOR = 11
|
|
215
|
+
ERGONOMIC_MIN = 44
|
|
216
|
+
|
|
217
|
+
# Core's own grammar, found relative to this file (not the working directory).
|
|
218
|
+
GRAMMAR_PATH = File.expand_path('../../config/grammar.yml', __dir__)
|
|
219
|
+
|
|
220
|
+
# The effective settings: the core defaults above (AUDITOR_CONFIG, generated from grammar.yml alone) with the
|
|
221
|
+
# consuming project's config over them, read at runtime (QSS.configure, lib/qss/configuration.rb).
|
|
222
|
+
def self.settings
|
|
223
|
+
QSS.configuration.auditor_settings(AUDITOR_CONFIG)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Everything derived from the settings, built once per audited file.
|
|
227
|
+
Rules = Struct.new(:id_prefix, :structural_id_pattern, :narrow_id_pattern, :icon_prefix_pattern, :portal_label, :whitelist)
|
|
228
|
+
|
|
229
|
+
def self.rules(settings = self.settings)
|
|
230
|
+
prefix = settings['id_prefix'].to_s
|
|
231
|
+
words = ->(key) { settings[key].map { |w| Regexp.escape(w) }.join('|') }
|
|
232
|
+
extensions = QSS.configuration.grammar_extensions.values.flatten
|
|
233
|
+
Rules.new(
|
|
234
|
+
prefix,
|
|
235
|
+
/^#{Regexp.escape(prefix)}.*(#{words.('structural_words')})/i,
|
|
236
|
+
/^#{Regexp.escape(prefix)}.*(#{words.('narrow_words')})/i,
|
|
237
|
+
settings['icon_prefixes'].empty? ? nil : /^(#{words.('icon_prefixes')})/,
|
|
238
|
+
settings['portal_root'].to_s.empty? ? 'the overlay portal root' : settings['portal_root'],
|
|
239
|
+
extensions.empty? ? DSL_WHITELIST : (DSL_WHITELIST + extensions).uniq
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# An empty id_prefix means "no prefix convention": nothing counts as prefixed/first-party by prefix.
|
|
244
|
+
def self.prefixed_id?(id, prefix = rules.id_prefix)
|
|
245
|
+
!prefix.empty? && id.start_with?(prefix)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def self.first_party?(id, classes, prefix = rules.id_prefix)
|
|
249
|
+
(!prefix.empty? && (id.start_with?(prefix) || classes.any? { |c| c.start_with?(prefix) })) ||
|
|
250
|
+
classes.any? { |c| c.start_with?('qss-') }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Whitelists
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def self.audit_file(file_path)
|
|
258
|
+
file_path = file_path.to_s # callers pass Pathname (specs) or String
|
|
259
|
+
return { status: 'grey', issues: [] } unless File.exist?(file_path)
|
|
260
|
+
raw_content = File.read(file_path).force_encoding('UTF-8').scrub rescue ""
|
|
261
|
+
return { status: 'grey', issues: [] } if raw_content.strip.empty?
|
|
262
|
+
|
|
263
|
+
# 0. Blueprint Integrity & Blast Radius Check (Global)
|
|
264
|
+
issues = []
|
|
265
|
+
r = rules
|
|
266
|
+
is_shared_definition = file_path.match?(/qss-objects\.json|grammar\.yml/i)
|
|
267
|
+
|
|
268
|
+
if is_shared_definition
|
|
269
|
+
issues << { type: 'blueprint', message: "High Blast Radius: You are editing a shared blueprint definition. Changes here propagate site-wide to all instances.", severity: 'orange' }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
if file_path.include?('qss-engine.js')
|
|
273
|
+
grammar_path = GRAMMAR_PATH
|
|
274
|
+
if File.exist?(grammar_path)
|
|
275
|
+
require 'yaml'
|
|
276
|
+
grammar = YAML.load_file(grammar_path) rescue nil
|
|
277
|
+
if grammar
|
|
278
|
+
# Detect manual additions to objectRegistry in JS
|
|
279
|
+
# blue_names = grammar['objects']&.keys || [] # Placeholder if objects moved to grammar.yml
|
|
280
|
+
# For now, we enforce that direct JS edits are forbidden.
|
|
281
|
+
unless raw_content.include?('// GENERATED FROM grammar.yml - DO NOT EDIT')
|
|
282
|
+
issues << { type: 'protocol', message: "Blueprint Integrity Violation: Manual edits detected in generated engine. Changes must be made in grammar.yml.", severity: 'red' }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Strip ERB tags to avoid parser confusion, but keep placeholders for audit
|
|
289
|
+
doc_content = raw_content.gsub(/<%.*?%>/m, '<!--ERB-->')
|
|
290
|
+
doc = Nokogiri::HTML.fragment(doc_content)
|
|
291
|
+
|
|
292
|
+
# 1. Global Checks
|
|
293
|
+
if raw_content.include?('!important')
|
|
294
|
+
issues << { type: 'protocol', message: "Cheat detected: '!important' is forbidden in QSS", severity: 'red' }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# 2. Structural Scoping (Thin Wrapper Protocol)
|
|
298
|
+
is_thin_protocol_file = raw_content.include?('contain: layout') || raw_content.include?('var(--')
|
|
299
|
+
|
|
300
|
+
# 2.0 Shared Definition Overwrite Check (Monkey-patching Prevention)
|
|
301
|
+
if raw_content.match?(/objectRegistry\s*[\[=]|let\s+objectRegistry|const\s+objectRegistry/i) && !file_path.include?('qss-engine.js')
|
|
302
|
+
issues << { type: 'protocol', message: "Monkey-patching Violation: Manual override of 'objectRegistry' detected. Object definitions must be registered through registerObject() or config/qss/qss-objects.json.", severity: 'red' }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
doc.traverse do |node|
|
|
306
|
+
next unless node.element?
|
|
307
|
+
|
|
308
|
+
classes = node['class']&.split(/\s+/) || []
|
|
309
|
+
styles = node['style'] || ""
|
|
310
|
+
id = node['id'] || ""
|
|
311
|
+
is_first_party = first_party?(id, classes, r.id_prefix)
|
|
312
|
+
is_structural = id.match?(r.structural_id_pattern)
|
|
313
|
+
active_thin_protocol = is_structural || is_thin_protocol_file
|
|
314
|
+
|
|
315
|
+
# A zone is genuinely foreign only if it was established by a non-first-party element
|
|
316
|
+
is_genuinely_foreign = node.ancestors.any? do |a|
|
|
317
|
+
a_classes = a['class']&.split(/\s+/) || []
|
|
318
|
+
a_id = a['id'] || ""
|
|
319
|
+
a_is_first_party = first_party?(a_id, a_classes, r.id_prefix)
|
|
320
|
+
(a_classes.include?('foreign') && !a_is_first_party) || a_classes.include?('qss-payload-override')
|
|
321
|
+
end || (classes.include?('foreign') && !is_first_party) || classes.include?('qss-payload-override')
|
|
322
|
+
|
|
323
|
+
# 1.5 Suspicious Foreign Check
|
|
324
|
+
if is_first_party && classes.include?('foreign')
|
|
325
|
+
issues << { type: 'protocol', message: "Suspicious Foreign: First-party element <#{node.name}#{id.empty? ? '' : '#' + id}> is using the 'foreign' escape hatch. Verify this is intended.", severity: 'orange' }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# 2.1 Inline Styles Check
|
|
329
|
+
if !styles.empty?
|
|
330
|
+
# structural check
|
|
331
|
+
unless is_structural || is_genuinely_foreign
|
|
332
|
+
issues << { type: 'protocol', message: "Protocol Violation: Inline styles forbidden on non-structural element <#{node.name}#{id.empty? ? '' : '#' + id}>. Use QSS Adjectives.", severity: 'red' }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# 2.2 Hardcoded Relational Positioning
|
|
336
|
+
if active_thin_protocol && !is_genuinely_foreign
|
|
337
|
+
styles.scan(/\b(?<!-)(left|top|right|bottom|width|height)\s*:\s*(\d+px)/i).each do |prop, val|
|
|
338
|
+
issues << { type: 'protocol', message: "Thin Wrapper Violation: Hardcoded '#{prop}: #{val}' on ##{id}. Use var() for relational anchoring.", severity: 'red' }
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# 2.3 Containment Enforcement
|
|
344
|
+
if active_thin_protocol && id.match?(r.narrow_id_pattern)
|
|
345
|
+
unless styles.include?('contain: layout') || id.include?('portal') || is_genuinely_foreign
|
|
346
|
+
issues << { type: 'protocol', message: "Thin Wrapper Violation: Structural element ##{id} must carry 'contain: layout'", severity: 'red' }
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# 2.4 Stacking Context Warning
|
|
351
|
+
if active_thin_protocol && prefixed_id?(id, r.id_prefix) && styles.include?('z-index') && !id.include?('portal')
|
|
352
|
+
z_val = styles.match(/z-index\s*:\s*(\d+)/i)&.captures&.first
|
|
353
|
+
issues << { type: 'protocol', message: "Stacking Context Warning: z-index:#{z_val} on ##{id} is inside a contained frame. Use #{r.portal_label}.", severity: 'orange' }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# 3. Grammar & Payload Checks
|
|
357
|
+
unless is_genuinely_foreign
|
|
358
|
+
# 3.1 Payload Positional Violation
|
|
359
|
+
if PAYLOAD_TAGS.include?(node.name)
|
|
360
|
+
# Only flag flow-breaking properties. anchored and relative are flow-relative.
|
|
361
|
+
has_flow_breaking = classes.any? { |c| c.match?(/^(detached|floating|stuck|absolute|pos-x|pos-y|pos-r|pos-b|pos)/) || c.start_with?('^') }
|
|
362
|
+
if has_flow_breaking && !classes.include?('qss-payload-override')
|
|
363
|
+
issues << { type: 'protocol', message: "Protocol Violation: Payload tag <#{node.name}> cannot carry flow-breaking adjectives.", severity: 'red' }
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# 3.2 DSL Whitelist & Prefix Check
|
|
368
|
+
classes.each do |cls|
|
|
369
|
+
# Strip common prefixes for check
|
|
370
|
+
base_cls = cls.sub(/^(hover|group-hover|focus|active):/, '')
|
|
371
|
+
|
|
372
|
+
# Allow recognition of known icon/project-standard prefixes
|
|
373
|
+
# The consumer's id prefix is NOT allowed as a class prefix (only as an ID prefix)
|
|
374
|
+
next if r.icon_prefix_pattern && base_cls.match?(r.icon_prefix_pattern)
|
|
375
|
+
|
|
376
|
+
# Tailwind-like content allowance (Strictly anchored)
|
|
377
|
+
next if base_cls.match?(/^(text|bg|border|font|rounded|shadow|opacity|bright)-/)
|
|
378
|
+
next if base_cls.match?(/\A(m[trblxy]?|p[trblxy]?|w|h|space-[xy]|flex|grid|items|justify|gap|scroll|depth|pos-[xyrb]|text-size|rounded|border)-(\d+|full|auto|hidden|px|f|[\d.]+|ui|overlay|modal|canvas|content|center)\z/)
|
|
379
|
+
next if base_cls == 'qss'
|
|
380
|
+
|
|
381
|
+
unless r.whitelist.include?(base_cls)
|
|
382
|
+
issues << { type: 'grammar', message: "Foreign Syntax: '#{cls}' is not a valid QSS Adjective", severity: 'red' }
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# 3.3 Explicit Dimension Rule
|
|
387
|
+
if active_thin_protocol && id.match?(r.narrow_id_pattern) && !id.include?('portal')
|
|
388
|
+
has_width = styles.match(/\bwidth\s*:\s*[^;]+/i) || classes.any? { |c| c.match?(/^(w\[|w-full|w-f|w-\d+)/) || c.match?(/^[A-Z][a-zA-Z0-9]+$/) } || (styles.include?('left:') && styles.include?('right:'))
|
|
389
|
+
has_height = styles.match(/\bheight\s*:\s*[^;]+/i) || classes.any? { |c| c.match?(/^(h\[|h-full|h-f|h-\d+)/) || c.match?(/^[A-Z][a-zA-Z0-9]+$/) } || (styles.include?('top:') && styles.include?('bottom:'))
|
|
390
|
+
|
|
391
|
+
unless (has_width && has_height) || classes.include?('qss-content-driven')
|
|
392
|
+
issues << { type: 'protocol', message: "Implicit Dimension Warning: Structural element ##{id} lacks explicit dimensions.", severity: 'orange' }
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# 4. Legibility & Ergonomics
|
|
398
|
+
unless is_genuinely_foreign || classes.include?('qss-legibility-override')
|
|
399
|
+
classes.each do |cls|
|
|
400
|
+
if cls.match?(/text-size\[(\d+)\]/)
|
|
401
|
+
size = cls.match(/text-size\[(\d+)\]/)[1].to_i
|
|
402
|
+
issues << { type: 'legibility', message: "Font size #{size} below floor (#{LEGIBILITY_FLOOR})", severity: 'orange' } if size < LEGIBILITY_FLOOR
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
status = issues.any? { |i| i[:severity] == 'red' } ? 'red' : (issues.any? { |i| i[:severity] == 'orange' } ? 'orange' : 'green')
|
|
409
|
+
{ status: status, issues: issues }
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|