pandorga 1.6.7 → 1.6.9

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +3 -2
  4. data/_includes/content-runtime/40-listing.html +13 -5
  5. data/_includes/content-runtime/60-fragments.html +36 -10
  6. data/_includes/page/content-runtime.html +3 -0
  7. data/_includes/page/network-graph.html +5 -2
  8. data/_includes/page/timeline-section.html +13 -1
  9. data/_includes/theme/favicons.html +3 -1
  10. data/_layouts/articles.html +5 -2
  11. data/_layouts/bibliography.html +5 -2
  12. data/_layouts/blog.html +5 -2
  13. data/_layouts/cv.html +5 -2
  14. data/_layouts/home.html +54 -18
  15. data/_layouts/projects.html +5 -2
  16. data/_layouts/resources.html +5 -2
  17. data/_layouts/shell.html +6 -1
  18. data/assets/css/ledger/home.css +20 -0
  19. data/assets/css/ledger/rascunhos.css +40 -2
  20. data/assets/icons/apple-touch-icon.png +0 -0
  21. data/assets/icons/favicon-32.png +0 -0
  22. data/assets/icons/favicon.ico +0 -0
  23. data/assets/icons/favicon.svg +23 -0
  24. data/assets/icons/icon-192.png +0 -0
  25. data/assets/icons/icon-32.png +0 -0
  26. data/assets/icons/icon-512.png +0 -0
  27. data/assets/icons/icon-maskable-192.png +0 -0
  28. data/assets/icons/icon-maskable-512.png +0 -0
  29. data/assets/icons/studio-apple-touch-icon.png +0 -0
  30. data/assets/icons/studio-icon-192.png +0 -0
  31. data/assets/icons/studio-icon-512.png +0 -0
  32. data/assets/icons/studio-logo-32.png +0 -0
  33. data/assets/icons/studio-logo.ico +0 -0
  34. data/assets/icons/studio-logo.svg +25 -5
  35. data/assets/icons/studio-maskable-192.png +0 -0
  36. data/assets/icons/studio-maskable-512.png +0 -0
  37. data/lib/pandorga/jekyll/plugins/web_manifest.rb +81 -0
  38. data/lib/pandorga/version.rb +1 -1
  39. data/scripts/check_home_lists.mjs +237 -0
  40. data/scripts/lighthouse-home.sh +6 -0
  41. data/scripts/render-pwa-icons.py +141 -0
  42. data/scripts/test/test-home-articles.rb +30 -0
  43. data/scripts/test/test-home-lists.rb +83 -0
  44. data/scripts/test/test-pwa-icons.rb +280 -0
  45. data/scripts/test/test-rascunhos-licence.rb +43 -0
  46. data/scripts/validate.sh +4 -0
  47. data/studio/index.html +4 -1
  48. data/studio-app/index.html +4 -1
  49. metadata +24 -1
Binary file
Binary file
Binary file
Binary file
@@ -1,6 +1,26 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-hidden="true" focusable="false">
2
- <polygon fill="#a4c8da" points="16,1 24,5 13,14 2,17 2,9"/>
3
- <polygon fill="#95c8a6" points="24,5 30,9 30,23 22,28 19,18 13,14"/>
4
- <polygon fill="#ddd8a8" points="2,17 13,14 19,18 22,28 16,31 2,23"/>
5
- <polygon fill="none" stroke="#5e5d59" stroke-width="1" stroke-linejoin="miter" points="16,1 30,9 30,23 16,31 2,23 2,9"/>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Studio">
2
+ <title>Studio</title>
3
+ <style>
4
+ /* Same stamp and plate as the site icon. The small badge is the only
5
+ difference. It stays inside the maskable safe zone. */
6
+ .primary { fill: #a4c8da; }
7
+ .secondary { fill: #95c8a6; }
8
+ .tertiary { fill: #ddd8a8; }
9
+ .plate { fill: #5e5d59; }
10
+ .badge { fill: #b8b7ae; }
11
+ @media (prefers-color-scheme: dark) {
12
+ .primary { fill: #1c5a7a; }
13
+ .secondary { fill: #287445; }
14
+ .tertiary { fill: #746f30; }
15
+ .plate { fill: #b8b7ae; }
16
+ .badge { fill: #5e5d59; }
17
+ }
18
+ </style>
19
+ <rect class="plate" width="32" height="32"/>
20
+ <g transform="translate(7.68 7.68) scale(0.52)">
21
+ <polygon class="primary" points="0,0 23,0 15,17 0,20"/>
22
+ <polygon class="secondary" points="23,0 32,0 32,32 17,21 15,17"/>
23
+ <polygon class="tertiary" points="0,20 15,17 17,21 32,32 0,32"/>
24
+ </g>
25
+ <rect class="badge" x="20.32" y="20.32" width="2.88" height="2.88"/>
6
26
  </svg>
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+ require "json"
5
+
6
+ module Pandorga
7
+ module Jekyll
8
+ # Writes the web app manifests the shell and Studio link. The icons are
9
+ # theme assets. A site file at the same path overrides an icon; the
10
+ # manifest itself is generated so the name follows the site identity.
11
+ class WebManifestGenerator < ::Jekyll::Generator
12
+ safe true
13
+ priority :low
14
+
15
+ def generate(site)
16
+ emit(site, "site.webmanifest", site_manifest(site))
17
+ emit(site, "studio.webmanifest", studio_manifest)
18
+ end
19
+
20
+ private
21
+
22
+ def emit(site, name, payload)
23
+ page = ::Jekyll::PageWithoutAFile.new(site, site.source, "", name)
24
+ page.data["layout"] = nil
25
+ page.data["sitemap"] = false
26
+ page.content = "#{JSON.pretty_generate(payload)}\n"
27
+ site.pages << page
28
+ end
29
+
30
+ def site_manifest(site)
31
+ config = site.config
32
+ pandorga = config["pandorga"].is_a?(Hash) ? config["pandorga"] : {}
33
+ identity = pandorga["identity"].is_a?(Hash) ? pandorga["identity"] : {}
34
+ name = identity["name"].to_s.strip
35
+ name = config["title"].to_s.strip if name.empty?
36
+ name = "Site" if name.empty?
37
+ short = identity["short_name"].to_s.strip
38
+ short = name if short.empty?
39
+ manifest("/", name, short, studio: false)
40
+ end
41
+
42
+ def studio_manifest
43
+ manifest("/studio/", "Studio", "Studio", studio: true)
44
+ end
45
+
46
+ def manifest(start, name, short, studio:)
47
+ {
48
+ "id" => start,
49
+ "name" => name,
50
+ "short_name" => short,
51
+ "start_url" => start,
52
+ "scope" => start,
53
+ "display" => "standalone",
54
+ "background_color" => "#5e5d59",
55
+ "theme_color" => "#5e5d59",
56
+ "icons" => icons(studio: studio)
57
+ }
58
+ end
59
+
60
+ # Stems match scripts/render-pwa-icons.py. The site maskable file is
61
+ # icon-maskable-*.png. Studio's is studio-maskable-*.png.
62
+ def icons(studio:)
63
+ any_stem = studio ? "studio-icon" : "icon"
64
+ mask_stem = studio ? "studio-maskable" : "icon-maskable"
65
+ [
66
+ [512, any_stem, "any"],
67
+ [512, mask_stem, "maskable"],
68
+ [192, any_stem, "any"],
69
+ [192, mask_stem, "maskable"]
70
+ ].map do |size, stem, purpose|
71
+ {
72
+ "src" => "/assets/icons/#{stem}-#{size}.png",
73
+ "sizes" => "#{size}x#{size}",
74
+ "type" => "image/png",
75
+ "purpose" => purpose
76
+ }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.6.7"
4
+ VERSION = "1.6.9"
5
5
  end
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ // Load the built example home and fail when a listing band is still empty.
3
+ // The runtime fills those bands after the document loads. A score-only
4
+ // Lighthouse run treats an empty home as a fast page.
5
+
6
+ import { spawn } from 'node:child_process';
7
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
8
+ import { createServer } from 'node:net';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { setTimeout as delay } from 'node:timers/promises';
12
+
13
+ const args = process.argv.slice(2);
14
+
15
+ function flag(name) {
16
+ const index = args.indexOf(name);
17
+ if (index === -1 || !args[index + 1]) {
18
+ console.error(`ERROR: missing ${name}`);
19
+ process.exit(1);
20
+ }
21
+ return args[index + 1];
22
+ }
23
+
24
+ const chromePath = flag('--chrome');
25
+ const pageUrl = flag('--url');
26
+
27
+ const COUNTS = `({
28
+ projects: document.querySelectorAll('#home-projects .home-project-inventory-row').length,
29
+ articles: document.querySelectorAll('#home-articles .home-featured, #home-articles .ledger-card').length,
30
+ posts: document.querySelectorAll('#home-blog .home-draft-card').length,
31
+ projectsText: (document.querySelector('#home-projects') || {}).innerText || '',
32
+ articlesText: (document.querySelector('#home-articles') || {}).innerText || ''
33
+ })`;
34
+
35
+ const VIEWPORTS = [
36
+ { name: 'mobile', width: 390, height: 844, mobile: true, deviceScaleFactor: 2 },
37
+ { name: 'desktop', width: 1280, height: 800, mobile: false, deviceScaleFactor: 1 }
38
+ ];
39
+
40
+ function freePort() {
41
+ return new Promise((resolve, reject) => {
42
+ const server = createServer();
43
+ server.once('error', reject);
44
+ server.listen(0, '127.0.0.1', () => {
45
+ const { port } = server.address();
46
+ server.close(() => resolve(port));
47
+ });
48
+ });
49
+ }
50
+
51
+ class Cdp {
52
+ constructor(ws) {
53
+ this.ws = ws;
54
+ this.next = 1;
55
+ this.pending = new Map();
56
+ this.listeners = [];
57
+ ws.addEventListener('message', (event) => {
58
+ const message = JSON.parse(event.data);
59
+ if (message.id && this.pending.has(message.id)) {
60
+ const { resolve, reject } = this.pending.get(message.id);
61
+ this.pending.delete(message.id);
62
+ if (message.error) reject(new Error(message.error.message || JSON.stringify(message.error)));
63
+ else resolve(message.result || {});
64
+ return;
65
+ }
66
+ if (message.method) this.listeners.forEach((listen) => listen(message));
67
+ });
68
+ }
69
+
70
+ send(method, params = {}, sessionId) {
71
+ const id = this.next++;
72
+ const payload = { id, method, params };
73
+ if (sessionId) payload.sessionId = sessionId;
74
+ return new Promise((resolve, reject) => {
75
+ this.pending.set(id, { resolve, reject });
76
+ this.ws.send(JSON.stringify(payload));
77
+ });
78
+ }
79
+
80
+ once(method, sessionId) {
81
+ return new Promise((resolve) => {
82
+ const listen = (message) => {
83
+ if (message.method !== method) return;
84
+ if (sessionId && message.sessionId !== sessionId) return;
85
+ this.listeners = this.listeners.filter((entry) => entry !== listen);
86
+ resolve(message.params || {});
87
+ };
88
+ this.listeners.push(listen);
89
+ });
90
+ }
91
+ }
92
+
93
+ async function activePort(profile) {
94
+ try {
95
+ const text = await readFile(join(profile, 'DevToolsActivePort'), 'utf8');
96
+ const value = Number(text.split('\n')[0]);
97
+ return Number.isInteger(value) && value > 0 ? value : null;
98
+ } catch (_error) {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ async function waitForDevtools(port, chrome, profile) {
104
+ let stderr = '';
105
+ if (chrome.stderr) {
106
+ chrome.stderr.on('data', (chunk) => {
107
+ stderr += chunk;
108
+ });
109
+ }
110
+ for (let attempt = 0; attempt < 200; attempt += 1) {
111
+ if (chrome.exitCode !== null) {
112
+ throw new Error(`Chrome exited ${chrome.exitCode}: ${stderr.slice(-500)}`);
113
+ }
114
+ const opened = await activePort(profile);
115
+ const candidates = opened && opened !== port ? [port, opened] : [port];
116
+ for (const candidate of candidates) {
117
+ try {
118
+ const response = await fetch(`http://127.0.0.1:${candidate}/json/version`);
119
+ if (response.ok) return response.json();
120
+ } catch (_error) {
121
+ // Chrome is still starting.
122
+ }
123
+ }
124
+ await delay(100);
125
+ }
126
+ const opened = await activePort(profile);
127
+ throw new Error(
128
+ `Chrome DevTools did not answer on ${port}` +
129
+ `${opened ? ` (DevToolsActivePort ${opened})` : ''}. ${stderr.slice(-500)}`
130
+ );
131
+ }
132
+
133
+ async function openSocket(url) {
134
+ const ws = new WebSocket(url);
135
+ await new Promise((resolve, reject) => {
136
+ ws.addEventListener('open', resolve, { once: true });
137
+ ws.addEventListener('error', () => reject(new Error('DevTools socket failed')), { once: true });
138
+ });
139
+ return ws;
140
+ }
141
+
142
+ async function navigate(browser, sessionId, url) {
143
+ const loaded = browser.once('Page.loadEventFired', sessionId);
144
+ await browser.send('Page.navigate', { url }, sessionId);
145
+ await Promise.race([
146
+ loaded,
147
+ delay(20000).then(() => {
148
+ throw new Error('home did not finish loading');
149
+ })
150
+ ]);
151
+ }
152
+
153
+ async function counts(browser, sessionId) {
154
+ const deadline = Date.now() + 15000;
155
+ let last = { projects: 0, articles: 0, posts: 0, projectsText: '', articlesText: '' };
156
+ while (Date.now() < deadline) {
157
+ const evaluated = await browser.send('Runtime.evaluate', {
158
+ expression: COUNTS,
159
+ returnByValue: true
160
+ }, sessionId);
161
+ if (evaluated.exceptionDetails) {
162
+ throw new Error('home list probe failed');
163
+ }
164
+ last = (evaluated.result && evaluated.result.value) || last;
165
+ if (last.projects > 0 && last.articles > 0 && last.posts > 0) return last;
166
+ await delay(200);
167
+ }
168
+ return last;
169
+ }
170
+
171
+ function filled(result) {
172
+ return result.projects > 0 && result.articles > 0 && result.posts > 0;
173
+ }
174
+
175
+ async function main() {
176
+ const profile = await mkdtemp(join(tmpdir(), 'pandorga-home-'));
177
+ const port = await freePort();
178
+ const chrome = spawn(chromePath, [
179
+ '--headless=new',
180
+ '--no-sandbox',
181
+ '--disable-gpu',
182
+ '--disable-dev-shm-usage',
183
+ '--no-first-run',
184
+ '--remote-allow-origins=*',
185
+ '--remote-debugging-address=127.0.0.1',
186
+ `--remote-debugging-port=${port}`,
187
+ `--user-data-dir=${profile}`,
188
+ 'about:blank'
189
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
190
+
191
+ let failed = false;
192
+ try {
193
+ const version = await waitForDevtools(port, chrome, profile);
194
+ const ws = await openSocket(version.webSocketDebuggerUrl);
195
+ const browser = new Cdp(ws);
196
+ const created = await browser.send('Target.createTarget', { url: 'about:blank' });
197
+ const attached = await browser.send('Target.attachToTarget', {
198
+ targetId: created.targetId,
199
+ flatten: true
200
+ });
201
+ const sessionId = attached.sessionId;
202
+ await browser.send('Page.enable', {}, sessionId);
203
+ await browser.send('Runtime.enable', {}, sessionId);
204
+
205
+ for (const viewport of VIEWPORTS) {
206
+ await browser.send('Emulation.setDeviceMetricsOverride', {
207
+ width: viewport.width,
208
+ height: viewport.height,
209
+ deviceScaleFactor: viewport.deviceScaleFactor,
210
+ mobile: viewport.mobile
211
+ }, sessionId);
212
+ await navigate(browser, sessionId, pageUrl);
213
+ const result = await counts(browser, sessionId);
214
+ console.log(
215
+ `home lists (${viewport.name}): projects=${result.projects} articles=${result.articles} posts=${result.posts}`
216
+ );
217
+ if (!filled(result)) {
218
+ console.error(`ERROR: ${viewport.name} home is missing project, article, or post items`);
219
+ console.error(`projects band: ${JSON.stringify(String(result.projectsText).slice(0, 180))}`);
220
+ console.error(`articles band: ${JSON.stringify(String(result.articlesText).slice(0, 180))}`);
221
+ failed = true;
222
+ }
223
+ }
224
+ ws.close();
225
+ } finally {
226
+ chrome.kill('SIGKILL');
227
+ await delay(100);
228
+ await rm(profile, { recursive: true, force: true });
229
+ }
230
+
231
+ if (failed) process.exit(1);
232
+ }
233
+
234
+ main().catch((error) => {
235
+ console.error(`ERROR: ${error.message}`);
236
+ process.exit(1);
237
+ });
@@ -57,6 +57,12 @@ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
57
57
  done
58
58
  [[ "${ready}" == "1" ]] || fail "example server did not answer on port ${PORT}"
59
59
 
60
+ # Empty bands still score well. This fails in warn mode and in enforce mode.
61
+ step "Home lists after load (mobile and desktop)"
62
+ node "${ROOT}/scripts/check_home_lists.mjs" \
63
+ --chrome "${CHROME}" \
64
+ --url "http://127.0.0.1:${PORT}/"
65
+
60
66
  step "Lighthouse mobile (one run)"
61
67
  rm -f "${OUT}"
62
68
  npx --yes lighthouse@13.5.0 "http://127.0.0.1:${PORT}/" \
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """Rasterize the site and Studio launcher icons from the site stamp.
3
+
4
+ Both icons use the square mark in `_includes/theme/site-logo.html` and the
5
+ light-on-dark plate of `assets/icons/favicon.svg`: dark frame, pastel triad.
6
+ Studio adds a small plate in the lower right. Nothing important sits outside
7
+ the maskable safe zone (the center 80% circle). File names match
8
+ `lib/pandorga/jekyll/plugins/web_manifest.rb`. Requires Pillow.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ from PIL import Image, ImageDraw
17
+
18
+ # 32×32 square site stamp.
19
+ PRIMARY = [(0, 0), (23, 0), (15, 17), (0, 20)]
20
+ SECONDARY = [(23, 0), (32, 0), (32, 32), (17, 21), (15, 17)]
21
+ TERTIARY = [(0, 20), (15, 17), (17, 21), (32, 32), (0, 32)]
22
+
23
+ PLATE = (0x5E, 0x5D, 0x59)
24
+ FILL_PRIMARY = (0xA4, 0xC8, 0xDA)
25
+ FILL_SECONDARY = (0x95, 0xC8, 0xA6)
26
+ FILL_TERTIARY = (0xDD, 0xD8, 0xA8)
27
+ BADGE = (0xB8, 0xB7, 0xAE)
28
+
29
+ # The mark's box is 52% of the canvas. Its corners stay inside the 80% circle
30
+ # (half-diagonal 0.367 of the canvas, safe radius 0.40).
31
+ MARK_SCALE = 0.52
32
+ # Badge center, as an offset from the canvas center, and its side. The badge
33
+ # corners stay inside the same circle.
34
+ BADGE_OFFSET = 0.18
35
+ BADGE_SIDE = 0.09
36
+
37
+
38
+ def _polygon(points: list[tuple[int, int]], size: int) -> list[tuple[float, float]]:
39
+ span = size * MARK_SCALE
40
+ offset = (size - span) / 2
41
+ return [(offset + x / 32 * span, offset + y / 32 * span) for x, y in points]
42
+
43
+
44
+ def _ink_bounds(image: Image.Image) -> tuple[int, int, int, int]:
45
+ pixels = image.load()
46
+ width, height = image.size
47
+ min_x, min_y = width, height
48
+ max_x, max_y = -1, -1
49
+ for y in range(height):
50
+ for x in range(width):
51
+ if pixels[x, y] == PLATE:
52
+ continue
53
+ min_x = min(min_x, x)
54
+ min_y = min(min_y, y)
55
+ max_x = max(max_x, x)
56
+ max_y = max(max_y, y)
57
+ return (min_x, min_y, max_x, max_y)
58
+
59
+
60
+ def render(size: int, studio: bool) -> Image.Image:
61
+ image = Image.new("RGB", (size, size), PLATE)
62
+ draw = ImageDraw.Draw(image)
63
+ draw.polygon(_polygon(PRIMARY, size), fill=FILL_PRIMARY)
64
+ draw.polygon(_polygon(SECONDARY, size), fill=FILL_SECONDARY)
65
+ draw.polygon(_polygon(TERTIARY, size), fill=FILL_TERTIARY)
66
+ if studio:
67
+ # The badge may only replace stamp pixels. Painting the plate would
68
+ # grow the art and make Studio a different size on the home screen.
69
+ stamped = image.copy()
70
+ side = size * BADGE_SIDE
71
+ center = size * (0.5 + BADGE_OFFSET)
72
+ origin = center - side / 2
73
+ draw.rectangle([origin, origin, origin + side, origin + side], fill=BADGE)
74
+ source = stamped.load()
75
+ painted = image.load()
76
+ for y in range(size):
77
+ for x in range(size):
78
+ if source[x, y] == PLATE:
79
+ painted[x, y] = PLATE
80
+ return image
81
+
82
+
83
+ def main() -> None:
84
+ parser = argparse.ArgumentParser(description=__doc__)
85
+ parser.add_argument(
86
+ "--repo-root",
87
+ type=Path,
88
+ default=Path(__file__).resolve().parents[1],
89
+ )
90
+ args = parser.parse_args()
91
+ icons = args.repo_root / "assets" / "icons"
92
+ icons.mkdir(parents=True, exist_ok=True)
93
+
94
+ site = {
95
+ 512: ["icon-512.png", "icon-maskable-512.png"],
96
+ 192: ["icon-192.png", "icon-maskable-192.png"],
97
+ 180: ["apple-touch-icon.png"],
98
+ 32: ["icon-32.png", "favicon-32.png"],
99
+ }
100
+ studio = {
101
+ 512: ["studio-icon-512.png", "studio-maskable-512.png"],
102
+ 192: ["studio-icon-192.png", "studio-maskable-192.png"],
103
+ 180: ["studio-apple-touch-icon.png"],
104
+ 32: ["studio-logo-32.png"],
105
+ }
106
+ for size in site:
107
+ plain = render(size, studio=False)
108
+ marked = render(size, studio=True)
109
+ if plain.size != marked.size or _ink_bounds(plain) != _ink_bounds(marked):
110
+ raise SystemExit(
111
+ f"{size}px site and Studio art bounds differ: "
112
+ f"{plain.size} {_ink_bounds(plain)} vs {marked.size} {_ink_bounds(marked)}"
113
+ )
114
+ for name in site[size]:
115
+ plain.save(icons / name, "PNG")
116
+ for name in studio[size]:
117
+ marked.save(icons / name, "PNG")
118
+ icon32 = render(32, studio=False)
119
+ icon16 = render(16, studio=False)
120
+ studio32 = render(32, studio=True)
121
+ studio16 = render(16, studio=True)
122
+ if _ink_bounds(icon16) != _ink_bounds(studio16):
123
+ raise SystemExit("16px site and Studio art bounds differ")
124
+ icon32.save(
125
+ icons / "favicon.ico",
126
+ format="ICO",
127
+ sizes=[(16, 16), (32, 32)],
128
+ append_images=[icon16],
129
+ )
130
+ studio32.save(
131
+ icons / "studio-logo.ico",
132
+ format="ICO",
133
+ sizes=[(16, 16), (32, 32)],
134
+ append_images=[studio16],
135
+ )
136
+
137
+ print(f"Wrote launcher icons under {icons.relative_to(args.repo_root)}")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-27. Home Articles band: titles are 2pt larger, and on a phone the
5
+ # featured card has more room above its text. Listed cards keep their padding.
6
+
7
+ require "pathname"
8
+
9
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
10
+
11
+ def fail!(message)
12
+ warn "FAIL test-home-articles: #{message}"
13
+ exit 1
14
+ end
15
+
16
+ home = ROOT.join("assets/css/ledger/home.css").read
17
+
18
+ fail!("featured article title is not 2pt larger") unless home.include?(
19
+ '[data-home-kind="articles"] .home-featured-title'
20
+ ) && home.include?("calc(var(--home-featured-title-size) + 2pt)")
21
+
22
+ fail!("listed article titles are not 2pt larger") unless home.include?(
23
+ "calc(var(--type-card) + 2pt)"
24
+ ) && home.include?('[data-home-kind="articles"] .home-card-grid .card-title')
25
+
26
+ phone = home[/^@media \(max-width: 767px\) \{\n \[data-home-kind="articles"\] \.home-featured \{.*?\n \}/m]
27
+ fail!("phone featured card has no extra top padding") unless phone && phone.include?("padding-top:")
28
+ fail!("phone featured padding changes the listed cards") if phone.include?("ledger-card") || phone.include?("home-card-grid")
29
+
30
+ puts "PASS test-home-articles"
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # PLT-AC-26. Listing scripts follow the page, and the runtime stays after
5
+ # it. Those scripts have to start once the runtime is assigned. The
6
+ # Lighthouse script then loads the built example home and fails when the
7
+ # project, article, or post bands are still empty.
8
+
9
+ require "pathname"
10
+ require "open3"
11
+
12
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
13
+
14
+ def fail!(message)
15
+ warn "FAIL test-home-lists: #{message}"
16
+ exit 1
17
+ end
18
+
19
+ runtime = ROOT.join("_includes/page/content-runtime.html").read
20
+ assigned = runtime.index("window.ContentRuntime = (function () {")
21
+ dispatched = runtime.index("document.dispatchEvent(new CustomEvent('pandorga:runtime'))")
22
+ fail!("runtime does not assign ContentRuntime") unless assigned
23
+ fail!("runtime does not announce pandorga:runtime") unless dispatched
24
+ fail!("runtime announces itself before the assignment") unless dispatched > assigned
25
+ fail!("runtime moved back ahead of the page") unless ROOT.join("_layouts/shell.html").read.index("{{ content }}") <
26
+ ROOT.join("_layouts/shell.html").read.index("page/content-runtime.html")
27
+
28
+ pages = %w[
29
+ _layouts/home.html
30
+ _layouts/articles.html
31
+ _layouts/blog.html
32
+ _layouts/projects.html
33
+ _layouts/cv.html
34
+ _layouts/resources.html
35
+ _layouts/bibliography.html
36
+ _includes/page/network-graph.html
37
+ _includes/page/timeline-section.html
38
+ ]
39
+ pages.each do |relative|
40
+ text = ROOT.join(relative).read
41
+ fail!("#{relative} does not wait for pandorga:runtime") unless text.include?("pandorga:runtime")
42
+ end
43
+
44
+ home = ROOT.join("_layouts/home.html").read
45
+ fail!("home paint still runs before the runtime") unless home.scan("pandorga:runtime").length >= 2
46
+ fail!("home bands still fill in one turn") unless home.include?("scheduler.yield") && home.include?("await afterPaint()")
47
+ fail!("specimen measurement does not yield") unless home.include?("await measureTallestSpecimen()")
48
+ fail!("home still fills every band inside one callback") if home.include?("sections.forEach(function (section) { fillBand(section, index); })")
49
+ fragments = ROOT.join("_includes/content-runtime/60-fragments.html").read
50
+ fail!("home section markdown still runs in one turn") unless fragments.include?("function yieldToMain()") &&
51
+ fragments.include?("return yieldToMain().then(function () {")
52
+ listing = ROOT.join("_includes/content-runtime/40-listing.html").read
53
+ fail!("date formatting builds a new formatter per card") unless listing.include?("dateFormatters")
54
+
55
+ marked = ROOT.join("_includes/content-runtime/00-config.html").read
56
+ fail!("markdown library no longer defers") unless marked.include?("<script defer ")
57
+
58
+ shell = ROOT.join("_layouts/shell.html").read
59
+ fail!("text faces no longer wait for the first paint") unless shell.scan("requestAnimationFrame").length >= 2
60
+ fail!("icon subset link was removed") unless shell.include?("__pandorgaIconsHref")
61
+
62
+ spec = ROOT.join("docs/spec.md").read
63
+ fail!("spec is missing PLT-AC-26") unless spec.include?("PLT-AC-26")
64
+ fail!("spec no longer keeps the runtime after the page") unless spec.include?("runtime stays after the page")
65
+
66
+ lighthouse = ROOT.join("scripts/lighthouse-home.sh").read
67
+ checker = lighthouse.index("check_home_lists.mjs")
68
+ score = lighthouse.index("Lighthouse mobile (one run)")
69
+ fail!("lighthouse script does not load the home lists") unless checker
70
+ fail!("list check runs only after the score") unless score && checker < score
71
+
72
+ probe = ROOT.join("scripts/check_home_lists.mjs").read
73
+ fail!("probe ignores the project inventory") unless probe.include?("#home-projects .home-project-inventory-row")
74
+ fail!("probe ignores article cards") unless probe.include?("#home-articles .home-featured, #home-articles .ledger-card")
75
+ fail!("probe ignores the phone viewport") unless probe.include?("name: 'mobile'")
76
+ fail!("probe ignores the desktop viewport") unless probe.include?("name: 'desktop'")
77
+ fail!("probe does not pin DevTools to IPv4") unless probe.include?("--remote-debugging-address=127.0.0.1")
78
+ fail!("probe gives Chrome only a few seconds to open DevTools") unless probe.include?("attempt < 200")
79
+
80
+ _out, err, status = Open3.capture3("node", "--check", ROOT.join("scripts/check_home_lists.mjs").to_s)
81
+ fail!("home list probe is not valid JavaScript\n#{err}") unless status.success?
82
+
83
+ puts "PASS test-home-lists"