pandorga 1.6.7 → 1.6.8

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +3 -2
  4. data/_includes/page/content-runtime.html +3 -0
  5. data/_includes/page/network-graph.html +5 -2
  6. data/_includes/page/timeline-section.html +13 -1
  7. data/_includes/theme/favicons.html +3 -1
  8. data/_layouts/articles.html +5 -2
  9. data/_layouts/bibliography.html +5 -2
  10. data/_layouts/blog.html +5 -2
  11. data/_layouts/cv.html +5 -2
  12. data/_layouts/home.html +10 -3
  13. data/_layouts/projects.html +5 -2
  14. data/_layouts/resources.html +5 -2
  15. data/_layouts/shell.html +6 -1
  16. data/assets/css/ledger/home.css +20 -0
  17. data/assets/css/ledger/rascunhos.css +40 -2
  18. data/assets/icons/apple-touch-icon.png +0 -0
  19. data/assets/icons/favicon-32.png +0 -0
  20. data/assets/icons/favicon.ico +0 -0
  21. data/assets/icons/favicon.svg +23 -0
  22. data/assets/icons/icon-192.png +0 -0
  23. data/assets/icons/icon-32.png +0 -0
  24. data/assets/icons/icon-512.png +0 -0
  25. data/assets/icons/icon-maskable-192.png +0 -0
  26. data/assets/icons/icon-maskable-512.png +0 -0
  27. data/assets/icons/studio-apple-touch-icon.png +0 -0
  28. data/assets/icons/studio-icon-192.png +0 -0
  29. data/assets/icons/studio-icon-512.png +0 -0
  30. data/assets/icons/studio-logo-32.png +0 -0
  31. data/assets/icons/studio-logo.ico +0 -0
  32. data/assets/icons/studio-logo.svg +25 -5
  33. data/assets/icons/studio-maskable-192.png +0 -0
  34. data/assets/icons/studio-maskable-512.png +0 -0
  35. data/lib/pandorga/jekyll/plugins/web_manifest.rb +81 -0
  36. data/lib/pandorga/version.rb +1 -1
  37. data/scripts/check_home_lists.mjs +209 -0
  38. data/scripts/lighthouse-home.sh +6 -0
  39. data/scripts/render-pwa-icons.py +141 -0
  40. data/scripts/test/test-home-articles.rb +30 -0
  41. data/scripts/test/test-home-lists.rb +74 -0
  42. data/scripts/test/test-pwa-icons.rb +280 -0
  43. data/scripts/test/test-rascunhos-licence.rb +43 -0
  44. data/scripts/validate.sh +4 -0
  45. data/studio/index.html +4 -1
  46. data/studio-app/index.html +4 -1
  47. metadata +24 -1
@@ -0,0 +1,209 @@
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, 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 waitForDevtools(port) {
94
+ for (let attempt = 0; attempt < 50; attempt += 1) {
95
+ try {
96
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`);
97
+ if (response.ok) return response.json();
98
+ } catch (_error) {
99
+ // Chrome is still starting.
100
+ }
101
+ await delay(100);
102
+ }
103
+ throw new Error('Chrome DevTools did not answer');
104
+ }
105
+
106
+ async function openSocket(url) {
107
+ const ws = new WebSocket(url);
108
+ await new Promise((resolve, reject) => {
109
+ ws.addEventListener('open', resolve, { once: true });
110
+ ws.addEventListener('error', () => reject(new Error('DevTools socket failed')), { once: true });
111
+ });
112
+ return ws;
113
+ }
114
+
115
+ async function navigate(browser, sessionId, url) {
116
+ const loaded = browser.once('Page.loadEventFired', sessionId);
117
+ await browser.send('Page.navigate', { url }, sessionId);
118
+ await Promise.race([
119
+ loaded,
120
+ delay(20000).then(() => {
121
+ throw new Error('home did not finish loading');
122
+ })
123
+ ]);
124
+ }
125
+
126
+ async function counts(browser, sessionId) {
127
+ const deadline = Date.now() + 15000;
128
+ let last = { projects: 0, articles: 0, posts: 0, projectsText: '', articlesText: '' };
129
+ while (Date.now() < deadline) {
130
+ const evaluated = await browser.send('Runtime.evaluate', {
131
+ expression: COUNTS,
132
+ returnByValue: true
133
+ }, sessionId);
134
+ if (evaluated.exceptionDetails) {
135
+ throw new Error('home list probe failed');
136
+ }
137
+ last = (evaluated.result && evaluated.result.value) || last;
138
+ if (last.projects > 0 && last.articles > 0 && last.posts > 0) return last;
139
+ await delay(200);
140
+ }
141
+ return last;
142
+ }
143
+
144
+ function filled(result) {
145
+ return result.projects > 0 && result.articles > 0 && result.posts > 0;
146
+ }
147
+
148
+ async function main() {
149
+ const profile = await mkdtemp(join(tmpdir(), 'pandorga-home-'));
150
+ const port = await freePort();
151
+ const chrome = spawn(chromePath, [
152
+ '--headless=new',
153
+ '--no-sandbox',
154
+ '--disable-gpu',
155
+ '--disable-dev-shm-usage',
156
+ '--no-first-run',
157
+ '--remote-allow-origins=*',
158
+ `--remote-debugging-port=${port}`,
159
+ `--user-data-dir=${profile}`,
160
+ 'about:blank'
161
+ ], { stdio: 'ignore' });
162
+
163
+ let failed = false;
164
+ try {
165
+ const version = await waitForDevtools(port);
166
+ const ws = await openSocket(version.webSocketDebuggerUrl);
167
+ const browser = new Cdp(ws);
168
+ const created = await browser.send('Target.createTarget', { url: 'about:blank' });
169
+ const attached = await browser.send('Target.attachToTarget', {
170
+ targetId: created.targetId,
171
+ flatten: true
172
+ });
173
+ const sessionId = attached.sessionId;
174
+ await browser.send('Page.enable', {}, sessionId);
175
+ await browser.send('Runtime.enable', {}, sessionId);
176
+
177
+ for (const viewport of VIEWPORTS) {
178
+ await browser.send('Emulation.setDeviceMetricsOverride', {
179
+ width: viewport.width,
180
+ height: viewport.height,
181
+ deviceScaleFactor: viewport.deviceScaleFactor,
182
+ mobile: viewport.mobile
183
+ }, sessionId);
184
+ await navigate(browser, sessionId, pageUrl);
185
+ const result = await counts(browser, sessionId);
186
+ console.log(
187
+ `home lists (${viewport.name}): projects=${result.projects} articles=${result.articles} posts=${result.posts}`
188
+ );
189
+ if (!filled(result)) {
190
+ console.error(`ERROR: ${viewport.name} home is missing project, article, or post items`);
191
+ console.error(`projects band: ${JSON.stringify(String(result.projectsText).slice(0, 180))}`);
192
+ console.error(`articles band: ${JSON.stringify(String(result.articlesText).slice(0, 180))}`);
193
+ failed = true;
194
+ }
195
+ }
196
+ ws.close();
197
+ } finally {
198
+ chrome.kill('SIGKILL');
199
+ await delay(100);
200
+ await rm(profile, { recursive: true, force: true });
201
+ }
202
+
203
+ if (failed) process.exit(1);
204
+ }
205
+
206
+ main().catch((error) => {
207
+ console.error(`ERROR: ${error.message}`);
208
+ process.exit(1);
209
+ });
@@ -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,74 @@
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 return when the runtime is missing") if home.include?("if (!runtime) return")
47
+
48
+ marked = ROOT.join("_includes/content-runtime/00-config.html").read
49
+ fail!("markdown library no longer defers") unless marked.include?("<script defer ")
50
+
51
+ shell = ROOT.join("_layouts/shell.html").read
52
+ fail!("text faces no longer wait for the first paint") unless shell.scan("requestAnimationFrame").length >= 2
53
+ fail!("icon subset link was removed") unless shell.include?("__pandorgaIconsHref")
54
+
55
+ spec = ROOT.join("docs/spec.md").read
56
+ fail!("spec is missing PLT-AC-26") unless spec.include?("PLT-AC-26")
57
+ fail!("spec no longer keeps the runtime after the page") unless spec.include?("runtime stays after the page")
58
+
59
+ lighthouse = ROOT.join("scripts/lighthouse-home.sh").read
60
+ checker = lighthouse.index("check_home_lists.mjs")
61
+ score = lighthouse.index("Lighthouse mobile (one run)")
62
+ fail!("lighthouse script does not load the home lists") unless checker
63
+ fail!("list check runs only after the score") unless score && checker < score
64
+
65
+ probe = ROOT.join("scripts/check_home_lists.mjs").read
66
+ fail!("probe ignores the project inventory") unless probe.include?("#home-projects .home-project-inventory-row")
67
+ fail!("probe ignores article cards") unless probe.include?("#home-articles .home-featured, #home-articles .ledger-card")
68
+ fail!("probe ignores the phone viewport") unless probe.include?("name: 'mobile'")
69
+ fail!("probe ignores the desktop viewport") unless probe.include?("name: 'desktop'")
70
+
71
+ _out, err, status = Open3.capture3("node", "--check", ROOT.join("scripts/check_home_lists.mjs").to_s)
72
+ fail!("home list probe is not valid JavaScript\n#{err}") unless status.success?
73
+
74
+ puts "PASS test-home-lists"