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
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-51. A 16:9 home hero at every width (docs/features/hero-16x9.md).
5
+ #
6
+ # Each check names the acceptance item it covers (51.1 … 51.10). Pass item
7
+ # numbers to run a subset: `ruby scripts/test/test-hero-16x9.rb 4 5`.
8
+ # 51.9 writes images with ImageMagick. Without it the generator half is
9
+ # skipped locally and fails on CI, like test-thumbnail-variants.
10
+
11
+ require "fileutils"
12
+ require "open3"
13
+ require "pathname"
14
+ require "set"
15
+ require "tmpdir"
16
+ require "yaml"
17
+
18
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
19
+ HOME_CSS = ROOT.join("assets/css/ledger/home.css")
20
+ LAYOUT = ROOT.join("_layouts/home.html")
21
+ SHELL = ROOT.join("_layouts/shell.html")
22
+ RUNTIME = ROOT.join("_includes/content-runtime/60-fragments.html")
23
+ SCHEMA = ROOT.join("lib/pandorga/registry/schema.yml")
24
+ CONFIG_DOC = ROOT.join("docs/configuration.md")
25
+
26
+ require_relative "../content/hero_portraits"
27
+ require_relative "../../lib/pandorga/thumbnail_variants"
28
+
29
+ ONLY_ITEMS = ARGV.map { |a| a.sub(/\A(PLT-AC-)?51\./, "") }.to_set
30
+ DESKTOP = "(min-width:1024px)"
31
+ PHONE = "(max-width:767px)"
32
+
33
+ $failures = []
34
+
35
+ def item(id)
36
+ return if !ONLY_ITEMS.empty? && !ONLY_ITEMS.include?(id.to_s)
37
+
38
+ @item = id
39
+ yield
40
+ end
41
+
42
+ def fail!(msg)
43
+ $failures << "FAIL PLT-AC-51.#{@item}: #{msg}"
44
+ end
45
+
46
+ # ── CSS parser (same shape as test-v3-layout.rb) ───────────────────────────
47
+
48
+ def strip_comments(text)
49
+ text.gsub(%r{/\*.*?\*/}m, "")
50
+ end
51
+
52
+ def split_top(text, sep)
53
+ parts = []
54
+ depth = 0
55
+ buf = +""
56
+ text.each_char do |c|
57
+ if c == "(" || c == "["
58
+ depth += 1
59
+ elsif c == ")" || c == "]"
60
+ depth -= 1
61
+ elsif c == sep && depth.zero?
62
+ parts << buf
63
+ buf = +""
64
+ next
65
+ end
66
+ buf << c
67
+ end
68
+ parts << buf
69
+ parts.map(&:strip).reject(&:empty?)
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
+ inner = h.sub(/\A@media/, "").gsub(/\s+/, "")
89
+ parse_rules(body, [media, inner].compact.join("and"), out)
90
+ elsif h.start_with?("@supports", "@layer", "@container")
91
+ parse_rules(body, media, out)
92
+ elsif !h.start_with?("@")
93
+ decls = split_top(body, ";").filter_map do |d|
94
+ prop, val = d.split(":", 2)
95
+ next unless val
96
+
97
+ [prop.strip.downcase, val.gsub(/\s+/, " ").strip]
98
+ end
99
+ split_top(h, ",").each do |sel|
100
+ out << { sel: sel.gsub(/\s+/, " ").strip, decls: decls, media: media, order: out.length }
101
+ end
102
+ end
103
+ head = +""
104
+ i = j
105
+ elsif c == ";" && head.strip.start_with?("@")
106
+ head = +""
107
+ i += 1
108
+ elsif c == "}"
109
+ i += 1
110
+ else
111
+ head << c
112
+ i += 1
113
+ end
114
+ end
115
+ out
116
+ end
117
+
118
+ RULES = parse_rules(strip_comments(HOME_CSS.read))
119
+
120
+ def rules_for(sel, media: :any)
121
+ RULES.select do |r|
122
+ r[:sel] == sel && (media == :any || r[:media] == media)
123
+ end
124
+ end
125
+
126
+ # The value the last matching rule declares, or nil.
127
+ def decl(sel, prop, media: nil)
128
+ rules_for(sel, media: media).reverse_each do |r|
129
+ pair = r[:decls].reverse.find { |p, _| p == prop }
130
+ return pair[1] if pair
131
+ end
132
+ nil
133
+ end
134
+
135
+ def expect_decl(sel, prop, want, media: nil)
136
+ got = decl(sel, prop, media: media)
137
+ ok = want.is_a?(Regexp) ? got.to_s.match?(want) : got == want
138
+ where = media ? " under #{media}" : ""
139
+ fail!("#{sel} #{prop} should be #{want.inspect}#{where}, got #{got.inspect}") unless ok
140
+ end
141
+
142
+ def hero_rule?(r)
143
+ r[:sel].include?("home-hero")
144
+ end
145
+
146
+ # ── 51.1 Plate ──────────────────────────────────────────────────────────────
147
+
148
+ item(1) do
149
+ expect_decl(".home-hero-plate", "aspect-ratio", "16 / 9")
150
+ RULES.select { |r| hero_rule?(r) }.each do |r|
151
+ r[:decls].each do |prop, val|
152
+ next unless prop == "aspect-ratio"
153
+
154
+ if val.match?(%r{\A(3\s*/\s*4|4\s*/\s*3)\z})
155
+ fail!("#{r[:sel]} sets aspect-ratio #{val} under #{r[:media].inspect}")
156
+ end
157
+ end
158
+ end
159
+ landscape = RULES.select { |r| r[:sel].include?("--landscape") && hero_rule?(r) }
160
+ fail!("no .home-hero-plate--landscape rule may remain") unless landscape.empty?
161
+ end
162
+
163
+ # ── 51.2 Phone ──────────────────────────────────────────────────────────────
164
+
165
+ item(2) do
166
+ expect_decl(".home-hero-portrait", "width", /\Acalc\(100% \+ \(2 \* var\(--gutter-mobile\)\)\)\z/, media: PHONE)
167
+ expect_decl(".home-hero-portrait", "margin-inline", "calc(-1 * var(--gutter-mobile))", media: PHONE)
168
+ expect_decl(".home-hero-plate", "border-inline", "0", media: PHONE)
169
+ end
170
+
171
+ # ── 51.3 768px to 1023px ───────────────────────────────────────────────────
172
+
173
+ item(3) do
174
+ rules_for(".home-hero-portrait").each do |r|
175
+ r[:decls].each do |prop, val|
176
+ next unless prop == "max-width" && val.match?(/rem/)
177
+
178
+ fail!(".home-hero-portrait max-width #{val} under #{r[:media].inspect} caps the plate")
179
+ end
180
+ end
181
+ expect_decl(".home-hero-portrait", "width", "100%")
182
+ end
183
+
184
+ # ── 51.4 From 1024px ───────────────────────────────────────────────────────
185
+
186
+ item(4) do
187
+ expect_decl(".home-hero", "display", "flex", media: DESKTOP)
188
+ expect_decl(".home-hero-copy", "flex", "0 0 46%", media: DESKTOP)
189
+ expect_decl(".home-hero-copy", "min-width", "0", media: DESKTOP)
190
+ expect_decl(".home-hero-portrait", "flex", "1 1 0", media: DESKTOP)
191
+ expect_decl(".home-hero-portrait", "min-height", "15rem", media: DESKTOP)
192
+ expect_decl(".home-hero-portrait", "max-width", "none", media: DESKTOP)
193
+ expect_decl(".home-hero-portrait", "aspect-ratio", "auto", media: DESKTOP)
194
+ [".home-hero-plate-wrap", ".home-hero-plate"].each do |sel|
195
+ expect_decl(sel, "position", "absolute", media: DESKTOP)
196
+ expect_decl(sel, "inset", "0", media: DESKTOP)
197
+ expect_decl(sel, "aspect-ratio", "auto", media: DESKTOP)
198
+ end
199
+ expect_decl(".home-hero-plate img", "object-fit", "cover", media: DESKTOP)
200
+ expect_decl(".home-hero-plate img", "object-position", /\Acenter( center)?\z/, media: DESKTOP)
201
+
202
+ layout = LAYOUT.read
203
+ fail!("the crosshairs must stay on the plate wrapper") unless layout.include?('class="ledger-frame-crosshairs"')
204
+ fail!("the corner marks must stay in the plate") unless layout.include?('class="ledger-frame-corners"')
205
+ end
206
+
207
+ # ── 51.5 Cascade ────────────────────────────────────────────────────────────
208
+
209
+ item(5) do
210
+ [".home-hero-portrait", ".home-hero-plate-wrap", ".home-hero-plate", ".home-hero-plate img"].each do |sel|
211
+ base = rules_for(sel, media: nil).map { |r| r[:order] }.max
212
+ wide = rules_for(sel, media: DESKTOP).map { |r| r[:order] }.min
213
+ if base.nil? || wide.nil?
214
+ fail!("#{sel} needs a base rule and a #{DESKTOP} rule")
215
+ elsif wide < base
216
+ fail!("the #{DESKTOP} #{sel} rule comes before its base rule, which wins on source order")
217
+ end
218
+ end
219
+ end
220
+
221
+ # ── 51.6 Name from 1024px ───────────────────────────────────────────────────
222
+
223
+ item(6) do
224
+ title = ".home-ledger .home-hero-title"
225
+ expect_decl(title, "white-space", "normal", media: DESKTOP)
226
+ expect_decl(title, "font-size",
227
+ "min(calc(100cqi / var(--hero-name-fit, 8.4)), var(--hero-name-max, 4.5rem))",
228
+ media: DESKTOP)
229
+ %w[.home-hero-given .home-hero-family].each do |sel|
230
+ expect_decl(sel, "display", "inline-block", media: DESKTOP)
231
+ expect_decl(sel, "white-space", "nowrap", media: DESKTOP)
232
+ end
233
+ end
234
+
235
+ # ── 51.7 Markup ─────────────────────────────────────────────────────────────
236
+
237
+ item(7) do
238
+ layout = LAYOUT.read
239
+ imgs = layout.scan(/<img[^>]*data-content-hero-portrait[^>]*>/m)
240
+ fail!("the layout must render exactly one hero image, got #{imgs.length}") unless imgs.length == 1
241
+ img = imgs.first.to_s
242
+ unless img.include?('width="1600"') && img.include?('height="900"')
243
+ fail!("the hero image must be 1600×900, got #{img.inspect}")
244
+ end
245
+ fail!("no data-hero-landscape on the hero image") if img.include?("data-hero-landscape")
246
+ hero = layout[/<div class="home-hero-portrait">.*?<\/figure>/m].to_s
247
+ fail!("the plate has no <picture> or <source>") if hero.include?("<picture") || hero.include?("<source")
248
+ fail!("the home layout must not read hero_landscape") if layout.include?("hero_landscape")
249
+ fail!("the plate has no --landscape class") if layout.include?("home-hero-plate--landscape")
250
+ shell = SHELL.read
251
+ fail!("the shell must not read hero_landscape") if shell.include?("hero_landscape")
252
+ fail!("the shell must not preload a hero image") if shell.match?(/rel="preload" as="image"/)
253
+ end
254
+
255
+ # ── 51.8 Runtime ────────────────────────────────────────────────────────────
256
+
257
+ item(8) do
258
+ runtime = RUNTIME.read
259
+ start = runtime.index("function heroPortraitEntries")
260
+ finish = runtime.index("function hydratePageFragments")
261
+ if start.nil? || finish.nil? || finish < start
262
+ fail!("hero functions must precede hydratePageFragments")
263
+ else
264
+ body = runtime[start...finish]
265
+ %w[heroLandscapeEntries paintNarrowHero showConfiguredLandscape showHeroLandscape
266
+ applyHeroLandscapeFile setNarrowSource watchHeroFrame configuredLandscape heroPlate].each do |name|
267
+ fail!("#{name} must be removed") if body.include?(name)
268
+ end
269
+ fail!("the hero draw must not watch a media query") if body.include?("matchMedia")
270
+ fail!("the hero runtime must not read a landscape field") if body.include?("landscape")
271
+ hydrate = body[/function hydrateHeroPortrait\(\) \{.*?\n \}/m].to_s
272
+ unless hydrate.include?("loadJson('pages/home/portraits.json')") && hydrate.include?("showHeroPortrait(img, heroPortraitEntries(data))")
273
+ fail!("hydrateHeroPortrait must draw from portraits.json at every width, got #{hydrate.inspect}")
274
+ end
275
+ end
276
+ end
277
+
278
+ # ── 51.9 Export ─────────────────────────────────────────────────────────────
279
+
280
+ # Minimal JPEG and PNG headers. HeroPortraits reads only the size.
281
+ def write_jpeg_header(path, width, height)
282
+ sof = [0xFF, 0xC0, 0x00, 0x11, 0x08].pack("C*") + [height, width].pack("nn") +
283
+ [0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01].pack("C*")
284
+ app0 = [0xFF, 0xE0, 0x00, 0x10].pack("C*") + "JFIF\x00".b + [1, 1, 0, 0, 1, 0, 1, 0, 0].pack("C*")
285
+ FileUtils.mkdir_p(path.dirname)
286
+ File.binwrite(path, [0xFF, 0xD8].pack("C*") + app0 + sof + [0xFF, 0xD9].pack("C*"))
287
+ end
288
+
289
+ def write_png_header(path, width, height)
290
+ ihdr = "IHDR".b + [width, height, 8, 2, 0, 0, 0].pack("NNCCCCC")
291
+ FileUtils.mkdir_p(path.dirname)
292
+ File.binwrite(path, "\x89PNG\r\n\x1A\n".b + [13].pack("N") + ihdr + "\x00\x00\x00\x00".b)
293
+ end
294
+
295
+ item(9) do
296
+ Dir.mktmpdir("hero-16x9") do |dir|
297
+ root = Pathname.new(dir)
298
+ pool = root.join("media/images/hero")
299
+ write_jpeg_header(pool.join("a.jpg"), 1920, 1080)
300
+ write_jpeg_header(pool.join("variants/a-480.jpg"), 480, 270)
301
+ write_jpeg_header(pool.join("variants/a-960.jpg"), 960, 1280) # stale 3:4
302
+ write_png_header(pool.join("b.png"), 1920, 1080)
303
+ write_png_header(pool.join("variants/b-1440.jpg"), 1440, 810) # sniffed, not by name
304
+
305
+ got = HeroPortraits.entries(root)
306
+ sizes = "(max-width: 1023px) 100vw, 50vw"
307
+ want = [
308
+ { "src" => "/media/images/hero/a.jpg",
309
+ "srcset" => "/media/images/hero/variants/a-480.jpg 480w", "sizes" => sizes },
310
+ { "src" => "/media/images/hero/b.png",
311
+ "srcset" => "/media/images/hero/variants/b-1440.jpg 1440w", "sizes" => sizes }
312
+ ]
313
+ fail!("entries must list only 16:9 variants, got #{got.inspect}") unless got == want
314
+ fail!("no entry may carry landscape") if got.any? { |e| e.key?("landscape") }
315
+ end
316
+
317
+ bins = Pandorga::ThumbnailVariants.resolve_imagemagick
318
+ if bins.nil?
319
+ if ENV["CI"]
320
+ fail!("ImageMagick is missing on CI")
321
+ else
322
+ puts "SKIP PLT-AC-51.9 generator: ImageMagick not installed"
323
+ end
324
+ next
325
+ end
326
+
327
+ Dir.mktmpdir("hero-16x9-gen") do |dir|
328
+ root = Pathname.new(dir)
329
+ pool = root.join("content/media/images/hero")
330
+ FileUtils.mkdir_p(pool)
331
+ big = pool.join("wide.jpg")
332
+ small = pool.join("small.jpg")
333
+ out, status = Open3.capture2e(*bins[:convert], "-size", "1920x1080", "xc:steelblue", big.to_s)
334
+ fail!("could not draw a fixture: #{out}") unless status.success?
335
+ out, status = Open3.capture2e(*bins[:convert], "-size", "1000x750", "xc:tomato", small.to_s)
336
+ fail!("could not draw a fixture: #{out}") unless status.success?
337
+ stale = pool.join("variants/wide-960.jpg")
338
+ FileUtils.mkdir_p(stale.dirname)
339
+ out, status = Open3.capture2e(*bins[:convert], "-size", "960x1280", "xc:gray", stale.to_s)
340
+ fail!("could not draw a fixture: #{out}") unless status.success?
341
+ past = Time.now - 3600
342
+ File.utime(past, past, big.to_s)
343
+ File.utime(past, past, small.to_s)
344
+
345
+ Pandorga::ThumbnailVariants.generate(root)
346
+
347
+ size_of = lambda do |path|
348
+ o, _e, s = Open3.capture3(*bins[:identify], "-format", "%wx%h", "#{path}[0]")
349
+ s.success? ? o.strip : nil
350
+ end
351
+ { "wide-480.jpg" => "480x270", "wide-960.jpg" => "960x540", "wide-1440.jpg" => "1440x810",
352
+ "small-480.jpg" => "480x270", "small-960.jpg" => "960x540" }.each do |name, want|
353
+ got = size_of.call(pool.join("variants", name))
354
+ fail!("variants/#{name} should be #{want}, got #{got.inspect}") unless got == want
355
+ end
356
+ fail!("no variant wider than its original") if pool.join("variants/small-1440.jpg").exist?
357
+
358
+ entries = HeroPortraits.entries(root.join("content"))
359
+ wide = entries.find { |e| e["src"] == "/media/images/hero/wide.jpg" }
360
+ want = %w[480 960 1440].map { |w| "/media/images/hero/variants/wide-#{w}.jpg #{w}w" }.join(", ")
361
+ fail!("the export must list the generated variants, got #{wide.inspect}") unless wide && wide["srcset"] == want
362
+ end
363
+ end
364
+
365
+ # ── 51.10 Registry ──────────────────────────────────────────────────────────
366
+
367
+ item(10) do
368
+ schema = YAML.safe_load(SCHEMA.read, aliases: true) || {}
369
+ keys = schema.dig("home", "keys") || {}
370
+ fail!("pandorga.home.hero_landscape must leave the registry schema") if keys.key?("hero_landscape")
371
+ fail!("docs/configuration.md must not list hero_landscape") if CONFIG_DOC.read.include?("hero_landscape")
372
+ end
373
+
374
+ if $failures.empty?
375
+ puts "PLT-AC-51: all checks passed"
376
+ else
377
+ puts $failures
378
+ exit 1
379
+ end
@@ -30,10 +30,10 @@ if (!slice.includes('function pickHeroPortrait')) {
30
30
  }
31
31
 
32
32
  const api = new Function(
33
- `${slice}\nreturn { heroPortraitEntries, heroLandscapeEntries, pickHeroPortrait };`
33
+ `${slice}\nreturn { heroPortraitEntries, pickHeroPortrait };`
34
34
  )();
35
35
 
36
- const { heroPortraitEntries, heroLandscapeEntries, pickHeroPortrait } = api;
36
+ const { heroPortraitEntries, pickHeroPortrait } = api;
37
37
 
38
38
  let failed = 0;
39
39
 
@@ -81,30 +81,20 @@ same(
81
81
  'normalised rows'
82
82
  );
83
83
 
84
+ // AC-HERO-08: one 16:9 frame at every width. A stale landscape key from an
85
+ // older export is dropped, so no width draws a second file.
84
86
  same(
85
87
  heroPortraitEntries([
86
- { src: '/media/images/hero/a.jpg', landscape: ' /media/images/hero/a-16x9.jpg ' },
87
- { src: '/media/images/hero/b.jpg', landscape: '' },
88
- { src: '/media/images/hero/c.jpg', landscape: ' ' },
89
- { src: '/media/images/hero/d.jpg', landscape: 4 },
88
+ { src: '/media/images/hero/a.jpg', landscape: '/media/images/hero/a-16x9.jpg' },
89
+ { src: '/media/images/hero/b.jpg', srcset: '/v/b-480.jpg 480w', sizes: '50vw', landscape: 'x' },
90
90
  ]),
91
91
  [
92
- { src: '/media/images/hero/a.jpg', alt: '', landscape: '/media/images/hero/a-16x9.jpg' },
93
- { src: '/media/images/hero/b.jpg', alt: '' },
94
- { src: '/media/images/hero/c.jpg', alt: '' },
95
- { src: '/media/images/hero/d.jpg', alt: '' },
92
+ { src: '/media/images/hero/a.jpg', alt: '' },
93
+ { src: '/media/images/hero/b.jpg', alt: '', srcset: '/v/b-480.jpg 480w', sizes: '50vw' },
96
94
  ],
97
- 'optional landscape'
95
+ 'landscape dropped'
98
96
  );
99
- same(
100
- heroLandscapeEntries([
101
- { src: '/media/images/hero/a.jpg', alt: '', landscape: '/media/images/hero/a-16x9.jpg' },
102
- { src: '/media/images/hero/b.jpg', alt: '' },
103
- ]),
104
- [{ src: '/media/images/hero/a.jpg', alt: '', landscape: '/media/images/hero/a-16x9.jpg' }],
105
- 'landscape pool'
106
- );
107
- same(heroLandscapeEntries([]), [], 'empty landscape pool');
97
+ check(!/landscape/i.test(slice), 'the picker slice must not read a landscape field');
108
98
 
109
99
  // AC-HERO-06
110
100
  const two = [
@@ -9,6 +9,8 @@
9
9
  # AC-HERO-04 the plate starts with no photograph
10
10
  # AC-HERO-05 the runtime draws once per load and fades the file in after it loads
11
11
  # AC-HERO-06 / AC-HERO-07 the picker, executed from the runtime source
12
+ # AC-HERO-08 one 16:9 frame at every width (PLT-AC-51): no landscape file,
13
+ # no picture source, no width switch
12
14
 
13
15
  require "fileutils"
14
16
  require "json"
@@ -105,16 +107,15 @@ hero_imgs.each do |hero_img|
105
107
  check!(hero_img.include?('fetchpriority="high"'),
106
108
  "the hero image must request fetchpriority high, got #{hero_img.inspect}")
107
109
  end
108
- check!(layout.include?("page.hero_landscape") && layout.include?("site.pandorga.home.hero_landscape"),
109
- "narrow hero reads front matter and pandorga.home.hero_landscape")
110
- check!(layout.include?('media="(max-width: 767px)"') && layout.include?("<picture>"),
111
- "a configured landscape file is a picture source for narrow viewports")
112
- check!(layout.include?("data-hero-landscape"),
113
- "the image carries the landscape URL for the runtime")
110
+ check!(hero_imgs.length == 1 && hero_imgs.first.include?('width="1600"') &&
111
+ hero_imgs.first.include?('height="900"'),
112
+ "AC-HERO-08: one 1600×900 plate image, got #{hero_imgs.inspect}")
113
+ check!(!layout.include?("hero_landscape") && !layout.include?("data-hero-landscape") &&
114
+ !layout.include?("<picture") && !layout.include?("<source"),
115
+ "AC-HERO-08: the layout has no landscape file and no picture source")
114
116
  shell = SHELL.read
115
- check!(shell.include?('rel="preload" as="image"') && shell.include?('fetchpriority="high"') &&
116
- shell.include?('media="(max-width: 767px)"') && shell.include?("hero_landscape"),
117
- "the shell preloads a configured landscape image on narrow viewports")
117
+ check!(!shell.include?("hero_landscape") && !shell.match?(/rel="preload" as="image"/),
118
+ "AC-HERO-08: the shell preloads no hero image")
118
119
  tailwind_at = shell.index('href="/assets/css/tailwind.css"')
119
120
  icons_at = shell.index("window.__pandorgaIconsHref")
120
121
  check!(tailwind_at && icons_at && tailwind_at < icons_at,
@@ -129,17 +130,23 @@ check!(plate_img&.match?(/opacity:\s*0/) && plate_img.match?(/transition:[^;]*op
129
130
  shown = home_css[/^\.home-hero-plate img\.is-shown \{.*?\n\}/m]
130
131
  check!(shown&.match?(/opacity:\s*1/),
131
132
  "a ready photograph fades to full opacity, got #{shown.inspect}")
132
- check!(!home_css.include?("aspect-ratio: 4 / 3"),
133
- "the phone plate must not cover-crop the portrait into 4:3")
134
- landscape_plate = home_css[/@media \(max-width: 767px\) \{\s*\.home-hero-plate--landscape \{[^}]*\}/m]
135
- check!(landscape_plate&.include?("aspect-ratio: 16 / 9"),
136
- "a landscape plate is 16:9 on a phone, got #{landscape_plate.inspect}")
137
- landscape_img = home_css[/\.home-hero-plate--landscape img \{[^}]*\}/m]
138
- check!(landscape_img&.include?("object-fit: contain") && landscape_img.include?("opacity: 0"),
139
- "the 16:9 plate stays empty until the chosen file loads, got #{landscape_img.inspect}")
140
- landscape_shown = home_css[/\.home-hero-plate--landscape img\.is-shown \{[^}]*\}/m]
141
- check!(landscape_shown&.include?("opacity: 1"),
142
- "the chosen 16:9 file paints in the fixed plate, got #{landscape_shown.inspect}")
133
+ # Scoped to the hero rules: other home cards (the Rascunhos posters,
134
+ # PLT-AC-49) may use 4:3. Any spacing of 4/3 counts.
135
+ hero_crops = home_css.scan(/([^{}]*home-hero[^{}]*)\{([^{}]*)\}/).select do |_selector, body|
136
+ body.match?(/aspect-ratio:\s*4\s*\/\s*3/)
137
+ end
138
+ check!(hero_crops.empty?,
139
+ "the phone plate must not cover-crop the portrait into 4:3, got #{hero_crops.map(&:first).map(&:strip).inspect}")
140
+ portrait_crops = home_css.scan(/([^{}]*home-hero[^{}]*)\{([^{}]*)\}/).select do |_selector, body|
141
+ body.match?(/aspect-ratio:\s*3\s*\/\s*4/)
142
+ end
143
+ check!(portrait_crops.empty?,
144
+ "AC-HERO-08: no hero rule keeps the 3:4 frame, got #{portrait_crops.map(&:first).map(&:strip).inspect}")
145
+ base_plate = home_css[/^\.home-hero-plate \{.*?\n\}/m]
146
+ check!(base_plate&.match?(%r{aspect-ratio:\s*16\s*/\s*9}),
147
+ "AC-HERO-08: the plate is 16:9 at every width, got #{base_plate.inspect}")
148
+ check!(!home_css.include?("home-hero-plate--landscape"),
149
+ "AC-HERO-08: the landscape plate class is gone")
143
150
 
144
151
  runtime = RUNTIME.read
145
152
  start = runtime.index("function heroPortraitEntries")
@@ -161,11 +168,8 @@ check!(onload && onload.index("applyHeroPortraitSources") && onload.index("img.s
161
168
  "srcset must be applied before src so the full file is not the first request")
162
169
  check!(body.include?("probe.fetchPriority = 'high'"),
163
170
  "the portrait probe must use fetchPriority high")
164
- check!(body.include?("heroLandscapeEntries") && body.include?("paintNarrowHero") &&
165
- body.include?("entry.landscape"),
166
- "a narrow viewport draws from entries that carry a landscape URL")
167
- check!(body.include?("showConfiguredLandscape") && body.include?("data-hero-landscape"),
168
- "hero_landscape is the fallback when no entry has a landscape")
171
+ check!(!body.include?("landscape") && !body.include?("matchMedia"),
172
+ "AC-HERO-08: one draw at every width, with no landscape field and no width switch")
169
173
  check!(runtime.include?("hydrateHeroPortrait();"),
170
174
  "hydratePageFragments must draw the plate before the later fragment chain")
171
175
  plate_call = runtime.index("function hydratePageFragments")
@@ -202,6 +206,8 @@ Dir.mktmpdir("hero-portraits") do |dir|
202
206
  payload = JSON.parse(exported.read)
203
207
  expected = HeroPortraits.entries(ROOT.join("content"))
204
208
  check!(payload == expected, "the exported list must be the hero folder, got #{payload.inspect}")
209
+ check!(payload.none? { |entry| entry.key?("landscape") },
210
+ "AC-HERO-08: the export writes no landscape field")
205
211
  srcs = payload.map { |entry| entry["src"] }
206
212
  check!(srcs == pool_names.map { |name| "/media/images/hero/#{name}" },
207
213
  "the export must name the four photographs, got #{srcs.inspect}")
@@ -84,7 +84,8 @@ expected_light_tokens = {
84
84
  "--mark-hover-c" => "9%",
85
85
  "--mark-quiet-a" => "12%",
86
86
  "--mark-quiet-b" => "9%",
87
- "--mark-quiet-c" => "3%"
87
+ "--mark-quiet-c" => "3%",
88
+ "--action-ink-mix" => "74%"
88
89
  }
89
90
 
90
91
  expected_dark_tokens = {
@@ -102,7 +103,8 @@ expected_dark_tokens = {
102
103
  "--mark-hover-c" => "7%",
103
104
  "--mark-quiet-a" => "10%",
104
105
  "--mark-quiet-b" => "7%",
105
- "--mark-quiet-c" => "3%"
106
+ "--mark-quiet-c" => "3%",
107
+ "--action-ink-mix" => "85%"
106
108
  }
107
109
 
108
110
  expected_light_tokens.each do |k, v|
@@ -245,6 +247,14 @@ detail_type_size = parsed_files["detail.css"].find { |r| r[:selector] == ".detai
245
247
  fail!("missing .detail-type-size-trigger in detail.css") unless detail_type_size
246
248
  fail!(".detail-type-size-trigger must set margin: 1px") unless detail_type_size[:declarations]["margin"] == "1px"
247
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
+
248
258
  filter_trigger = parsed_files["base.css"].find { |r| r[:selector] == ".listing-filter-trigger" }
249
259
  fail!("missing .listing-filter-trigger in base.css") unless filter_trigger
250
260
  fail!(".listing-filter-trigger must set margin: 1px") unless filter_trigger[:declarations]["margin"] == "1px"
@@ -76,6 +76,11 @@ fail!("probe ignores the phone viewport") unless probe.include?("name: 'mobile'"
76
76
  fail!("probe ignores the desktop viewport") unless probe.include?("name: 'desktop'")
77
77
  fail!("probe does not pin DevTools to IPv4") unless probe.include?("--remote-debugging-address=127.0.0.1")
78
78
  fail!("probe gives Chrome only a few seconds to open DevTools") unless probe.include?("attempt < 200")
79
+ # PLT-AC-26: a Chrome that never opens DevTools (seen on the runner) gets a
80
+ # second launch with a fresh profile and port, not a weaker list check.
81
+ fail!("probe does not relaunch Chrome when DevTools stays closed") unless probe.include?("LAUNCH_ATTEMPTS = 2")
82
+ launch = probe[/async function launchChrome\(.*?\n\}/m].to_s
83
+ fail!("each launch must use a fresh profile and port") unless launch.include?("mkdtemp(") && launch.include?("freePort()")
79
84
 
80
85
  _out, err, status = Open3.capture3("node", "--check", ROOT.join("scripts/check_home_lists.mjs").to_s)
81
86
  fail!("home list probe is not valid JavaScript\n#{err}") unless status.success?
@@ -23,8 +23,8 @@ end
23
23
 
24
24
  guide = ROOT.join("docs/getting-started.md").read
25
25
  readme = ROOT.join("README.md").read
26
- unless guide.include?('gem at `~> 1.3`')
27
- warn "FAIL test-new: getting-started must document the scaffold pin ~> 1.3"
26
+ unless guide.include?('gem at `~> 2.0`')
27
+ warn "FAIL test-new: getting-started must document the scaffold pin ~> 2.0"
28
28
  exit 1
29
29
  end
30
30
  if readme.include?("once 1.1 is on RubyGems")
@@ -51,8 +51,8 @@ scaffold = ROOT.join("lib/pandorga/commands/new.rb").read
51
51
  exit 1
52
52
  end
53
53
  end
54
- unless deploy.include?('~> 1.3') && deploy.include?(%(tag: "v#{version}"))
55
- warn "FAIL test-new: deploy doc must pin ~> 1.3 and tag v#{version}"
54
+ unless deploy.include?('~> 2.0') && deploy.include?(%(tag: "v#{version}"))
55
+ warn "FAIL test-new: deploy doc must pin ~> 2.0 and tag v#{version}"
56
56
  exit 1
57
57
  end
58
58
  unless deploy.include?("content/*") && deploy.include?("docs/*") && deploy.include?("Gemfile.lock")
@@ -108,8 +108,8 @@ end
108
108
  end
109
109
 
110
110
  gemfile = DEST.join("Gemfile").read
111
- unless gemfile.include?('gem "pandorga", "~> 1.3"')
112
- warn "FAIL test-new: Gemfile must pin pandorga ~> 1.3\n#{gemfile}"
111
+ unless gemfile.include?('gem "pandorga", "~> 2.0"')
112
+ warn "FAIL test-new: Gemfile must pin pandorga ~> 2.0\n#{gemfile}"
113
113
  exit 1
114
114
  end
115
115
  unless gemfile.include?("path:") && gemfile.include?("github:")
@@ -28,7 +28,8 @@ fail!("shell home link needs an identity.name fallback") unless SHELL.include?("
28
28
  fail!("footer home link needs an identity.name fallback") unless FOOTER.include?("site.pandorga.identity.name")
29
29
 
30
30
  fail!("top bar is still a nav") if SHELL.include?('<nav class="sticky')
31
- fail!("top bar is not a header") unless SHELL.include?('<header class="sticky top-0 z-50 bg-surface">')
31
+ # PLT-AC-49: the header paints the translucent canvas through .site-topbar.
32
+ fail!("top bar is not a header") unless SHELL.include?('<header class="site-topbar sticky top-0 z-50">')
32
33
  fail!("section links lost their name") unless SHELL.include?('aria-label="Sections"')
33
34
  navs = SHELL.scan(/<nav\b/).size
34
35
  fail!("shell should have one nav landmark, found #{navs}") unless navs == 1