pandorga 1.14.0 → 2.0.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/DESIGN.md +15 -30
  4. data/_includes/content-runtime/43-blocks.html +0 -3
  5. data/_includes/content-runtime/60-fragments.html +83 -149
  6. data/_includes/content-runtime/90-bootstrap.html +3 -0
  7. data/_includes/cv/cv-box.html +10 -4
  8. data/_includes/home/band-index.html +22 -7
  9. data/_includes/home/band-listing.html +7 -3
  10. data/_includes/page/timeline-section.html +8 -3
  11. data/_includes/theme/theme-vars-block.liquid +3 -0
  12. data/_layouts/articles.html +0 -2
  13. data/_layouts/bibliography.html +0 -2
  14. data/_layouts/blog.html +0 -5
  15. data/_layouts/cv.html +27 -8
  16. data/_layouts/home.html +2 -13
  17. data/_layouts/projects.html +17 -9
  18. data/_layouts/resources.html +0 -9
  19. data/_layouts/shell.html +1 -7
  20. data/assets/css/article.css +10 -2
  21. data/assets/css/base.css +68 -44
  22. data/assets/css/ledger/bibliography.css +0 -31
  23. data/assets/css/ledger/chrome.css +51 -2
  24. data/assets/css/ledger/cv.css +0 -40
  25. data/assets/css/ledger/detail.css +20 -6
  26. data/assets/css/ledger/home.css +261 -402
  27. data/assets/css/ledger/projects.css +17 -19
  28. data/assets/css/ledger/rascunhos.css +0 -27
  29. data/assets/css/ledger/sources.css +0 -57
  30. data/assets/css/ledger/writing.css +0 -62
  31. data/lib/pandorga/commands/new.rb +2 -2
  32. data/lib/pandorga/commands/thumbnails.rb +3 -2
  33. data/lib/pandorga/registry/schema.yml +0 -3
  34. data/lib/pandorga/thumbnail_variants.rb +29 -3
  35. data/lib/pandorga/version.rb +1 -1
  36. data/scripts/check_home_lists.mjs +33 -5
  37. data/scripts/content/hero_portraits.rb +59 -11
  38. data/scripts/test/test-css-structure.rb +0 -1
  39. data/scripts/test/test-hero-16x9.rb +379 -0
  40. data/scripts/test/test-hero-portraits.mjs +10 -20
  41. data/scripts/test/test-hero-portraits.rb +31 -25
  42. data/scripts/test/test-highlighter-pills.rb +12 -2
  43. data/scripts/test/test-home-lists.rb +5 -0
  44. data/scripts/test/test-new.rb +6 -6
  45. data/scripts/test/test-shell-a11y.rb +2 -1
  46. data/scripts/test/test-studio-section-texts.rb +625 -0
  47. data/scripts/test/test-theme-contrast.rb +13 -2
  48. data/scripts/test/test-v3-layout.rb +559 -0
  49. data/scripts/validate.sh +3 -0
  50. data/studio/assets/{index-BJyN-kB3.js → index-B9h1WUYY.js} +1 -1
  51. data/studio/index.html +1 -1
  52. data/studio/schema.yml +126 -2
  53. data/studio-app/src/lib/yaml-doc.js +6 -0
  54. data/templates/network/studio.yml +10 -0
  55. metadata +5 -2
@@ -71,8 +71,8 @@ def mix(color_a, color_b, percentage)
71
71
  end
72
72
 
73
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 }
74
+ { name: "light", theme: light, menu_strength: 18, strong_strength: 28, hover_strength: 30, facet_strength: 14, quiet_strength: 12 },
75
+ { name: "dark", theme: dark, menu_strength: 14, strong_strength: 22, hover_strength: 24, facet_strength: 14, quiet_strength: 10 }
76
76
  ]
77
77
 
78
78
  themes.each do |entry|
@@ -114,6 +114,17 @@ themes.each do |entry|
114
114
  fail!("PLT-AC-46 Menu active #{mode} #{accent} on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
115
115
  end
116
116
 
117
+ # F9: Text calls to action (PLT-AC-49): the emphasis ink over the strong
118
+ # and hover mark heads, on every step a card or the canvas can show.
119
+ (surfaces + [neutrals["panel_contrast"]]).compact.uniq.each do |bg|
120
+ # The ink is `--action-ink-mix`: 74% accent in light, 85% in dark.
121
+ f9_text = mix(accent, on_surface, (mode == "light") ? 74 : 85)
122
+ [entry[:strong_strength], entry[:hover_strength]].each do |strength|
123
+ c = contrast(f9_text, mix(accent, bg, strength))
124
+ fail!("PLT-AC-49 F9 #{mode} #{accent} at #{strength}% on #{bg}: #{c.round(2)}:1 < 4.5:1") if c < 4.5
125
+ end
126
+ end
127
+
117
128
  # F6: Solid mark (inverted on-primary text over solid accent at glyph)
118
129
  c_f6 = contrast(on_primary, accent)
119
130
  fail!("PLT-AC-46 F6 #{mode} #{on_primary} on #{accent}: #{c_f6.round(2)}:1 < 4.5:1") if c_f6 < 4.5
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-49. Canvas and card layout (docs/features/v3-layout.md).
5
+ #
6
+ # Each check names the acceptance item it covers (49.1 … 49.14). Pass item
7
+ # numbers to run a subset: `ruby scripts/test/test-v3-layout.rb 5 7`.
8
+ # The parser understands nested @media blocks and splits selector lists only
9
+ # on top-level commas, so `:is()` / `:where()` lists stay whole.
10
+
11
+ require "pathname"
12
+ require "set"
13
+
14
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
15
+ CSS = ROOT.join("assets/css")
16
+ require "set"
17
+ ONLY_ITEMS = ARGV.map { |a| a.sub(/\A(PLT-AC-)?49\./, "") }.to_set
18
+
19
+ MOBILE = "max-width:767px"
20
+ WIDE = "min-width:768px"
21
+
22
+ $failures = []
23
+
24
+ def item(id)
25
+ return if !ONLY_ITEMS.empty? && !ONLY_ITEMS.include?(id.to_s)
26
+
27
+ @item = id
28
+ yield
29
+ end
30
+
31
+ def fail!(msg)
32
+ $failures << "FAIL PLT-AC-49.#{@item}: #{msg}"
33
+ end
34
+
35
+ def strip_comments(text)
36
+ text.gsub(%r{/\*.*?\*/}m, "")
37
+ end
38
+
39
+ def split_top(text, sep)
40
+ parts = []
41
+ depth = 0
42
+ quote = nil
43
+ buf = +""
44
+ text.each_char do |c|
45
+ if quote
46
+ quote = nil if c == quote
47
+ elsif c == '"' || c == "'"
48
+ quote = c
49
+ elsif c == "(" || c == "["
50
+ depth += 1
51
+ elsif c == ")" || c == "]"
52
+ depth -= 1
53
+ elsif c == sep && depth.zero?
54
+ parts << buf
55
+ buf = +""
56
+ next
57
+ end
58
+ buf << c
59
+ end
60
+ parts << buf
61
+ parts.map(&:strip).reject(&:empty?)
62
+ end
63
+
64
+ def norm_selector(sel)
65
+ sel.gsub(/\s+/, " ").gsub(/\s*([>+~])\s*/, '\1').strip
66
+ end
67
+
68
+ def norm_media(head)
69
+ head.sub(/\A@media/, "").gsub(/\s+/, "")
70
+ end
71
+
72
+ def parse_rules(css, media = nil, out = [])
73
+ i = 0
74
+ head = +""
75
+ while i < css.length
76
+ c = css[i]
77
+ if c == "{"
78
+ depth = 1
79
+ j = i + 1
80
+ while j < css.length && depth.positive?
81
+ depth += 1 if css[j] == "{"
82
+ depth -= 1 if css[j] == "}"
83
+ j += 1
84
+ end
85
+ body = css[(i + 1)...(j - 1)]
86
+ h = head.strip
87
+ if h.start_with?("@media")
88
+ parse_rules(body, [media, norm_media(h)].compact.join("and"), out)
89
+ elsif h.start_with?("@supports", "@layer", "@container")
90
+ parse_rules(body, media, out)
91
+ elsif !h.start_with?("@")
92
+ decls = split_top(body, ";").filter_map do |d|
93
+ prop, val = d.split(":", 2)
94
+ next unless val
95
+
96
+ [prop.strip.downcase, val.gsub(/\s+/, " ").strip]
97
+ end
98
+ split_top(h, ",").each do |sel|
99
+ out << { sel: norm_selector(sel), decls: decls, media: media }
100
+ end
101
+ end
102
+ head = +""
103
+ i = j
104
+ elsif c == ";" && head.strip.start_with?("@")
105
+ head = +""
106
+ i += 1
107
+ elsif c == "}"
108
+ i += 1
109
+ else
110
+ head << c
111
+ i += 1
112
+ end
113
+ end
114
+ out
115
+ end
116
+
117
+ SHEETS = Hash.new do |h, rel|
118
+ text = strip_comments(File.read(CSS.join(rel)))
119
+ h[rel] = { text: text, rules: parse_rules(text) }
120
+ end
121
+
122
+ def media_match?(rule_media, media)
123
+ return rule_media.nil? if media.nil?
124
+
125
+ !rule_media.nil? && rule_media.include?(media)
126
+ end
127
+
128
+ # Ordered declarations of every rule for `selector` under `media`
129
+ # (nil = outside any @media), in source order.
130
+ def decls(rel, selector, media = nil)
131
+ sel = norm_selector(selector)
132
+ SHEETS[rel][:rules].select { |r| r[:sel] == sel && media_match?(r[:media], media) }.flat_map { |r| r[:decls] }
133
+ end
134
+
135
+ def value(list, prop)
136
+ list.reverse.find { |p, _| p == prop }&.last
137
+ end
138
+
139
+ def background(list)
140
+ list.reverse.find { |p, _| p == "background" || p == "background-color" }&.last
141
+ end
142
+
143
+ def where(rel, selector, media)
144
+ "#{rel} #{selector}#{media ? " @media (#{media})" : ''}"
145
+ end
146
+
147
+ def expect(rel, selector, expected, media: nil)
148
+ list = decls(rel, selector, media)
149
+ if list.empty?
150
+ fail!("#{where(rel, selector, media)} has no rule")
151
+ return
152
+ end
153
+ expected.each do |prop, want|
154
+ got = prop == :background ? background(list) : value(list, prop)
155
+ next if got == want
156
+
157
+ fail!("#{where(rel, selector, media)} #{prop}: expected `#{want}`, found `#{got || 'unset'}`")
158
+ end
159
+ end
160
+
161
+ def expect_unset(rel, selector, props, media: nil)
162
+ list = decls(rel, selector, media)
163
+ props.each do |prop|
164
+ got = value(list, prop)
165
+ fail!("#{where(rel, selector, media)} must not set #{prop} (found `#{got}`)") if got
166
+ end
167
+ end
168
+
169
+ # `border` must come before `border-top` so the shorthand does not reset it.
170
+ def expect_top_after_border(rel, selector)
171
+ list = decls(rel, selector)
172
+ b = list.rindex { |p, _| p == "border" }
173
+ t = list.rindex { |p, _| p == "border-top" }
174
+ return if b && t && t > b
175
+
176
+ fail!("#{rel} #{selector}: `border-top` must follow the `border` shorthand")
177
+ end
178
+
179
+ def expect_clamp(rel, selector, lines, media: nil, display: true)
180
+ want = { "-webkit-line-clamp" => lines.to_s, "line-clamp" => lines.to_s }
181
+ want.merge!("display" => "-webkit-box", "-webkit-box-orient" => "vertical", "overflow" => "hidden") if display
182
+ expect(rel, selector, want, media: media)
183
+ end
184
+
185
+ def no_contrast_surface(rel, pattern)
186
+ SHEETS[rel][:rules].each do |r|
187
+ next unless r[:sel].match?(pattern)
188
+
189
+ r[:decls].each do |_, v|
190
+ next unless v.match?(/--color-surface-(card|high)-contrast\b/)
191
+
192
+ fail!("#{rel} #{r[:sel]} still paints #{v[/--color-surface-\w+-contrast/]}")
193
+ end
194
+ end
195
+ end
196
+
197
+ def read(rel)
198
+ File.read(ROOT.join(rel))
199
+ end
200
+
201
+ # 49.1 Canvas -----------------------------------------------------------------
202
+ item(1) do
203
+ unless read("_includes/theme/theme-vars-block.liquid").include?("--color-canvas: {{ tn.canvas | default: tn.panel_contrast")
204
+ fail!("theme-vars-block must emit --color-canvas from neutrals.canvas, else panel_contrast")
205
+ end
206
+ expect("base.css", "html body", { background: "var(--color-canvas)" })
207
+ expect("ledger/home.css", ".home-band--hero", { background: "transparent" })
208
+ expect("ledger/home.css", ".home-band--contrast", { background: "transparent" })
209
+ no_contrast_surface("base.css", /./)
210
+ end
211
+
212
+ # 49.2 Cards ------------------------------------------------------------------
213
+ item(2) do
214
+ %w[.ledger-card .ledger-system-card].each do |sel|
215
+ expect("base.css", sel, {
216
+ background: "var(--color-surface-card)",
217
+ "border" => "1px solid var(--border-ink)",
218
+ "border-radius" => "var(--radius-card)"
219
+ })
220
+ end
221
+ SHEETS["base.css"][:rules].each do |r|
222
+ next unless r[:sel].match?(/\[data-theme=["']?light|:not\(\[data-theme=["']?dark/)
223
+ next unless r[:sel].match?(/\.ledger-(system-)?card\z/)
224
+
225
+ fail!("base.css #{r[:sel]}: remove the light-only card border override")
226
+ end
227
+ expect("ledger/home.css", ".home-featured", {
228
+ background: "var(--color-surface-card)",
229
+ "border" => "1px solid var(--border-ink)",
230
+ "border-top" => "2px solid var(--color-primary)"
231
+ })
232
+ expect_top_after_border("ledger/home.css", ".home-featured")
233
+ end
234
+
235
+ # 49.3 Decoration -------------------------------------------------------------
236
+ RETIRED_SHARDS = %w[
237
+ home-hero-shard home-featured-shard projects-lead-shard projects-hero-shard
238
+ writing-lead-shard rascunhos-shard bib-masthead-shard bib-foot-shard
239
+ sources-masthead-shard sources-foot-shard cv-dossier-shard
240
+ ].freeze
241
+
242
+ item(3) do
243
+ home = SHEETS["ledger/home.css"][:text]
244
+ fail!("home.css still draws a radial-gradient pool") if home.include?("radial-gradient(")
245
+ fail!("home.css still blurs a pool (filter: blur)") if home.match?(/(?<![-\w])filter:\s*blur\(/)
246
+ keep = %w[_includes/page/site-footer.html _includes/home/band-index.html]
247
+ Dir[ROOT.join("{_layouts,_includes}/**/*.html").to_s].sort.each do |path|
248
+ rel = Pathname.new(path).relative_path_from(ROOT).to_s
249
+ next if keep.include?(rel)
250
+
251
+ fail!("#{rel} still renders a decorative shard") if File.read(path).include?("shard-accent")
252
+ end
253
+ Dir[CSS.join("**/*.css").to_s].sort.each do |path|
254
+ text = strip_comments(File.read(path))
255
+ RETIRED_SHARDS.each do |cls|
256
+ fail!("#{Pathname.new(path).relative_path_from(ROOT)} still styles .#{cls}") if text.match?(/\.#{cls}\b/)
257
+ end
258
+ end
259
+ if read("_includes/content-runtime/43-blocks.html").include?("ledger-frame-corners")
260
+ fail!("43-blocks.html still emits ledger-frame-corners on cards")
261
+ end
262
+ unless read("_includes/page/site-footer.html").include?("shard-accent")
263
+ fail!("the footer must keep its shards")
264
+ end
265
+ end
266
+
267
+ # 49.4 Top bar ----------------------------------------------------------------
268
+ item(4) do
269
+ shell = read("_layouts/shell.html")
270
+ fail!("shell.html top bar must be <header class=\"site-topbar sticky top-0 z-50\">") unless shell.include?('<header class="site-topbar sticky top-0 z-50">')
271
+ fail!("shell.html still draws the tonal separator under the top bar") if shell.include?("bg-surface-container-low h-[1px]")
272
+ expect("ledger/chrome.css", ".site-topbar", {
273
+ "background-color" => "color-mix(in srgb, var(--color-canvas) 92%, transparent)",
274
+ "backdrop-filter" => "blur(10px)",
275
+ "-webkit-backdrop-filter" => "blur(10px)",
276
+ "border-bottom" => "1px solid var(--border-gentle-divider)"
277
+ })
278
+ %w[#theme-toggle-btn #mobile-menu-btn].each do |sel|
279
+ expect("ledger/chrome.css", sel, {
280
+ "width" => "2.25rem",
281
+ "height" => "2.25rem",
282
+ "border" => "1px solid var(--color-outline-variant)",
283
+ "background-color" => "var(--color-surface-card)",
284
+ "color" => "var(--color-on-surface)"
285
+ })
286
+ end
287
+ end
288
+
289
+ # 49.5 Band header ------------------------------------------------------------
290
+ item(5) do
291
+ h = "ledger/home.css"
292
+ expect(h, ".home-band:not(.home-band--hero) .ledger-section-banner", {
293
+ "display" => "grid", "grid-template-columns" => "minmax(0, 1fr) auto"
294
+ })
295
+ expect(h, ".home-band .ledger-section-lead", { "display" => "contents" })
296
+ expect(h, ".home-band .ledger-section-banner .ledger-kicker", { "grid-column" => "1 / -1", "grid-row" => "1" })
297
+ expect(h, ".home-band .ledger-kicker:empty", { "display" => "none" })
298
+ expect(h, ".home-band .ledger-section-banner .section-title", {
299
+ "grid-column" => "1", "grid-row" => "2", "font-size" => "var(--type-section-sm)"
300
+ })
301
+ expect(h, ".home-band .ledger-section-banner .section-title", { "font-size" => "var(--type-section)" }, media: WIDE)
302
+ expect(h, ".home-band .home-section-aside", { "grid-column" => "2", "grid-row" => "2" })
303
+ expect(h, ".home-band .home-section-note", {
304
+ "grid-column" => "1 / -1", "grid-row" => "3", "font-size" => "var(--type-dek)"
305
+ })
306
+ end
307
+
308
+ # 49.6 Card type --------------------------------------------------------------
309
+ item(6) do
310
+ b = "base.css"
311
+ expect(b, ".ledger-meta", {
312
+ "font-family" => "var(--font-mono)", "letter-spacing" => "0", "font-size" => "var(--type-micro)"
313
+ })
314
+ %w[.card-title .ledger-card-title].each do |sel|
315
+ expect(b, sel, { "font-weight" => "var(--weight-emphasis)" })
316
+ expect(b, sel, { "font-size" => "var(--type-card-sm)", "line-height" => "1.25" }, media: MOBILE)
317
+ end
318
+ %w[.ledger-card-body .ledger-system-card].each do |sel|
319
+ expect(b, sel, { "padding" => "var(--space-md)" }, media: MOBILE)
320
+ end
321
+ expect_clamp(b, ".ledger-card-copy", 3)
322
+ expect_clamp(b, ".ledger-card-copy", 4, media: WIDE, display: false)
323
+ expect(b, ".ledger-card-copy", { "font-size" => "var(--type-dek)" }, media: MOBILE)
324
+ end
325
+
326
+ # 49.7 Featured article -------------------------------------------------------
327
+ item(7) do
328
+ h = "ledger/home.css"
329
+ expect(h, ".home-featured", {
330
+ "display" => "flex", "flex-direction" => "column", "padding" => "0"
331
+ }, media: MOBILE)
332
+ expect(h, ".home-featured > .ledger-poster", {
333
+ "order" => "-1", "width" => "100%", "margin" => "0", "aspect-ratio" => "16 / 9", "border" => "0"
334
+ }, media: MOBILE)
335
+ expect(h, ".home-featured-lead", { "padding" => "var(--space-md)" }, media: MOBILE)
336
+ expect_clamp(h, ".home-featured-copy", 3)
337
+ expect_clamp(h, ".home-featured-copy", 4, media: WIDE, display: false)
338
+ end
339
+
340
+ # 49.8 Index panels -----------------------------------------------------------
341
+ item(8) do
342
+ band = read("_includes/home/band-index.html")
343
+ fail!("band-index.html still renders a shard") if band.include?("shard-accent")
344
+ head = band.index('class="home-index-panel-head"')
345
+ figure = band.index('<figure class="home-index-plate"')
346
+ if head.nil? || figure.nil?
347
+ fail!("band-index.html needs .home-index-panel-head before figure.home-index-plate")
348
+ else
349
+ block = band[head...figure]
350
+ unless figure > head && block.include?('class="home-index-title"') && block.include?("ledger-cta") && block.include?("</div>")
351
+ fail!("band-index.html: .home-index-panel-head must hold the h3 title and the Open link, before the plate")
352
+ end
353
+ end
354
+ h = "ledger/home.css"
355
+ expect(h, ".home-index-panel", {
356
+ "padding" => "0", "gap" => "0",
357
+ background: "var(--color-surface-card)", "border" => "1px solid var(--border-ink)"
358
+ })
359
+ expect(h, ".home-index-panel-head", {
360
+ "display" => "flex", "justify-content" => "space-between", "align-items" => "center",
361
+ "padding" => "var(--space-sm) var(--space-md)"
362
+ })
363
+ expect(h, ".home-index-plate", { "border-top" => "1px solid var(--border-gentle-divider)" })
364
+ fail!("home.css still styles .home-index-shard") if SHEETS[h][:text].include?(".home-index-shard")
365
+ no_contrast_surface(h, /home-index-panel/)
366
+ end
367
+
368
+ # 49.9 Rascunhos --------------------------------------------------------------
369
+ item(9) do
370
+ h = "ledger/home.css"
371
+ rules = SHEETS[h][:rules]
372
+ wide = ->(r) { r[:media]&.include?(WIDE) }
373
+ # Selectors with `:nth-child()` are matched without whitespace, so
374
+ # `nth-child(4n + 1)` and `nth-child(4n+1)` count as the same rule.
375
+ loose = ->(sel) { sel.gsub(/\s+/, "") }
376
+ find = lambda do |sel, media|
377
+ rules.select { |r| loose.(r[:sel]) == loose.(sel) && media_match?(r[:media], media) }.flat_map { |r| r[:decls] }
378
+ end
379
+ want = lambda do |sel, media, expected|
380
+ list = find.(sel, media)
381
+ if list.empty?
382
+ fail!("#{where(h, sel, media)} has no rule")
383
+ next
384
+ end
385
+ expected.each do |prop, v|
386
+ got = value(list, prop)
387
+ fail!("#{where(h, sel, media)} #{prop}: expected `#{v}`, found `#{got || 'unset'}`") unless got == v
388
+ end
389
+ end
390
+
391
+ # Desktop keeps the dropped-sheet tilt; phones never rotate a card.
392
+ %w[4n+1 4n+2 4n+3 4n].each do |n|
393
+ t = value(find.(".home-draft-list>*:nth-child(#{n})", WIDE), "transform")
394
+ next if t&.match?(/\Arotate\(\s*-?(?!0(\.0+)?deg)[\d.]+deg\s*\)\z/)
395
+
396
+ fail!("#{where(h, ".home-draft-list>*:nth-child(#{n})", WIDE)} must keep a non-zero rotate(), found `#{t || 'unset'}`")
397
+ end
398
+ rules.each do |r|
399
+ next if wide.(r)
400
+
401
+ r[:decls].each do |p, v|
402
+ fail!("#{r[:sel]}#{r[:media] ? " @media (#{r[:media]})" : ''} sets #{p}: #{v}; rotate() only from 768px") if v.include?("rotate(")
403
+ end
404
+ end
405
+
406
+ # Phones: two cards side by side, the third a landscape card across both
407
+ # columns, the rest hidden.
408
+ third = ".home-draft-list>.home-draft-card:nth-child(3)"
409
+ want.(third, MOBILE, { "grid-column" => "1 / -1", "flex-direction" => "row" })
410
+ want.("#{third} .ledger-poster", MOBILE, {
411
+ "flex" => "0 0 38%", "aspect-ratio" => "auto", "align-self" => "stretch"
412
+ })
413
+ want.("#{third} .ledger-poster img", MOBILE, { "position" => "absolute", "inset" => "0" })
414
+ want.("#{third} .ledger-card-body", MOBILE, { "flex" => "1 1 auto", "min-width" => "0" })
415
+ want.(".home-draft-list>.home-draft-card:nth-child(n + 4)", MOBILE, { "display" => "none" })
416
+ rules.each do |r|
417
+ next unless r[:sel].include?("home-draft") && r[:sel].include?("nth-child(3)") && wide.(r)
418
+ next unless r[:decls].any? { |p, _| %w[grid-column flex-direction].include?(p) }
419
+
420
+ fail!("#{r[:sel]} @media (#{r[:media]}) reshapes the third card from 768px; phones only")
421
+ end
422
+ rules.each do |r|
423
+ next unless loose.(r[:sel]).include?("nth-child(n+4)") && r[:sel].include?("home-draft") && !r[:media]&.include?(MOBILE)
424
+
425
+ fail!("#{r[:sel]} hides drafts outside phones") if r[:decls].any? { |p, v| p == "display" && v == "none" }
426
+ end
427
+
428
+ expect(h, ".home-draft-card .ledger-poster", { "aspect-ratio" => "4 / 3" }, media: MOBILE)
429
+ expect(h, ".home-draft-card .ledger-meta", {
430
+ "flex-wrap" => "nowrap", "white-space" => "nowrap", "overflow" => "hidden"
431
+ }, media: MOBILE)
432
+ expect(h, ".home-draft-card .ledger-card-title", { "font-size" => "var(--type-dek)" }, media: MOBILE)
433
+ expect(h, ".home-draft-card .ledger-card-copy", { "font-size" => "var(--type-label)" }, media: MOBILE)
434
+ expect_clamp(h, ".home-draft-card .ledger-card-title", 3)
435
+ expect_clamp(h, ".home-draft-card .ledger-card-copy", 3)
436
+ expect_clamp(h, ".home-draft-card .ledger-card-copy", 4, media: WIDE, display: false)
437
+ rules.each do |r|
438
+ next unless r[:sel].include?("home-draft-card .ledger-card-copy")
439
+
440
+ r[:decls].each do |p, v|
441
+ fail!("#{r[:sel]} still clamps to #{v}") if p.end_with?("line-clamp") && %w[5 6].include?(v)
442
+ fail!("#{r[:sel]} keeps the 5-6 line filler (#{p}: #{v})") if (p == "min-height" && v.include?("--type-subtitle")) || (p == "height" && v == "0")
443
+ end
444
+ end
445
+ end
446
+
447
+ # 49.10 Projects --------------------------------------------------------------
448
+ item(10) do
449
+ h = "ledger/home.css"
450
+ expect(h, ".home-project-inventory-row", {
451
+ background: "var(--color-surface-card)", "border" => "1px solid var(--border-ink)"
452
+ })
453
+ no_contrast_surface(h, /home-project/)
454
+ p = "ledger/projects.css"
455
+ expect(p, ".projects-page .projects-lead", {
456
+ "border" => "1px solid var(--border-ink)", "border-top" => "2px solid var(--color-primary)"
457
+ })
458
+ expect_top_after_border(p, ".projects-page .projects-lead")
459
+ expect_unset(p, ".projects-page .projects-lead", %w[border-left border-left-color])
460
+ expect_unset(p, ".projects-page .projects-lead:hover", %w[border-left border-left-color])
461
+ expect(p, ".projects-page .projects-lead-body", { "padding" => "var(--space-md)" }, media: MOBILE)
462
+ expect(p, ".projects-page .projects-grid", { "gap" => "var(--space-md)" }, media: MOBILE)
463
+ expect(p, ".projects-page .ledger-system-card > .ledger-poster", {
464
+ "margin" => "calc(var(--space-md) * -1) calc(var(--space-md) * -1) 0"
465
+ }, media: MOBILE)
466
+ end
467
+
468
+ # 49.11 Reading view ----------------------------------------------------------
469
+ item(11) do
470
+ d = "ledger/detail.css"
471
+ side = [".text-detail-sidebar", ".text-detail-sidebar .article-sidebar-heading", ".text-detail-sidebar .article-meta"]
472
+ side.each do |sel|
473
+ expect(d, sel, { background: "var(--color-surface-card)" })
474
+ expect(d, sel, { background: "transparent" }, media: MOBILE)
475
+ end
476
+ expect(d, ".text-detail-sidebar", { "border" => "0" }, media: MOBILE)
477
+ a = "article.css"
478
+ expect(a, ".text-page-main", {
479
+ background: "var(--color-surface-card)", "border" => "1px solid var(--border-ink)"
480
+ })
481
+ expect(a, ".text-page-main", {
482
+ background: "transparent", "border" => "0", "padding" => "0"
483
+ }, media: MOBILE)
484
+ end
485
+
486
+ # 49.12 Text calls to action --------------------------------------------------
487
+ PLAIN_CTA = ".ledger-cta:where(:not(.ledger-cta--emphasis):not(.ledger-cta--emphasis-alt))"
488
+ CTA_INK = "color-mix(in srgb, var(--color-primary) var(--action-ink-mix), var(--color-on-surface))"
489
+
490
+ item(12) do
491
+ b = "base.css"
492
+ shared = SHEETS[b][:rules].find do |r|
493
+ r[:sel] == PLAIN_CTA && r[:media].nil? && background(r[:decls]).to_s.include?("var(--mark-1)")
494
+ end
495
+ fail!("base.css: #{PLAIN_CTA} must join the shared highlighter mark rule") unless shared
496
+ expect(b, PLAIN_CTA, {
497
+ "--mark-1" => "var(--mark-strong-a)", "--mark-2" => "var(--mark-strong-b)", "--mark-3" => "var(--mark-strong-c)",
498
+ "color" => CTA_INK
499
+ })
500
+ expect(b, "#{PLAIN_CTA}:hover", {
501
+ "--mark-1" => "var(--mark-hover-a)", "--mark-2" => "var(--mark-hover-b)", "--mark-3" => "var(--mark-hover-c)",
502
+ "color" => CTA_INK
503
+ })
504
+ Dir[CSS.join("**/*.css").to_s].sort.each do |path|
505
+ rel = Pathname.new(path).relative_path_from(CSS).to_s
506
+ SHEETS[rel][:rules].each do |r|
507
+ next unless r[:sel].include?(".ledger-cta")
508
+
509
+ r[:decls].each do |p, v|
510
+ next unless p.start_with?("text-decoration") && v.include?("underline")
511
+
512
+ fail!("#{rel} #{r[:sel]} still underlines a call to action")
513
+ end
514
+ end
515
+ end
516
+ end
517
+
518
+ # 49.13 Guards ----------------------------------------------------------------
519
+ IMPORTANT_BASELINE = {
520
+ "article.css" => 5, "tailwind.css" => 9, "ledger/bibliography.css" => 1,
521
+ "ledger/detail.css" => 4, "ledger/sources.css" => 1
522
+ }.freeze
523
+
524
+ item(13) do
525
+ Dir[CSS.join("**/*.css").to_s].sort.each do |path|
526
+ rel = Pathname.new(path).relative_path_from(CSS).to_s
527
+ count = SHEETS[rel][:text].scan("!important").size
528
+ allowed = IMPORTANT_BASELINE.fetch(rel, 0)
529
+ fail!("#{rel} has #{count} !important (baseline #{allowed})") if count > allowed
530
+ end
531
+ end
532
+
533
+ # 49.14 Top bar fit -----------------------------------------------------------
534
+ RAIL_FROM = "min-width:1280px"
535
+
536
+ item(14) do
537
+ chrome = "ledger/chrome.css"
538
+ expect(chrome, ".site-rail", { "display" => "none" })
539
+ expect(chrome, ".site-rail", { "display" => "flex" }, media: RAIL_FROM)
540
+ expect(chrome, "#site-menu", { "display" => "none" }, media: RAIL_FROM)
541
+ SHEETS[chrome][:rules].each do |r|
542
+ next unless r[:media]
543
+ next unless %w[.site-rail #site-menu].include?(r[:sel])
544
+ next unless r[:decls].any? { |p, _| p == "display" }
545
+ next if r[:media] == "(#{RAIL_FROM})"
546
+
547
+ fail!("#{chrome} #{r[:sel]} sets display under @media #{r[:media]}; only (#{RAIL_FROM}) may")
548
+ end
549
+ expect(chrome, ".site-rail", { "gap" => "var(--space-sm)" }, media: "min-width:1280px)and(max-width:1439px")
550
+ expect(chrome, ".site-rail", { "gap" => "var(--space-lg)" })
551
+ end
552
+
553
+ if $failures.empty?
554
+ puts "PLT-AC-49 v3 layout: OK"
555
+ else
556
+ puts $failures
557
+ warn "#{$failures.size} PLT-AC-49 check(s) failed"
558
+ exit 1
559
+ end
data/scripts/validate.sh CHANGED
@@ -71,9 +71,12 @@ TESTS=(
71
71
  scripts/test/test-css-delivery.rb
72
72
  scripts/test/test-discovery.rb
73
73
  scripts/test/test-hero-portraits.rb
74
+ scripts/test/test-hero-16x9.rb
74
75
  scripts/test/test-thumbnail-variants.rb
75
76
  scripts/test/test-highlighter-pills.rb
76
77
  scripts/test/test-timeline-pan.rb
78
+ scripts/test/test-v3-layout.rb
79
+ scripts/test/test-studio-section-texts.rb
77
80
  )
78
81
 
79
82
  failed=0