pandorga 1.12.0 → 1.14.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,306 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Highlighter pills test gate.
5
+ # PLT-AC-46
6
+
7
+ require "pathname"
8
+
9
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
10
+
11
+ def fail!(message)
12
+ warn "FAIL test-highlighter-pills: #{message}"
13
+ exit 1
14
+ end
15
+
16
+ def parse_css_rules(css_text)
17
+ clean = css_text.gsub(/\/\*.*?\*\//m, "")
18
+ rules = []
19
+ i = 0
20
+ len = clean.length
21
+ selector_buf = +""
22
+ media_context = nil
23
+ while i < len
24
+ char = clean[i]
25
+ if char == "{"
26
+ sel = selector_buf.strip
27
+ if sel.start_with?("@")
28
+ media_context = sel
29
+ selector_buf.clear
30
+ i += 1
31
+ next
32
+ end
33
+ depth = 1
34
+ j = i + 1
35
+ while j < len && depth > 0
36
+ depth += 1 if clean[j] == "{"
37
+ depth -= 1 if clean[j] == "}"
38
+ j += 1
39
+ end
40
+ decl_text = clean[(i + 1)...(j - 1)]
41
+ i = j
42
+ decls = {}
43
+ decl_text.split(";").each do |part|
44
+ part = part.strip
45
+ next if part.empty?
46
+ prop, val = part.split(":", 2)
47
+ next unless prop && val
48
+ decls[prop.strip.downcase] = val.strip
49
+ end
50
+ sel.split(",").each do |s|
51
+ rules << { selector: s.strip, declarations: decls, raw_body: decl_text, media: media_context }
52
+ end
53
+ selector_buf.clear
54
+ next
55
+ elsif char == "}"
56
+ media_context = nil
57
+ selector_buf.clear
58
+ i += 1
59
+ next
60
+ else
61
+ selector_buf << char
62
+ i += 1
63
+ end
64
+ end
65
+ rules
66
+ end
67
+
68
+ # 1. Tokens (criterion 2)
69
+ base_css = ROOT.join("assets/css/base.css").read
70
+ fail!("missing base.css") if base_css.empty?
71
+
72
+ expected_light_tokens = {
73
+ "--mark-a" => "18%",
74
+ "--mark-b" => "14%",
75
+ "--mark-c" => "5%",
76
+ "--mark-strong-a" => "28%",
77
+ "--mark-strong-b" => "22%",
78
+ "--mark-strong-c" => "8%",
79
+ "--mark-facet-a" => "14%",
80
+ "--mark-facet-b" => "11%",
81
+ "--mark-facet-c" => "4%",
82
+ "--mark-hover-a" => "30%",
83
+ "--mark-hover-b" => "24%",
84
+ "--mark-hover-c" => "9%",
85
+ "--mark-quiet-a" => "12%",
86
+ "--mark-quiet-b" => "9%",
87
+ "--mark-quiet-c" => "3%"
88
+ }
89
+
90
+ expected_dark_tokens = {
91
+ "--mark-a" => "14%",
92
+ "--mark-b" => "10%",
93
+ "--mark-c" => "4%",
94
+ "--mark-strong-a" => "22%",
95
+ "--mark-strong-b" => "17%",
96
+ "--mark-strong-c" => "6%",
97
+ "--mark-facet-a" => "14%",
98
+ "--mark-facet-b" => "11%",
99
+ "--mark-facet-c" => "4%",
100
+ "--mark-hover-a" => "24%",
101
+ "--mark-hover-b" => "18%",
102
+ "--mark-hover-c" => "7%",
103
+ "--mark-quiet-a" => "10%",
104
+ "--mark-quiet-b" => "7%",
105
+ "--mark-quiet-c" => "3%"
106
+ }
107
+
108
+ expected_light_tokens.each do |k, v|
109
+ fail!("missing light token #{k}: #{v}") unless base_css.match?(/#{Regexp.escape(k)}\s*:\s*#{Regexp.escape(v)}/)
110
+ end
111
+
112
+ expected_dark_tokens.each do |k, v|
113
+ fail!("missing dark token #{k}: #{v}") unless base_css.match?(/\[data-theme=["']dark["']\].*?#{Regexp.escape(k)}\s*:\s*#{Regexp.escape(v)}/m)
114
+ end
115
+
116
+ # 2. Borders and rings (criterion 1)
117
+ files_to_check = {
118
+ "base.css" => ROOT.join("assets/css/base.css").read,
119
+ "chrome.css" => ROOT.join("assets/css/ledger/chrome.css").read,
120
+ "writing.css" => ROOT.join("assets/css/ledger/writing.css").read,
121
+ "sources.css" => ROOT.join("assets/css/ledger/sources.css").read,
122
+ "bibliography.css" => ROOT.join("assets/css/ledger/bibliography.css").read,
123
+ "cv.css" => ROOT.join("assets/css/ledger/cv.css").read,
124
+ "detail.css" => ROOT.join("assets/css/ledger/detail.css").read,
125
+ "home.css" => ROOT.join("assets/css/ledger/home.css").read
126
+ }
127
+
128
+ parsed_files = files_to_check.transform_values { |css| parse_css_rules(css) }
129
+
130
+ # Selector checks across files for families F1-F8 and M (excluding unchanged F4c)
131
+ modified_selectors = [
132
+ # F1
133
+ { file: "base.css", selector: ".ledger-chip" },
134
+ { file: "base.css", selector: "a.ledger-chip" },
135
+ # F3
136
+ { file: "base.css", selector: ".lang-pill" },
137
+ { file: "sources.css", selector: ".source-facets .source-facet" },
138
+ { file: "sources.css", selector: ".source-facets .ledger-chip" },
139
+ { file: "sources.css", selector: ".source-facets .lang-pill" },
140
+ { file: "bibliography.css", selector: ".bib-facet" },
141
+ { file: "bibliography.css", selector: ".bib-facets .lang-pill" },
142
+ # F4a
143
+ { file: "writing.css", selector: ".writing-rail .listing-filter-chip" },
144
+ # F4b
145
+ { file: "base.css", selector: ".listing-filter-chip" },
146
+ # F5
147
+ { file: "base.css", selector: ".ledger-cta--emphasis" },
148
+ { file: "base.css", selector: ".ledger-cta--emphasis-alt" },
149
+ # F7
150
+ { file: "detail.css", selector: ".detail-action" },
151
+ { file: "detail.css", selector: ".detail-type-size-trigger" },
152
+ { file: "base.css", selector: ".listing-filter-trigger" },
153
+ # F8
154
+ { file: "detail.css", selector: ".article-related-section-pill" },
155
+ # M
156
+ { file: "chrome.css", selector: ".site-rail-link.is-active" }
157
+ ]
158
+
159
+ modified_selectors.each do |item|
160
+ matching = parsed_files[item[:file]].select { |r| r[:selector] == item[:selector] }
161
+ fail!("selector #{item[:selector]} not found in #{item[:file]}") if matching.empty?
162
+
163
+ matching.each do |r|
164
+ decls = r[:declarations]
165
+ # Check borders: border should be 0, none, or border-color transparent
166
+ if decls.key?("border")
167
+ val = decls["border"]
168
+ unless val == "0" || val == "none" || val.start_with?("0") || val.include?("transparent")
169
+ fail!("unexpected non-zero border '#{val}' in #{item[:file]} for #{item[:selector]}")
170
+ end
171
+ end
172
+ if decls.key?("border-color")
173
+ val = decls["border-color"]
174
+ fail!("border-color not transparent in #{item[:file]} for #{item[:selector]}") unless val == "transparent"
175
+ end
176
+ # Check inset ring
177
+ if decls.key?("box-shadow")
178
+ val = decls["box-shadow"]
179
+ fail!("inset ring found in #{item[:file]} for #{item[:selector]}: #{val}") if val.include?("inset")
180
+ end
181
+ end
182
+ end
183
+
184
+ # 3. Filter states (criterion 3)
185
+ writing_rules = parsed_files["writing.css"]
186
+ writing_filter = writing_rules.find { |r| r[:selector] == ".writing-rail .listing-filter-chip" }
187
+ fail!("missing .writing-rail .listing-filter-chip in writing.css") unless writing_filter
188
+ fail!(".writing-rail .listing-filter-chip still has border-left") if writing_filter[:declarations].key?("border-left")
189
+ fail!(".writing-rail .listing-filter-chip needs align-self: flex-start") unless writing_filter[:declarations]["align-self"] == "flex-start"
190
+
191
+ # F4c in cv.css must be unchanged
192
+ cv_css = files_to_check["cv.css"]
193
+ fail!("F4c .cv-timeline-option must stay unchanged") unless cv_css.include?(".cv-timeline-option") && cv_css.include?("border-left: 2px solid transparent")
194
+
195
+ # Selected filters set color: var(--color-on-surface)
196
+ active_panel_filter = parsed_files["base.css"].find { |r| r[:selector] == ".listing-filter-chip.is-active" }
197
+ fail!("missing .listing-filter-chip.is-active in base.css") unless active_panel_filter
198
+ fail!(".listing-filter-chip.is-active must have color: var(--color-on-surface)") unless active_panel_filter[:declarations]["color"] == "var(--color-on-surface)"
199
+
200
+ active_rail_filter = parsed_files["writing.css"].find { |r| r[:selector] == ".writing-rail .listing-filter-chip.is-active" }
201
+ fail!("missing .writing-rail .listing-filter-chip.is-active in writing.css") unless active_rail_filter
202
+ fail!(".writing-rail .listing-filter-chip.is-active must have color: var(--color-on-surface)") unless active_rail_filter[:declarations]["color"] == "var(--color-on-surface)"
203
+
204
+ # 4. Solid mark (criterion 4 and §2.2)
205
+ home_f6 = parsed_files["home.css"].find { |r| r[:selector] == ".home-featured-foot .home-featured-cta" }
206
+ fail!("missing .home-featured-foot .home-featured-cta in home.css") unless home_f6
207
+ fail!("home F6 CTA must have color: var(--color-on-primary)") unless home_f6[:declarations]["color"] == "var(--color-on-primary)"
208
+ fail!("home F6 CTA must use solid mark recipe with calc(100% - 0.8rem)") unless home_f6[:declarations]["background"]&.include?("calc(100% - 0.8rem)")
209
+
210
+ home_f6_hover = parsed_files["home.css"].find { |r| r[:selector] == ".home-featured-foot .home-featured-cta:hover" }
211
+ fail!("missing .home-featured-foot .home-featured-cta:hover in home.css") unless home_f6_hover
212
+ fail!("home F6 CTA hover must be solid var(--color-primary)") unless home_f6_hover[:declarations]["background"] == "var(--color-primary)"
213
+
214
+ writing_f6 = parsed_files["writing.css"].find { |r| r[:selector] == ".writing-index .writing-lead-foot .writing-lead-cta" }
215
+ fail!("missing .writing-index .writing-lead-foot .writing-lead-cta in writing.css") unless writing_f6
216
+ fail!("writing F6 CTA must have color: var(--color-on-primary)") unless writing_f6[:declarations]["color"] == "var(--color-on-primary)"
217
+ fail!("writing F6 CTA must use solid mark recipe with calc(100% - 0.8rem)") unless writing_f6[:declarations]["background"]&.include?("calc(100% - 0.8rem)")
218
+
219
+ writing_f6_hover = parsed_files["writing.css"].find { |r| r[:selector] == ".writing-index .writing-lead-cta:hover" }
220
+ fail!("missing .writing-index .writing-lead-cta:hover in writing.css") unless writing_f6_hover
221
+ fail!("writing F6 CTA hover must be solid var(--color-primary)") unless writing_f6_hover[:declarations]["background"] == "var(--color-primary)"
222
+
223
+ # 5. Strong buttons & Action margins (criterion 4)
224
+ f5_emp = parsed_files["base.css"].find { |r| r[:selector] == ".ledger-cta--emphasis" }
225
+ fail!("missing .ledger-cta--emphasis") unless f5_emp
226
+ fail!(".ledger-cta--emphasis must set margin: 1px") unless f5_emp[:declarations]["margin"] == "1px"
227
+
228
+ f5_alt = parsed_files["base.css"].find { |r| r[:selector] == ".ledger-cta--emphasis-alt" }
229
+ fail!("missing .ledger-cta--emphasis-alt") unless f5_alt
230
+ fail!(".ledger-cta--emphasis-alt must set margin: 1px") unless f5_alt[:declarations]["margin"] == "1px"
231
+
232
+ f5_hover = parsed_files["base.css"].find { |r| r[:selector] == ".ledger-cta--emphasis:hover" }
233
+ fail!("missing .ledger-cta--emphasis:hover") unless f5_hover
234
+ fail!(".ledger-cta--emphasis:hover must keep background: var(--color-primary)") unless f5_hover[:declarations]["background"] == "var(--color-primary)"
235
+
236
+ f5_alt_hover = parsed_files["base.css"].find { |r| r[:selector] == ".ledger-cta--emphasis-alt:hover" }
237
+ fail!("missing .ledger-cta--emphasis-alt:hover") unless f5_alt_hover
238
+ fail!(".ledger-cta--emphasis-alt:hover must keep background: var(--color-secondary)") unless f5_alt_hover[:declarations]["background"] == "var(--color-secondary)"
239
+
240
+ detail_action = parsed_files["detail.css"].find { |r| r[:selector] == ".detail-action" }
241
+ fail!("missing .detail-action in detail.css") unless detail_action
242
+ fail!(".detail-action must set margin: 1px") unless detail_action[:declarations]["margin"] == "1px"
243
+
244
+ detail_type_size = parsed_files["detail.css"].find { |r| r[:selector] == ".detail-type-size-trigger" }
245
+ fail!("missing .detail-type-size-trigger in detail.css") unless detail_type_size
246
+ fail!(".detail-type-size-trigger must set margin: 1px") unless detail_type_size[:declarations]["margin"] == "1px"
247
+
248
+ filter_trigger = parsed_files["base.css"].find { |r| r[:selector] == ".listing-filter-trigger" }
249
+ fail!("missing .listing-filter-trigger in base.css") unless filter_trigger
250
+ fail!(".listing-filter-trigger must set margin: 1px") unless filter_trigger[:declarations]["margin"] == "1px"
251
+
252
+ # 6. Variants (criterion 8 and §2.3)
253
+ base_rules = parsed_files["base.css"]
254
+ var_a = base_rules.find { |r| r[:selector].include?(":nth-child(3n+1)") }
255
+ var_b = base_rules.find { |r| r[:selector].include?(":nth-child(3n+2)") }
256
+ var_c = base_rules.find { |r| r[:selector].include?(":nth-child(3n)") && !r[:selector].include?("3n+") }
257
+
258
+ fail!("missing nth-child(3n+1) variant rule") unless var_a
259
+ fail!("missing nth-child(3n+2) variant rule") unless var_b
260
+ fail!("missing nth-child(3n) variant rule") unless var_c
261
+
262
+ [var_a, var_b, var_c].each do |rule|
263
+ rule[:declarations].each_key do |prop|
264
+ fail!("variant rule #{rule[:selector]} modifies non-mk property #{prop}") unless prop.start_with?("--mk-")
265
+ end
266
+ end
267
+
268
+ fail!("variant A angle must be 104deg") unless var_a[:declarations]["--mk-angle"] == "104deg"
269
+ fail!("variant A s0 must be 0.9%") unless var_a[:declarations]["--mk-s0"] == "0.9%"
270
+ fail!("variant A s1 must be 2.6%") unless var_a[:declarations]["--mk-s1"] == "2.6%"
271
+ fail!("variant A mid must be 58%") unless var_a[:declarations]["--mk-mid"] == "58%"
272
+ fail!("variant A end must be 98%") unless var_a[:declarations]["--mk-end"] == "98%"
273
+ fail!("variant A h must be 100%") unless var_a[:declarations]["--mk-h"] == "100%"
274
+
275
+ fail!("variant B angle must be 97deg") unless var_b[:declarations]["--mk-angle"] == "97deg"
276
+ fail!("variant B s0 must be 1.8%") unless var_b[:declarations]["--mk-s0"] == "1.8%"
277
+ fail!("variant B s1 must be 4%") unless var_b[:declarations]["--mk-s1"] == "4%"
278
+ fail!("variant B mid must be 66%") unless var_b[:declarations]["--mk-mid"] == "66%"
279
+ fail!("variant B end must be 96%") unless var_b[:declarations]["--mk-end"] == "96%"
280
+ fail!("variant B h must be 88%") unless var_b[:declarations]["--mk-h"] == "88%"
281
+ fail!("variant B y must be 85%") unless var_b[:declarations]["--mk-y"] == "85%"
282
+
283
+ fail!("variant C angle must be 111deg") unless var_c[:declarations]["--mk-angle"] == "111deg"
284
+ fail!("variant C s0 must be 0.4%") unless var_c[:declarations]["--mk-s0"] == "0.4%"
285
+ fail!("variant C s1 must be 1.8%") unless var_c[:declarations]["--mk-s1"] == "1.8%"
286
+ fail!("variant C mid must be 48%") unless var_c[:declarations]["--mk-mid"] == "48%"
287
+ fail!("variant C end must be 92%") unless var_c[:declarations]["--mk-end"] == "92%"
288
+ fail!("variant C h must be 90%") unless var_c[:declarations]["--mk-h"] == "90%"
289
+ fail!("variant C y must be 20%") unless var_c[:declarations]["--mk-y"] == "20%"
290
+
291
+ # 7. Focus (criterion 5 and §3.1)
292
+ focus_rule = base_rules.find do |r|
293
+ r[:selector].include?(":focus-visible") &&
294
+ r[:declarations]["outline"]&.include?("2px solid") &&
295
+ r[:declarations]["outline-offset"] == "2px"
296
+ end
297
+ fail!("missing 2px outline :focus-visible rule covering interactive pills") unless focus_rule
298
+
299
+ # 8. Menu rule reads tokens (criterion 1)
300
+ rail_active = parsed_files["chrome.css"].find { |r| r[:selector] == ".site-rail-link.is-active" }
301
+ fail!("missing .site-rail-link.is-active in chrome.css") unless rail_active
302
+ fail!(".site-rail-link.is-active must read var(--mark-a)") unless rail_active[:declarations]["background"]&.include?("var(--mark-a)")
303
+ fail!(".site-rail-link.is-active must read var(--mark-b)") unless rail_active[:declarations]["background"]&.include?("var(--mark-b)")
304
+ fail!(".site-rail-link.is-active must read var(--mark-c)") unless rail_active[:declarations]["background"]&.include?("var(--mark-c)")
305
+
306
+ puts "PASS test-highlighter-pills"
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // Refs: PLT-AC-44
3
+ // Executes the real renderPosterMedia sliced out of
4
+ // _includes/content-runtime/43-blocks.html rather than a copy of it.
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
11
+ const utilsSource = fs.readFileSync(
12
+ path.join(root, '_includes/content-runtime/10-utils.html'),
13
+ 'utf8'
14
+ );
15
+ const blocksSource = fs.readFileSync(
16
+ path.join(root, '_includes/content-runtime/43-blocks.html'),
17
+ 'utf8'
18
+ );
19
+
20
+ const escStart = utilsSource.indexOf('function escapeHtml');
21
+ const escEnd = utilsSource.indexOf('function shellOrigin');
22
+ if (escStart < 0 || escEnd <= escStart) {
23
+ console.error('FAIL test-poster-srcset: escapeHtml/escapeAttr missing from 10-utils.html');
24
+ process.exit(1);
25
+ }
26
+ const escSlice = utilsSource.slice(escStart, escEnd);
27
+
28
+ const posterStart = blocksSource.indexOf('function renderPosterMedia');
29
+ const posterEnd = blocksSource.indexOf('function twoDigits');
30
+ if (posterStart < 0 || posterEnd <= posterStart) {
31
+ console.error('FAIL test-poster-srcset: renderPosterMedia missing from 43-blocks.html');
32
+ process.exit(1);
33
+ }
34
+ const posterSlice = blocksSource.slice(posterStart, posterEnd);
35
+
36
+ const renderPosterMedia = new Function(
37
+ `${escSlice}\n${posterSlice}\nreturn renderPosterMedia;`
38
+ )();
39
+
40
+ let failed = 0;
41
+
42
+ function check(condition, message) {
43
+ if (!condition) {
44
+ console.error(`FAIL test-poster-srcset: ${message}`);
45
+ failed += 1;
46
+ }
47
+ }
48
+
49
+ function same(got, expected, message) {
50
+ check(got === expected, `${message}: got ${JSON.stringify(got)}, want ${JSON.stringify(expected)}`);
51
+ }
52
+
53
+ // 1. Without srcset/sizes: byte-identical to today's output
54
+ const expectedPlain = [
55
+ '<div class="ledger-poster">',
56
+ '<img src="/media/images/covers/wide.jpg" alt="Wide Project" loading="lazy" decoding="async"',
57
+ ' onerror="var p=this.closest(\'.ledger-poster\'); if (p) p.remove();">',
58
+ '</div>'
59
+ ].join('');
60
+
61
+ same(
62
+ renderPosterMedia('/media/images/covers/wide.jpg', 'Wide Project'),
63
+ expectedPlain,
64
+ 'plain image without options'
65
+ );
66
+ same(
67
+ renderPosterMedia('/media/images/covers/wide.jpg', 'Wide Project', {}),
68
+ expectedPlain,
69
+ 'plain image with empty options'
70
+ );
71
+ same(
72
+ renderPosterMedia('/media/images/covers/wide.jpg', 'Wide Project', {
73
+ srcset: ' ',
74
+ sizes: '(min-width: 900px) 35vw, 100vw'
75
+ }),
76
+ expectedPlain,
77
+ 'empty srcset omits both srcset and sizes'
78
+ );
79
+
80
+ // 2. With srcset + sizes: img has src = original, srcset and sizes attributes (escaped)
81
+ const srcsetVal = '/media/images/covers/variants/wide-480.jpg 480w, /media/images/covers/variants/wide-960.jpg 960w';
82
+ const sizesVal = '(min-width: 900px) 35vw, 100vw';
83
+ const withSrcset = renderPosterMedia('/media/images/covers/wide.jpg', 'Wide "Quote"', {
84
+ srcset: srcsetVal,
85
+ sizes: sizesVal
86
+ });
87
+
88
+ const expectedWithSrcset = [
89
+ '<div class="ledger-poster">',
90
+ '<img src="/media/images/covers/wide.jpg"',
91
+ ' srcset="/media/images/covers/variants/wide-480.jpg 480w, /media/images/covers/variants/wide-960.jpg 960w"',
92
+ ' sizes="(min-width: 900px) 35vw, 100vw"',
93
+ ' alt="Wide &quot;Quote&quot;" loading="lazy" decoding="async"',
94
+ ' onerror="var p=this.closest(\'.ledger-poster\'); if (p) p.remove();">',
95
+ '</div>'
96
+ ].join('');
97
+
98
+ same(withSrcset, expectedWithSrcset, 'image with srcset and sizes');
99
+
100
+ // Escaping inside srcset and sizes
101
+ const escaped = renderPosterMedia('/media/a.jpg', 'Alt', {
102
+ srcset: '/media/a.jpg?x=1&y="2" 480w',
103
+ sizes: '(max-width: 600px) 100vw, "50vw"'
104
+ });
105
+ check(
106
+ escaped.includes('srcset="/media/a.jpg?x=1&amp;y=&quot;2&quot; 480w"'),
107
+ 'srcset attribute is escaped'
108
+ );
109
+ check(
110
+ escaped.includes('sizes="(max-width: 600px) 100vw, &quot;50vw&quot;"'),
111
+ 'sizes attribute is escaped'
112
+ );
113
+
114
+ // 3. Video URLs ignore srcset and sizes
115
+ const expectedVideo = [
116
+ '<div class="ledger-poster">',
117
+ '<video src="/media/clips/demo.mp4" controls preload="none" ',
118
+ 'playsinline aria-label="Demo"></video>',
119
+ '</div>'
120
+ ].join('');
121
+
122
+ same(
123
+ renderPosterMedia('/media/clips/demo.mp4', 'Demo', {
124
+ srcset: srcsetVal,
125
+ sizes: sizesVal
126
+ }),
127
+ expectedVideo,
128
+ 'video URL ignores srcset and sizes'
129
+ );
130
+
131
+ // 4. Inspect 43-blocks.html call sites for renderSystemCard and renderProjectSpecimen
132
+ const sysCardStart = blocksSource.indexOf('function renderSystemCard');
133
+ const sysCardEnd = blocksSource.indexOf('function renderProjectLinks');
134
+ const specStart = blocksSource.indexOf('function renderProjectSpecimen');
135
+ check(sysCardStart > 0 && sysCardEnd > sysCardStart, 'renderSystemCard found in 43-blocks.html');
136
+ check(specStart > 0, 'renderProjectSpecimen found in 43-blocks.html');
137
+
138
+ const sysCardBody = blocksSource.slice(sysCardStart, sysCardEnd);
139
+ const specBody = blocksSource.slice(specStart);
140
+
141
+ check(
142
+ sysCardBody.includes('renderPosterMedia') &&
143
+ sysCardBody.includes('fm.thumbnail_srcset') &&
144
+ sysCardBody.includes('sizes'),
145
+ 'renderSystemCard passes fm.thumbnail_srcset and sizes to renderPosterMedia'
146
+ );
147
+
148
+ check(
149
+ specBody.includes('renderPosterMedia') &&
150
+ specBody.includes('fm.thumbnail_srcset') &&
151
+ specBody.includes('sizes'),
152
+ 'renderProjectSpecimen passes fm.thumbnail_srcset and sizes to renderPosterMedia'
153
+ );
154
+
155
+ if (failed) {
156
+ console.error(`test-poster-srcset: ${failed} failure(s)`);
157
+ process.exit(1);
158
+ }
159
+ console.log('PASS test-poster-srcset');
@@ -70,4 +70,42 @@ fail!("restoreListingDocument is not on the runtime") unless BOOT.include?("rest
70
70
  fail!("#{layout} listing does not restore the document") unless source.include?("restoreListingDocument()")
71
71
  end
72
72
 
73
+ # PLT-AC-46: Menu mark follows section accent on desktop rail, mobile menu unchanged
74
+ rail_link_markup = SHELL[/<nav class="site-rail".*?<\/nav>/m]
75
+ fail!("site-rail nav not found") unless rail_link_markup
76
+ fail!("active site-rail-link must get page-accent--{{ _p.accent }}") unless rail_link_markup.include?("page-accent--{{ _p.accent }}")
77
+ fail!("active site-rail-link accent must be guarded by != 'blue'") unless rail_link_markup.include?("_p.accent != 'blue'") || rail_link_markup.include?('_p.accent != "blue"')
78
+
79
+ mobile_menu = SHELL[/<div id="mobile-menu".*?<\/div>\s*<\/div>/m]
80
+ fail!("mobile menu not found") unless mobile_menu
81
+ fail!("mobile menu must not contain page-accent (PLT-AC-46)") if mobile_menu.include?("page-accent")
82
+ fail!("mobile menu must not contain gradient/mark (PLT-AC-46)") if mobile_menu.include?("linear-gradient") || mobile_menu.include?("ledger-mark")
83
+
84
+ EXPECTED_MOBILE_MENU = <<-'HTML'.strip
85
+ <div id="mobile-menu"
86
+ class="hidden absolute right-0 top-full mt-1 min-w-[14rem] py-2 bg-surface border border-gentle-subtle shadow-sm z-50"
87
+ role="menu" aria-label="Explore">
88
+ <div class="flex flex-col px-3 gap-0">
89
+ {% for _group in site.data.nav %}
90
+ {% unless forloop.first %}<div class="h-px bg-outline-variant/30 my-1.5" role="separator"></div>{% endunless %}
91
+ {% for _item in _group.pages %}
92
+ {% assign _key = _item.key %}
93
+ {% assign _p = _all_pages | where: "key", _key | first %}
94
+ {% comment %}The inactive state carries no weight class: Cabin's axis starts
95
+ at 400 and the font loader requests nothing below it, so the `font-light` this
96
+ used to set asked for a 300 that resolves straight back to 400 and changed
97
+ nothing on the page. The variant ink is what marks the state.{% endcomment %}
98
+ <a href="{{ _p.url }}" role="menuitem"
99
+ class="flex items-center gap-2.5 font-body {% if nav_active == _key %}text-primary font-semibold{% else %}text-on-surface-variant hover:text-primary{% endif %} transition-colors duration-300 py-2 px-1">
100
+ {% if _item.icon %}<span class="material-symbols-outlined text-lg shrink-0 flex items-center justify-center" aria-hidden="true">{{ _item.icon }}</span>{% endif %}
101
+ <span class="leading-none">{{ _p.title }}</span>
102
+ </a>
103
+ {% endfor %}
104
+ {% endfor %}
105
+ </div>
106
+ </div>
107
+ HTML
108
+
109
+ fail!("mobile menu is not byte-identical to pre-change snapshot (PLT-AC-46)") unless mobile_menu.strip == EXPECTED_MOBILE_MENU
110
+
73
111
  puts "PASS test-shell-a11y"
@@ -60,4 +60,95 @@ pairs.each do |name, (ink, panel)|
60
60
  reports << "#{name} #{ink} on #{panel} = #{pair_ratio.round(2)}:1"
61
61
  end
62
62
 
63
+ # PLT-AC-46: Highlighter pills contrast assertions.
64
+ # For each accent ink and each family's text color and strength, composite the mark head
65
+ # over background, surface, and surface_card, then assert >= 4.5:1 WCAG contrast.
66
+ def mix(color_a, color_b, percentage)
67
+ rgb_a = color_a.delete("#").scan(/../).map { |p| p.to_i(16) }
68
+ rgb_b = color_b.delete("#").scan(/../).map { |p| p.to_i(16) }
69
+ res = (0..2).map { |i| ((rgb_a[i] * percentage + rgb_b[i] * (100.0 - percentage)) / 100.0).round }
70
+ "#%02x%02x%02x" % res
71
+ end
72
+
73
+ themes = [
74
+ { name: "light", theme: light, menu_strength: 18, strong_strength: 28, facet_strength: 14, quiet_strength: 12 },
75
+ { name: "dark", theme: dark, menu_strength: 14, strong_strength: 22, facet_strength: 14, quiet_strength: 10 }
76
+ ]
77
+
78
+ themes.each do |entry|
79
+ mode = entry[:name]
80
+ theme_data = entry[:theme]
81
+ colors = theme_data["colors"] || {}
82
+ neutrals = theme_data["neutrals"] || {}
83
+ accents = [colors["primary"], colors["secondary"], colors["tertiary"]].compact
84
+ surfaces = [neutrals["background"], neutrals["surface"], neutrals["surface_card"]].compact.uniq
85
+
86
+ on_surface = neutrals["on_surface"].to_s
87
+ on_variant = neutrals["on_surface_variant"].to_s
88
+ on_primary = (mode == "light") ? "#ffffff" : theme_data.dig("static", "on_primary") || "#0f1a22"
89
+
90
+ accents.each do |accent|
91
+ surfaces.each do |bg|
92
+ # F1 / F2: Tags and featured badge
93
+ f1_text = (mode == "light") ? mix(accent, on_surface, 82) : mix(accent, on_surface, 78)
94
+ f1_head = mix(accent, bg, entry[:menu_strength])
95
+ c = contrast(f1_text, f1_head)
96
+ fail!("PLT-AC-46 F1 #{mode} #{accent} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
97
+ c_base = contrast(f1_text, bg)
98
+ fail!("PLT-AC-46 F1 #{mode} #{accent} on bare #{bg}: #{c_base.round(2)}:1 < 4.5:1") if c_base < 4.5
99
+
100
+ # F4: Selected filter
101
+ f4_head = mix(accent, bg, entry[:menu_strength])
102
+ c = contrast(on_surface, f4_head)
103
+ fail!("PLT-AC-46 F4 selected #{mode} #{accent} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
104
+
105
+ # F5: Emphasis button
106
+ f5_text = (mode == "light") ? mix(accent, on_surface, 82) : accent
107
+ f5_head = mix(accent, bg, entry[:strong_strength])
108
+ c = contrast(f5_text, f5_head)
109
+ fail!("PLT-AC-46 F5 #{mode} #{accent} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
110
+
111
+ # Menu M: Active link
112
+ m_head = mix(accent, bg, entry[:menu_strength])
113
+ c = contrast(on_surface, m_head)
114
+ fail!("PLT-AC-46 Menu active #{mode} #{accent} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
115
+ end
116
+
117
+ # F6: Solid mark (inverted on-primary text over solid accent at glyph)
118
+ c_f6 = contrast(on_primary, accent)
119
+ fail!("PLT-AC-46 F6 #{mode} #{on_primary} on #{accent}: #{c_f6.round(2)}:1 < 4.5:1") if c_f6 < 4.5
120
+ end
121
+
122
+ # F3: Facets
123
+ facet_inks = accents + [on_variant]
124
+ facet_inks.each do |ink|
125
+ surfaces.each do |bg|
126
+ f3_text = (mode == "light") ? mix(ink, on_surface, 82) : ink
127
+ f3_head = mix(ink, bg, entry[:facet_strength])
128
+ c = contrast(f3_text, f3_head)
129
+ fail!("PLT-AC-46 F3 #{mode} #{ink} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
130
+ end
131
+ end
132
+
133
+ # F4: Unselected filter
134
+ surfaces.each do |bg|
135
+ c = contrast(on_variant, bg)
136
+ fail!("PLT-AC-46 F4 unselected #{mode} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
137
+ end
138
+
139
+ # F7: Action buttons
140
+ surfaces.each do |bg|
141
+ f7_head = mix(on_variant, bg, entry[:quiet_strength])
142
+ c = contrast(on_surface, f7_head)
143
+ fail!("PLT-AC-46 F7 #{mode} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
144
+ end
145
+
146
+ # F8: Related "Cites / Cited by" labels
147
+ surfaces.each do |bg|
148
+ f8_head = mix(on_variant, bg, entry[:quiet_strength])
149
+ c = contrast(on_variant, f8_head)
150
+ fail!("PLT-AC-46 F8 #{mode} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
151
+ end
152
+ end
153
+
63
154
  puts "PASS test-theme-contrast (#{foreground} on #{background} = #{ratio.round(2)}:1; #{reports.join('; ')})"