pandorga 1.14.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/DESIGN.md +15 -30
- data/_includes/content-runtime/43-blocks.html +0 -3
- data/_includes/content-runtime/60-fragments.html +83 -149
- data/_includes/content-runtime/90-bootstrap.html +3 -0
- data/_includes/cv/cv-box.html +10 -4
- data/_includes/home/band-index.html +22 -7
- data/_includes/home/band-listing.html +7 -3
- data/_includes/page/timeline-section.html +8 -3
- data/_includes/theme/theme-vars-block.liquid +3 -0
- data/_layouts/articles.html +0 -2
- data/_layouts/bibliography.html +0 -2
- data/_layouts/blog.html +0 -5
- data/_layouts/cv.html +27 -8
- data/_layouts/home.html +2 -13
- data/_layouts/projects.html +17 -9
- data/_layouts/resources.html +0 -9
- data/_layouts/shell.html +1 -7
- data/assets/css/article.css +10 -2
- data/assets/css/base.css +65 -44
- data/assets/css/ledger/bibliography.css +0 -31
- data/assets/css/ledger/chrome.css +51 -2
- data/assets/css/ledger/cv.css +0 -40
- data/assets/css/ledger/detail.css +18 -4
- data/assets/css/ledger/home.css +261 -402
- data/assets/css/ledger/projects.css +17 -19
- data/assets/css/ledger/rascunhos.css +0 -27
- data/assets/css/ledger/sources.css +0 -57
- data/assets/css/ledger/writing.css +0 -62
- data/lib/pandorga/commands/new.rb +2 -2
- data/lib/pandorga/commands/thumbnails.rb +3 -2
- data/lib/pandorga/registry/schema.yml +0 -3
- data/lib/pandorga/thumbnail_variants.rb +29 -3
- data/lib/pandorga/version.rb +1 -1
- data/scripts/check_home_lists.mjs +33 -5
- data/scripts/content/hero_portraits.rb +59 -11
- data/scripts/test/test-css-structure.rb +0 -1
- data/scripts/test/test-hero-16x9.rb +379 -0
- data/scripts/test/test-hero-portraits.mjs +10 -20
- data/scripts/test/test-hero-portraits.rb +31 -25
- data/scripts/test/test-home-lists.rb +5 -0
- data/scripts/test/test-new.rb +6 -6
- data/scripts/test/test-shell-a11y.rb +2 -1
- data/scripts/test/test-studio-section-texts.rb +625 -0
- data/scripts/test/test-theme-contrast.rb +13 -2
- data/scripts/test/test-v3-layout.rb +559 -0
- data/scripts/validate.sh +3 -0
- data/studio/assets/{index-BJyN-kB3.js → index-B9h1WUYY.js} +1 -1
- data/studio/index.html +1 -1
- data/studio/schema.yml +126 -2
- data/studio-app/src/lib/yaml-doc.js +6 -0
- data/templates/network/studio.yml +10 -0
- 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,
|
|
33
|
+
`${slice}\nreturn { heroPortraitEntries, pickHeroPortrait };`
|
|
34
34
|
)();
|
|
35
35
|
|
|
36
|
-
const { heroPortraitEntries,
|
|
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: '
|
|
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: ''
|
|
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
|
-
'
|
|
95
|
+
'landscape dropped'
|
|
98
96
|
);
|
|
99
|
-
|
|
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!(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
"the
|
|
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?(
|
|
116
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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?("
|
|
165
|
-
|
|
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}")
|
|
@@ -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?
|
data/scripts/test/test-new.rb
CHANGED
|
@@ -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 `~>
|
|
27
|
-
warn "FAIL test-new: getting-started must document the scaffold pin ~>
|
|
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?('~>
|
|
55
|
-
warn "FAIL test-new: deploy doc must pin ~>
|
|
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", "~>
|
|
112
|
-
warn "FAIL test-new: Gemfile must pin pandorga ~>
|
|
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
|
-
|
|
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
|