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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/_includes/content-runtime/43-blocks.html +26 -4
- data/_includes/content-runtime/60-fragments.html +1 -0
- data/_includes/page/timeline-section.html +228 -29
- data/_layouts/projects.html +7 -1
- data/_layouts/shell.html +1 -1
- data/assets/css/base.css +174 -116
- data/assets/css/ledger/bibliography.css +15 -10
- data/assets/css/ledger/chrome.css +3 -12
- data/assets/css/ledger/cv.css +21 -0
- data/assets/css/ledger/detail.css +29 -15
- data/assets/css/ledger/home.css +6 -2
- data/assets/css/ledger/sources.css +27 -12
- data/assets/css/ledger/writing.css +10 -15
- data/lib/pandorga/cli.rb +4 -1
- data/lib/pandorga/commands/new.rb +1 -1
- data/lib/pandorga/commands/thumbnails.rb +19 -0
- data/lib/pandorga/thumbnail_variants.rb +267 -0
- data/lib/pandorga/version.rb +1 -1
- data/lib/pandorga.rb +1 -0
- data/scripts/content/export-content-json.rb +22 -1
- data/scripts/test/test-highlighter-pills.rb +306 -0
- data/scripts/test/test-poster-srcset.mjs +159 -0
- data/scripts/test/test-shell-a11y.rb +38 -0
- data/scripts/test/test-theme-contrast.rb +91 -0
- data/scripts/test/test-thumbnail-variants.rb +380 -0
- data/scripts/test/test-timeline-pan.mjs +147 -0
- data/scripts/test/test-timeline-pan.rb +116 -0
- data/scripts/validate.sh +13 -0
- metadata +8 -1
|
@@ -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 "Quote"" 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&y="2" 480w"'),
|
|
107
|
+
'srcset attribute is escaped'
|
|
108
|
+
);
|
|
109
|
+
check(
|
|
110
|
+
escaped.includes('sizes="(max-width: 600px) 100vw, "50vw""'),
|
|
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('; ')})"
|