pandorga 1.13.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.
@@ -171,26 +171,34 @@
171
171
  display: contents;
172
172
  }
173
173
 
174
- /* One geometry for all four. The border is currentColor rather than a fixed
175
- token, so each chip outlines itself in whatever ink it is already using and
176
- the row stays a family across both palettes. The shared category chip is a
177
- filled chip everywhere else on the site; here it outlines like its three
178
- neighbors, and its hover still fills — which is how a reader can tell that
179
- this one, alone, is a link. */
174
+ /* One geometry for all four. Facets lose their border and get a facet-strength
175
+ mark in their own ink. The tag chip is the only link and deepens its mark
176
+ on hover. */
180
177
  .source-facets .source-facet,
181
178
  .source-facets .ledger-chip,
182
179
  .source-facets .lang-pill {
183
180
  display: inline-flex;
184
181
  align-items: center;
185
182
  height: var(--source-facet-height);
186
- padding: 0 var(--space-sm);
187
- border: 1.5px solid color-mix(in srgb, currentColor 40%, var(--border-ink));
183
+ padding: 0 calc(var(--space-sm) + 1.5px);
184
+ border: 0;
188
185
  border-radius: var(--radius-card);
189
- background: transparent;
190
186
  white-space: nowrap;
191
187
  font-size: var(--type-label);
192
188
  }
193
189
 
190
+ .source-facets .ledger-chip {
191
+ --mark-1: var(--mark-facet-a);
192
+ --mark-2: var(--mark-facet-b);
193
+ --mark-3: var(--mark-facet-c);
194
+ }
195
+
196
+ .source-facets a.ledger-chip:hover {
197
+ --mark-1: var(--mark-hover-a);
198
+ --mark-2: var(--mark-hover-b);
199
+ --mark-3: var(--mark-hover-c);
200
+ }
201
+
194
202
  /* The two chips this page owns. The shared pill and chip bring their own
195
203
  type; these match it rather than inventing a second label voice. */
196
204
  .source-facet {
@@ -199,12 +207,19 @@
199
207
  font-weight: var(--weight-label);
200
208
  letter-spacing: 0.03em;
201
209
  line-height: 1;
202
- color: var(--color-on-surface-variant);
210
+ --facet-ink: var(--color-on-surface-variant);
211
+ color: color-mix(in srgb, var(--facet-ink) var(--facet-ink-mix), var(--color-on-surface));
212
+ --mark-ink: currentColor;
213
+ --mark-1: var(--mark-facet-a);
214
+ --mark-2: var(--mark-facet-b);
215
+ --mark-3: var(--mark-facet-c);
203
216
  }
204
217
 
205
- /* flag-icons sizes from the em, so the flag's own font-size is its height. */
218
+ /* flag-icons sizes from the em, so the flag's own font-size is its height.
219
+ The country chip gets no mark because the flag is its own shape. */
206
220
  .source-facet--country {
207
221
  padding-inline: 0.35rem;
222
+ background: none;
208
223
  }
209
224
 
210
225
  .source-facet--country .fi {
@@ -323,5 +338,5 @@ html[data-theme="light"] .source-card {
323
338
  /* The medium chip and the card edge read the same custom property, so they
324
339
  cannot drift apart: one colour, stated once, shown twice. */
325
340
  .source-facet--medium {
326
- color: var(--source-accent);
341
+ --facet-ink: var(--source-accent);
327
342
  }
@@ -207,16 +207,20 @@
207
207
  border-top: 1px solid var(--border-gentle-divider);
208
208
  }
209
209
 
210
+ /* Solid mark: opaque under the label, fades only in the trailing padding;
211
+ hover fills solid. */
210
212
  .writing-index .writing-lead-foot .writing-lead-cta {
211
213
  margin-left: auto;
212
214
  padding: 0.35rem var(--space-md);
213
- background: var(--color-primary);
215
+ background: linear-gradient(104deg, transparent 0.9%, var(--color-primary) 2.4%, var(--color-primary) calc(100% - 0.8rem), color-mix(in srgb, var(--color-primary) 40%, transparent) 100%);
216
+ -webkit-box-decoration-break: clone;
217
+ box-decoration-break: clone;
214
218
  color: var(--color-on-primary);
215
219
  font-weight: var(--weight-label);
216
220
  }
217
221
 
218
222
  .writing-index .writing-lead-cta:hover {
219
- background: color-mix(in srgb, var(--color-primary) 85%, var(--color-on-surface));
223
+ background: var(--color-primary);
220
224
  color: var(--color-on-primary);
221
225
  text-decoration: none;
222
226
  }
@@ -613,11 +617,10 @@ html[data-theme="light"] .writing-index .writing-lead-media .ledger-poster {
613
617
  }
614
618
 
615
619
  .writing-rail .listing-filter-chip {
620
+ align-self: flex-start;
616
621
  justify-content: flex-start;
617
- padding: 0.4rem 0.5rem;
618
- background: none;
622
+ padding: 0.4rem 0.5rem 0.4rem calc(0.5rem + 2px);
619
623
  border: 0;
620
- border-left: 2px solid transparent;
621
624
  color: var(--color-on-surface-variant);
622
625
  font-family: var(--font-label);
623
626
  font-size: var(--type-label-lg);
@@ -628,20 +631,12 @@ html[data-theme="light"] .writing-index .writing-lead-media .ledger-poster {
628
631
  }
629
632
 
630
633
  .writing-rail .listing-filter-chip:hover {
631
- background: var(--color-surface-high);
632
- border-left-color: var(--color-outline-variant);
633
634
  color: var(--color-on-surface);
634
635
  }
635
636
 
636
- /* The active state is carried by the page accent, the tinted fill and the rule
637
- on the left edge. Cabin (label role) can take real weight; colour still does
638
- the selected cue so the rail stays quiet. `--color-primary` follows
639
- `.page-accent--*` (green on Articles, ochre on Rascunhos), so the same rail
640
- markup does not stay locked to secondary. */
637
+ /* Active filter chip uses the shared highlighter mark without a left border rule or tinted fill. */
641
638
  .writing-rail .listing-filter-chip.is-active {
642
- background: color-mix(in srgb, var(--color-primary) 8%, transparent);
643
- border-left-color: var(--color-primary);
644
- color: var(--color-primary);
639
+ color: var(--color-on-surface);
645
640
  }
646
641
 
647
642
  /* ── Licence ──
@@ -24,7 +24,7 @@ module Pandorga
24
24
 
25
25
  gem "pandorga", "~> 1.3"
26
26
  # Local checkout: gem "pandorga", path: "../pandorga"
27
- # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.13.0"
27
+ # GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.14.0"
28
28
  gem "csv"
29
29
  gem "base64"
30
30
  RUBY
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.13.0"
4
+ VERSION = "1.14.0"
5
5
  end
@@ -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"
@@ -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('; ')})"