pandorga 1.13.0 → 1.14.1
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/page/timeline-section.html +228 -29
- data/_layouts/shell.html +1 -1
- data/assets/css/base.css +177 -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/commands/new.rb +1 -1
- data/lib/pandorga/version.rb +1 -1
- data/scripts/test/test-highlighter-pills.rb +316 -0
- data/scripts/test/test-shell-a11y.rb +38 -0
- data/scripts/test/test-theme-contrast.rb +91 -0
- data/scripts/test/test-timeline-pan.mjs +147 -0
- data/scripts/test/test-timeline-pan.rb +116 -0
- data/scripts/validate.sh +7 -0
- metadata +4 -1
|
@@ -171,26 +171,34 @@
|
|
|
171
171
|
display: contents;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
/* One geometry for all four.
|
|
175
|
-
|
|
176
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/*
|
|
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
|
-
|
|
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.
|
|
27
|
+
# GitHub tag: gem "pandorga", github: "alexandrelheinen/pandorga", tag: "v1.14.1"
|
|
28
28
|
gem "csv"
|
|
29
29
|
gem "base64"
|
|
30
30
|
RUBY
|
data/lib/pandorga/version.rb
CHANGED
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
"--action-ink-mix" => "74%"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expected_dark_tokens = {
|
|
92
|
+
"--mark-a" => "14%",
|
|
93
|
+
"--mark-b" => "10%",
|
|
94
|
+
"--mark-c" => "4%",
|
|
95
|
+
"--mark-strong-a" => "22%",
|
|
96
|
+
"--mark-strong-b" => "17%",
|
|
97
|
+
"--mark-strong-c" => "6%",
|
|
98
|
+
"--mark-facet-a" => "14%",
|
|
99
|
+
"--mark-facet-b" => "11%",
|
|
100
|
+
"--mark-facet-c" => "4%",
|
|
101
|
+
"--mark-hover-a" => "24%",
|
|
102
|
+
"--mark-hover-b" => "18%",
|
|
103
|
+
"--mark-hover-c" => "7%",
|
|
104
|
+
"--mark-quiet-a" => "10%",
|
|
105
|
+
"--mark-quiet-b" => "7%",
|
|
106
|
+
"--mark-quiet-c" => "3%",
|
|
107
|
+
"--action-ink-mix" => "85%"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
expected_light_tokens.each do |k, v|
|
|
111
|
+
fail!("missing light token #{k}: #{v}") unless base_css.match?(/#{Regexp.escape(k)}\s*:\s*#{Regexp.escape(v)}/)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
expected_dark_tokens.each do |k, v|
|
|
115
|
+
fail!("missing dark token #{k}: #{v}") unless base_css.match?(/\[data-theme=["']dark["']\].*?#{Regexp.escape(k)}\s*:\s*#{Regexp.escape(v)}/m)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 2. Borders and rings (criterion 1)
|
|
119
|
+
files_to_check = {
|
|
120
|
+
"base.css" => ROOT.join("assets/css/base.css").read,
|
|
121
|
+
"chrome.css" => ROOT.join("assets/css/ledger/chrome.css").read,
|
|
122
|
+
"writing.css" => ROOT.join("assets/css/ledger/writing.css").read,
|
|
123
|
+
"sources.css" => ROOT.join("assets/css/ledger/sources.css").read,
|
|
124
|
+
"bibliography.css" => ROOT.join("assets/css/ledger/bibliography.css").read,
|
|
125
|
+
"cv.css" => ROOT.join("assets/css/ledger/cv.css").read,
|
|
126
|
+
"detail.css" => ROOT.join("assets/css/ledger/detail.css").read,
|
|
127
|
+
"home.css" => ROOT.join("assets/css/ledger/home.css").read
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
parsed_files = files_to_check.transform_values { |css| parse_css_rules(css) }
|
|
131
|
+
|
|
132
|
+
# Selector checks across files for families F1-F8 and M (excluding unchanged F4c)
|
|
133
|
+
modified_selectors = [
|
|
134
|
+
# F1
|
|
135
|
+
{ file: "base.css", selector: ".ledger-chip" },
|
|
136
|
+
{ file: "base.css", selector: "a.ledger-chip" },
|
|
137
|
+
# F3
|
|
138
|
+
{ file: "base.css", selector: ".lang-pill" },
|
|
139
|
+
{ file: "sources.css", selector: ".source-facets .source-facet" },
|
|
140
|
+
{ file: "sources.css", selector: ".source-facets .ledger-chip" },
|
|
141
|
+
{ file: "sources.css", selector: ".source-facets .lang-pill" },
|
|
142
|
+
{ file: "bibliography.css", selector: ".bib-facet" },
|
|
143
|
+
{ file: "bibliography.css", selector: ".bib-facets .lang-pill" },
|
|
144
|
+
# F4a
|
|
145
|
+
{ file: "writing.css", selector: ".writing-rail .listing-filter-chip" },
|
|
146
|
+
# F4b
|
|
147
|
+
{ file: "base.css", selector: ".listing-filter-chip" },
|
|
148
|
+
# F5
|
|
149
|
+
{ file: "base.css", selector: ".ledger-cta--emphasis" },
|
|
150
|
+
{ file: "base.css", selector: ".ledger-cta--emphasis-alt" },
|
|
151
|
+
# F7
|
|
152
|
+
{ file: "detail.css", selector: ".detail-action" },
|
|
153
|
+
{ file: "detail.css", selector: ".detail-type-size-trigger" },
|
|
154
|
+
{ file: "base.css", selector: ".listing-filter-trigger" },
|
|
155
|
+
# F8
|
|
156
|
+
{ file: "detail.css", selector: ".article-related-section-pill" },
|
|
157
|
+
# M
|
|
158
|
+
{ file: "chrome.css", selector: ".site-rail-link.is-active" }
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
modified_selectors.each do |item|
|
|
162
|
+
matching = parsed_files[item[:file]].select { |r| r[:selector] == item[:selector] }
|
|
163
|
+
fail!("selector #{item[:selector]} not found in #{item[:file]}") if matching.empty?
|
|
164
|
+
|
|
165
|
+
matching.each do |r|
|
|
166
|
+
decls = r[:declarations]
|
|
167
|
+
# Check borders: border should be 0, none, or border-color transparent
|
|
168
|
+
if decls.key?("border")
|
|
169
|
+
val = decls["border"]
|
|
170
|
+
unless val == "0" || val == "none" || val.start_with?("0") || val.include?("transparent")
|
|
171
|
+
fail!("unexpected non-zero border '#{val}' in #{item[:file]} for #{item[:selector]}")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
if decls.key?("border-color")
|
|
175
|
+
val = decls["border-color"]
|
|
176
|
+
fail!("border-color not transparent in #{item[:file]} for #{item[:selector]}") unless val == "transparent"
|
|
177
|
+
end
|
|
178
|
+
# Check inset ring
|
|
179
|
+
if decls.key?("box-shadow")
|
|
180
|
+
val = decls["box-shadow"]
|
|
181
|
+
fail!("inset ring found in #{item[:file]} for #{item[:selector]}: #{val}") if val.include?("inset")
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# 3. Filter states (criterion 3)
|
|
187
|
+
writing_rules = parsed_files["writing.css"]
|
|
188
|
+
writing_filter = writing_rules.find { |r| r[:selector] == ".writing-rail .listing-filter-chip" }
|
|
189
|
+
fail!("missing .writing-rail .listing-filter-chip in writing.css") unless writing_filter
|
|
190
|
+
fail!(".writing-rail .listing-filter-chip still has border-left") if writing_filter[:declarations].key?("border-left")
|
|
191
|
+
fail!(".writing-rail .listing-filter-chip needs align-self: flex-start") unless writing_filter[:declarations]["align-self"] == "flex-start"
|
|
192
|
+
|
|
193
|
+
# F4c in cv.css must be unchanged
|
|
194
|
+
cv_css = files_to_check["cv.css"]
|
|
195
|
+
fail!("F4c .cv-timeline-option must stay unchanged") unless cv_css.include?(".cv-timeline-option") && cv_css.include?("border-left: 2px solid transparent")
|
|
196
|
+
|
|
197
|
+
# Selected filters set color: var(--color-on-surface)
|
|
198
|
+
active_panel_filter = parsed_files["base.css"].find { |r| r[:selector] == ".listing-filter-chip.is-active" }
|
|
199
|
+
fail!("missing .listing-filter-chip.is-active in base.css") unless active_panel_filter
|
|
200
|
+
fail!(".listing-filter-chip.is-active must have color: var(--color-on-surface)") unless active_panel_filter[:declarations]["color"] == "var(--color-on-surface)"
|
|
201
|
+
|
|
202
|
+
active_rail_filter = parsed_files["writing.css"].find { |r| r[:selector] == ".writing-rail .listing-filter-chip.is-active" }
|
|
203
|
+
fail!("missing .writing-rail .listing-filter-chip.is-active in writing.css") unless active_rail_filter
|
|
204
|
+
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)"
|
|
205
|
+
|
|
206
|
+
# 4. Solid mark (criterion 4 and §2.2)
|
|
207
|
+
home_f6 = parsed_files["home.css"].find { |r| r[:selector] == ".home-featured-foot .home-featured-cta" }
|
|
208
|
+
fail!("missing .home-featured-foot .home-featured-cta in home.css") unless home_f6
|
|
209
|
+
fail!("home F6 CTA must have color: var(--color-on-primary)") unless home_f6[:declarations]["color"] == "var(--color-on-primary)"
|
|
210
|
+
fail!("home F6 CTA must use solid mark recipe with calc(100% - 0.8rem)") unless home_f6[:declarations]["background"]&.include?("calc(100% - 0.8rem)")
|
|
211
|
+
|
|
212
|
+
home_f6_hover = parsed_files["home.css"].find { |r| r[:selector] == ".home-featured-foot .home-featured-cta:hover" }
|
|
213
|
+
fail!("missing .home-featured-foot .home-featured-cta:hover in home.css") unless home_f6_hover
|
|
214
|
+
fail!("home F6 CTA hover must be solid var(--color-primary)") unless home_f6_hover[:declarations]["background"] == "var(--color-primary)"
|
|
215
|
+
|
|
216
|
+
writing_f6 = parsed_files["writing.css"].find { |r| r[:selector] == ".writing-index .writing-lead-foot .writing-lead-cta" }
|
|
217
|
+
fail!("missing .writing-index .writing-lead-foot .writing-lead-cta in writing.css") unless writing_f6
|
|
218
|
+
fail!("writing F6 CTA must have color: var(--color-on-primary)") unless writing_f6[:declarations]["color"] == "var(--color-on-primary)"
|
|
219
|
+
fail!("writing F6 CTA must use solid mark recipe with calc(100% - 0.8rem)") unless writing_f6[:declarations]["background"]&.include?("calc(100% - 0.8rem)")
|
|
220
|
+
|
|
221
|
+
writing_f6_hover = parsed_files["writing.css"].find { |r| r[:selector] == ".writing-index .writing-lead-cta:hover" }
|
|
222
|
+
fail!("missing .writing-index .writing-lead-cta:hover in writing.css") unless writing_f6_hover
|
|
223
|
+
fail!("writing F6 CTA hover must be solid var(--color-primary)") unless writing_f6_hover[:declarations]["background"] == "var(--color-primary)"
|
|
224
|
+
|
|
225
|
+
# 5. Strong buttons & Action margins (criterion 4)
|
|
226
|
+
f5_emp = parsed_files["base.css"].find { |r| r[:selector] == ".ledger-cta--emphasis" }
|
|
227
|
+
fail!("missing .ledger-cta--emphasis") unless f5_emp
|
|
228
|
+
fail!(".ledger-cta--emphasis must set margin: 1px") unless f5_emp[:declarations]["margin"] == "1px"
|
|
229
|
+
|
|
230
|
+
f5_alt = parsed_files["base.css"].find { |r| r[:selector] == ".ledger-cta--emphasis-alt" }
|
|
231
|
+
fail!("missing .ledger-cta--emphasis-alt") unless f5_alt
|
|
232
|
+
fail!(".ledger-cta--emphasis-alt must set margin: 1px") unless f5_alt[:declarations]["margin"] == "1px"
|
|
233
|
+
|
|
234
|
+
f5_hover = parsed_files["base.css"].find { |r| r[:selector] == ".ledger-cta--emphasis:hover" }
|
|
235
|
+
fail!("missing .ledger-cta--emphasis:hover") unless f5_hover
|
|
236
|
+
fail!(".ledger-cta--emphasis:hover must keep background: var(--color-primary)") unless f5_hover[:declarations]["background"] == "var(--color-primary)"
|
|
237
|
+
|
|
238
|
+
f5_alt_hover = parsed_files["base.css"].find { |r| r[:selector] == ".ledger-cta--emphasis-alt:hover" }
|
|
239
|
+
fail!("missing .ledger-cta--emphasis-alt:hover") unless f5_alt_hover
|
|
240
|
+
fail!(".ledger-cta--emphasis-alt:hover must keep background: var(--color-secondary)") unless f5_alt_hover[:declarations]["background"] == "var(--color-secondary)"
|
|
241
|
+
|
|
242
|
+
detail_action = parsed_files["detail.css"].find { |r| r[:selector] == ".detail-action" }
|
|
243
|
+
fail!("missing .detail-action in detail.css") unless detail_action
|
|
244
|
+
fail!(".detail-action must set margin: 1px") unless detail_action[:declarations]["margin"] == "1px"
|
|
245
|
+
|
|
246
|
+
detail_type_size = parsed_files["detail.css"].find { |r| r[:selector] == ".detail-type-size-trigger" }
|
|
247
|
+
fail!("missing .detail-type-size-trigger in detail.css") unless detail_type_size
|
|
248
|
+
fail!(".detail-type-size-trigger must set margin: 1px") unless detail_type_size[:declarations]["margin"] == "1px"
|
|
249
|
+
|
|
250
|
+
# Detail action hover text on the gray hover mark must meet 4.5:1 with the
|
|
251
|
+
# green section primary, which takes the deeper action ink mix.
|
|
252
|
+
[".detail-action:hover", ".detail-type-size-trigger:hover"].each do |sel|
|
|
253
|
+
rule = parsed_files["detail.css"].find { |r| r[:selector] == sel }
|
|
254
|
+
fail!("missing #{sel} in detail.css") unless rule
|
|
255
|
+
fail!("#{sel} must mix its text with var(--action-ink-mix)") unless rule[:declarations]["color"].to_s.include?("var(--action-ink-mix)")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
filter_trigger = parsed_files["base.css"].find { |r| r[:selector] == ".listing-filter-trigger" }
|
|
259
|
+
fail!("missing .listing-filter-trigger in base.css") unless filter_trigger
|
|
260
|
+
fail!(".listing-filter-trigger must set margin: 1px") unless filter_trigger[:declarations]["margin"] == "1px"
|
|
261
|
+
|
|
262
|
+
# 6. Variants (criterion 8 and §2.3)
|
|
263
|
+
base_rules = parsed_files["base.css"]
|
|
264
|
+
var_a = base_rules.find { |r| r[:selector].include?(":nth-child(3n+1)") }
|
|
265
|
+
var_b = base_rules.find { |r| r[:selector].include?(":nth-child(3n+2)") }
|
|
266
|
+
var_c = base_rules.find { |r| r[:selector].include?(":nth-child(3n)") && !r[:selector].include?("3n+") }
|
|
267
|
+
|
|
268
|
+
fail!("missing nth-child(3n+1) variant rule") unless var_a
|
|
269
|
+
fail!("missing nth-child(3n+2) variant rule") unless var_b
|
|
270
|
+
fail!("missing nth-child(3n) variant rule") unless var_c
|
|
271
|
+
|
|
272
|
+
[var_a, var_b, var_c].each do |rule|
|
|
273
|
+
rule[:declarations].each_key do |prop|
|
|
274
|
+
fail!("variant rule #{rule[:selector]} modifies non-mk property #{prop}") unless prop.start_with?("--mk-")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
fail!("variant A angle must be 104deg") unless var_a[:declarations]["--mk-angle"] == "104deg"
|
|
279
|
+
fail!("variant A s0 must be 0.9%") unless var_a[:declarations]["--mk-s0"] == "0.9%"
|
|
280
|
+
fail!("variant A s1 must be 2.6%") unless var_a[:declarations]["--mk-s1"] == "2.6%"
|
|
281
|
+
fail!("variant A mid must be 58%") unless var_a[:declarations]["--mk-mid"] == "58%"
|
|
282
|
+
fail!("variant A end must be 98%") unless var_a[:declarations]["--mk-end"] == "98%"
|
|
283
|
+
fail!("variant A h must be 100%") unless var_a[:declarations]["--mk-h"] == "100%"
|
|
284
|
+
|
|
285
|
+
fail!("variant B angle must be 97deg") unless var_b[:declarations]["--mk-angle"] == "97deg"
|
|
286
|
+
fail!("variant B s0 must be 1.8%") unless var_b[:declarations]["--mk-s0"] == "1.8%"
|
|
287
|
+
fail!("variant B s1 must be 4%") unless var_b[:declarations]["--mk-s1"] == "4%"
|
|
288
|
+
fail!("variant B mid must be 66%") unless var_b[:declarations]["--mk-mid"] == "66%"
|
|
289
|
+
fail!("variant B end must be 96%") unless var_b[:declarations]["--mk-end"] == "96%"
|
|
290
|
+
fail!("variant B h must be 88%") unless var_b[:declarations]["--mk-h"] == "88%"
|
|
291
|
+
fail!("variant B y must be 85%") unless var_b[:declarations]["--mk-y"] == "85%"
|
|
292
|
+
|
|
293
|
+
fail!("variant C angle must be 111deg") unless var_c[:declarations]["--mk-angle"] == "111deg"
|
|
294
|
+
fail!("variant C s0 must be 0.4%") unless var_c[:declarations]["--mk-s0"] == "0.4%"
|
|
295
|
+
fail!("variant C s1 must be 1.8%") unless var_c[:declarations]["--mk-s1"] == "1.8%"
|
|
296
|
+
fail!("variant C mid must be 48%") unless var_c[:declarations]["--mk-mid"] == "48%"
|
|
297
|
+
fail!("variant C end must be 92%") unless var_c[:declarations]["--mk-end"] == "92%"
|
|
298
|
+
fail!("variant C h must be 90%") unless var_c[:declarations]["--mk-h"] == "90%"
|
|
299
|
+
fail!("variant C y must be 20%") unless var_c[:declarations]["--mk-y"] == "20%"
|
|
300
|
+
|
|
301
|
+
# 7. Focus (criterion 5 and §3.1)
|
|
302
|
+
focus_rule = base_rules.find do |r|
|
|
303
|
+
r[:selector].include?(":focus-visible") &&
|
|
304
|
+
r[:declarations]["outline"]&.include?("2px solid") &&
|
|
305
|
+
r[:declarations]["outline-offset"] == "2px"
|
|
306
|
+
end
|
|
307
|
+
fail!("missing 2px outline :focus-visible rule covering interactive pills") unless focus_rule
|
|
308
|
+
|
|
309
|
+
# 8. Menu rule reads tokens (criterion 1)
|
|
310
|
+
rail_active = parsed_files["chrome.css"].find { |r| r[:selector] == ".site-rail-link.is-active" }
|
|
311
|
+
fail!("missing .site-rail-link.is-active in chrome.css") unless rail_active
|
|
312
|
+
fail!(".site-rail-link.is-active must read var(--mark-a)") unless rail_active[:declarations]["background"]&.include?("var(--mark-a)")
|
|
313
|
+
fail!(".site-rail-link.is-active must read var(--mark-b)") unless rail_active[:declarations]["background"]&.include?("var(--mark-b)")
|
|
314
|
+
fail!(".site-rail-link.is-active must read var(--mark-c)") unless rail_active[:declarations]["background"]&.include?("var(--mark-c)")
|
|
315
|
+
|
|
316
|
+
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('; ')})"
|