@yojahny/wp-design-library 0.5.1 → 0.6.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 (130) hide show
  1. package/bin/library.mjs +1 -0
  2. package/entries/ada/entry.md +24 -6
  3. package/entries/air/entry.md +39 -10
  4. package/entries/ais-community-dark-depth/entry.md +44 -19
  5. package/entries/apple-home/entry.md +43 -10
  6. package/entries/apple-macbook-neo/entry.md +39 -10
  7. package/entries/apple-watch-ultra-4/entry.md +36 -11
  8. package/entries/bpco/entry.md +15 -7
  9. package/entries/column/entry.md +22 -9
  10. package/entries/cthdrl/entry.md +41 -19
  11. package/entries/dala-craftedbygc/entry.md +41 -16
  12. package/entries/dock/entry.md +36 -10
  13. package/entries/dub/entry.md +35 -10
  14. package/entries/duolingo/entry.md +42 -10
  15. package/entries/elevenlabs/entry.md +42 -10
  16. package/entries/flying-papers/entry.md +32 -10
  17. package/entries/gsap/entry.md +40 -10
  18. package/entries/letters/entry.md +40 -10
  19. package/entries/micro/entry.md +34 -10
  20. package/entries/monopo-saigon/entry.md +41 -12
  21. package/entries/notion/entry.md +42 -10
  22. package/entries/origin-financial/entry.md +42 -9
  23. package/entries/oryzo-ai/entry.md +46 -11
  24. package/entries/silencio/entry.md +6 -2
  25. package/entries/steep/entry.md +38 -12
  26. package/entries/stripe/entry.md +34 -10
  27. package/entries/superpower/entry.md +43 -9
  28. package/entries/superr/entry.md +22 -6
  29. package/entries/svz-weare/entry.md +15 -5
  30. package/entries/thoughtlab/entry.md +42 -10
  31. package/entries/ventriloc/entry.md +22 -6
  32. package/entries/vivid-and-co/entry.md +30 -5
  33. package/package.json +4 -1
  34. package/src/artifacts.mjs +31 -0
  35. package/src/cli/add.mjs +55 -24
  36. package/src/cli/index.mjs +1 -1
  37. package/src/cli/migrate-artifacts.mjs +92 -0
  38. package/src/cli/motion.mjs +19 -39
  39. package/src/cli/refresh.mjs +40 -10
  40. package/src/cli/save.mjs +1 -1
  41. package/src/cli/serve.mjs +27 -7
  42. package/src/cli/ui.mjs +1 -1
  43. package/src/entry.mjs +15 -11
  44. package/src/index/build.mjs +13 -3
  45. package/src/ingest/agreement.mjs +14 -1
  46. package/src/ingest/attach.mjs +8 -15
  47. package/src/ingest/save.mjs +2 -2
  48. package/src/mcp/tools.mjs +29 -17
  49. package/src/r2.mjs +95 -0
  50. package/src/ui/build.mjs +30 -12
  51. package/src/ui/templates/index.html +2 -0
  52. package/entries/ada/strip.webp +0 -0
  53. package/entries/air/captures/2/measure.json +0 -325
  54. package/entries/air/captures/2/strip.webp +0 -0
  55. package/entries/air/strip.webp +0 -0
  56. package/entries/ais-community-dark-depth/strip.webp +0 -0
  57. package/entries/apple-home/captures/2/measure.json +0 -91
  58. package/entries/apple-home/captures/2/strip.webp +0 -0
  59. package/entries/apple-home/strip.webp +0 -0
  60. package/entries/apple-macbook-neo/captures/2/measure.json +0 -570
  61. package/entries/apple-macbook-neo/captures/2/strip.webp +0 -0
  62. package/entries/apple-macbook-neo/strip.webp +0 -0
  63. package/entries/apple-watch-ultra-4/measure.json +0 -481
  64. package/entries/apple-watch-ultra-4/strip.webp +0 -0
  65. package/entries/bpco/captures/2/strip.webp +0 -0
  66. package/entries/bpco/captures/3/strip.webp +0 -0
  67. package/entries/bpco/strip.webp +0 -0
  68. package/entries/column/captures/2/measure.json +0 -996
  69. package/entries/column/captures/2/strip.webp +0 -0
  70. package/entries/column/strip.webp +0 -0
  71. package/entries/cthdrl/captures/2/measure.json +0 -251
  72. package/entries/cthdrl/captures/2/strip.webp +0 -0
  73. package/entries/cthdrl/strip.webp +0 -0
  74. package/entries/dala-craftedbygc/strip.webp +0 -0
  75. package/entries/dock/captures/2/measure.json +0 -206
  76. package/entries/dock/captures/2/strip.webp +0 -0
  77. package/entries/dock/strip.webp +0 -0
  78. package/entries/dub/captures/2/measure.json +0 -37
  79. package/entries/dub/captures/2/strip.webp +0 -0
  80. package/entries/dub/strip.webp +0 -0
  81. package/entries/duolingo/captures/2/measure.json +0 -138
  82. package/entries/duolingo/captures/2/strip.webp +0 -0
  83. package/entries/duolingo/strip.webp +0 -0
  84. package/entries/elevenlabs/captures/2/measure.json +0 -160
  85. package/entries/elevenlabs/captures/2/strip.webp +0 -0
  86. package/entries/elevenlabs/strip.webp +0 -0
  87. package/entries/flying-papers/captures/2/measure.json +0 -291
  88. package/entries/flying-papers/captures/2/strip.webp +0 -0
  89. package/entries/flying-papers/strip.webp +0 -0
  90. package/entries/gsap/captures/2/measure.json +0 -120
  91. package/entries/gsap/captures/2/strip.webp +0 -0
  92. package/entries/gsap/strip.webp +0 -0
  93. package/entries/letters/captures/2/measure.json +0 -20
  94. package/entries/letters/captures/2/strip.webp +0 -0
  95. package/entries/letters/strip.webp +0 -0
  96. package/entries/micro/captures/2/measure.json +0 -852
  97. package/entries/micro/captures/2/strip.webp +0 -0
  98. package/entries/micro/strip.webp +0 -0
  99. package/entries/monopo-saigon/captures/2/measure.json +0 -145
  100. package/entries/monopo-saigon/captures/2/strip.webp +0 -0
  101. package/entries/monopo-saigon/strip.webp +0 -0
  102. package/entries/notion/captures/2/measure.json +0 -338
  103. package/entries/notion/captures/2/strip.webp +0 -0
  104. package/entries/notion/strip.webp +0 -0
  105. package/entries/origin-financial/captures/2/measure.json +0 -169
  106. package/entries/origin-financial/captures/2/strip.webp +0 -0
  107. package/entries/origin-financial/strip.webp +0 -0
  108. package/entries/oryzo-ai/captures/2/measure.json +0 -39
  109. package/entries/oryzo-ai/captures/2/strip.webp +0 -0
  110. package/entries/oryzo-ai/strip.webp +0 -0
  111. package/entries/silencio/captures/2/strip.webp +0 -0
  112. package/entries/silencio/strip.webp +0 -0
  113. package/entries/steep/captures/2/measure.json +0 -20
  114. package/entries/steep/captures/2/strip.webp +0 -0
  115. package/entries/steep/strip.webp +0 -0
  116. package/entries/stripe/captures/2/measure.json +0 -892
  117. package/entries/stripe/captures/2/strip.webp +0 -0
  118. package/entries/stripe/strip.webp +0 -0
  119. package/entries/superpower/captures/2/measure.json +0 -179
  120. package/entries/superpower/captures/2/strip.webp +0 -0
  121. package/entries/superpower/strip.webp +0 -0
  122. package/entries/superr/strip.webp +0 -0
  123. package/entries/svz-weare/captures/2/measure.json +0 -129
  124. package/entries/svz-weare/captures/2/strip.webp +0 -0
  125. package/entries/svz-weare/strip.webp +0 -0
  126. package/entries/thoughtlab/captures/2/measure.json +0 -93
  127. package/entries/thoughtlab/captures/2/strip.webp +0 -0
  128. package/entries/thoughtlab/strip.webp +0 -0
  129. package/entries/ventriloc/strip.webp +0 -0
  130. package/entries/vivid-and-co/strip.webp +0 -0
@@ -5,7 +5,9 @@ tier: inspiration
5
5
  source:
6
6
  kind: public-site
7
7
  url: https://www.superr.ai/
8
- license: Captured from a Refero-hosted recording and the live site; description only, no redistribution of the source.
8
+ license: >-
9
+ Captured from a Refero-hosted recording and the live site; description only, no redistribution of the
10
+ source.
9
11
  captured:
10
12
  method: frames
11
13
  at: '2026-09-16'
@@ -14,14 +16,23 @@ captured:
14
16
  - method: frames
15
17
  input: /tmp/superr-refero.mp4
16
18
  artifacts:
17
- - strip.webp
19
+ - key: entries/superr/1/strip.webp
20
+ bytes: 274670
21
+ sha256: aba7bdda980d83f1996a50f7a65cbd370aa0466bc802941c347c873b0c7371f3
18
22
  frames: 36
19
23
  duration_ms: 20217
20
24
  media:
21
25
  frames: 36
22
26
  duration_ms: 20217
23
- roles: [hero, explainer, feature]
24
- feel: [light, playful, warm, premium]
27
+ roles:
28
+ - hero
29
+ - explainer
30
+ - feature
31
+ feel:
32
+ - light
33
+ - playful
34
+ - warm
35
+ - premium
25
36
  palette:
26
37
  canvas: '#fdfbf9'
27
38
  ink: '#171717'
@@ -30,8 +41,13 @@ type:
30
41
  display: gelica, "gelica Fallback", Georgia, serif
31
42
  body: gelica, "gelica Fallback", Georgia, serif
32
43
  motion:
33
- devices: [reveal, pan]
34
- notes: Eyeballed from 36 sampled frames, not measured. A large notebook dominates the hero, then reveals handwritten labels, stickers, and an opened-book application scene. No scroll behavior or individual triggers are visible.
44
+ devices:
45
+ - reveal
46
+ - pan
47
+ notes: >-
48
+ Eyeballed from 36 sampled frames, not measured. A large notebook dominates the hero, then reveals
49
+ handwritten labels, stickers, and an opened-book application scene. No scroll behavior or individual
50
+ triggers are visible.
35
51
  ported_from: null
36
52
  ---
37
53
 
@@ -14,16 +14,26 @@ captured:
14
14
  - method: frames
15
15
  input: /home/yojahny/Desktop/inspiration/weare.mp4
16
16
  artifacts:
17
- - strip.webp
17
+ - key: entries/svz-weare/1/strip.webp
18
+ bytes: 219198
19
+ sha256: 98c48537b768405a47817cbf398892ce026d1202ff50d833916b26e780ba62d7
18
20
  frames: 36
19
21
  duration_ms: 21900
20
22
  - method: both
21
23
  input: https://www.svz.io/
22
24
  artifacts:
23
- - captures/2/strip.webp
24
- - captures/2/measure.json
25
- - captures/2/screen-1440.png
26
- - captures/2/screen-390.png
25
+ - key: entries/svz-weare/2/strip.webp
26
+ bytes: 163242
27
+ sha256: fc794258d46dc28bd7d905f19380c2941775996bcb48f7f49953028b3b361ec4
28
+ - key: entries/svz-weare/2/measure.json
29
+ bytes: 2499
30
+ sha256: c2291e55af1be7426047b2a55188db7fcbd951b98af439aa8ed26161b9fca1e9
31
+ - key: entries/svz-weare/2/screen-1440.png
32
+ bytes: 59470
33
+ sha256: 14abebdc6411792eff6afd1e5692693f857d3ae07363c4ee29a1f24bb8b83efa
34
+ - key: entries/svz-weare/2/screen-390.png
35
+ bytes: 20897
36
+ sha256: 54228ad1733da9a9b403e703e00906942ea2acdb768a092d7d8a42af84286c72
27
37
  media:
28
38
  frames: 36
29
39
  duration_ms: 21900
@@ -5,7 +5,9 @@ tier: inspiration
5
5
  source:
6
6
  kind: public-site
7
7
  url: https://www.thoughtlab.com/
8
- license: Captured from a Refero-hosted recording and the live site; description only, no redistribution of the source.
8
+ license: >-
9
+ Captured from a Refero-hosted recording and the live site; description only, no redistribution of the
10
+ source.
9
11
  captured:
10
12
  method: both
11
13
  at: '2026-09-16'
@@ -14,21 +16,42 @@ captured:
14
16
  - method: frames
15
17
  input: /tmp/thoughtlab-refero.mp4
16
18
  artifacts:
17
- - strip.webp
19
+ - key: entries/thoughtlab/1/strip.webp
20
+ bytes: 260958
21
+ sha256: 8d8e191dd3fecd18a394c54da3a275c90c78bffca607c87df5f1eca7dc052061
18
22
  frames: 36
19
23
  duration_ms: 24533
20
24
  - method: both
21
25
  input: https://www.thoughtlab.com/
22
26
  artifacts:
23
- - captures/2/strip.webp
24
- - captures/2/measure.json
25
- - captures/2/screen-1440.png
26
- - captures/2/screen-390.png
27
+ - key: entries/thoughtlab/2/strip.webp
28
+ bytes: 230142
29
+ sha256: 844c95156c28937ed5709b14bb47b28ec6d26e35a0703c8f39a5e93f5df7a82a
30
+ - key: entries/thoughtlab/2/measure.json
31
+ bytes: 1669
32
+ sha256: 033fcecae1af22f5c822f74fc3beff60c7fade187413fd0ad04e8dea2b7e42a4
33
+ - key: entries/thoughtlab/2/screen-1440.png
34
+ bytes: 145595
35
+ sha256: 1c36b785010a7de3d012b553a7029e5c5b7a57596edde81edf100323c152dc7f
36
+ - key: entries/thoughtlab/2/screen-390.png
37
+ bytes: 77652
38
+ sha256: 3d8dd9d72c2ae7a43cee2d62b5659a73b7d19f308297c4eed2fae551fa454310
27
39
  media:
28
40
  frames: 36
29
41
  duration_ms: 24533
30
- roles: [hero, feature, capability, proof, closing, footer]
31
- feel: [dark, premium, editorial, depth, cold]
42
+ roles:
43
+ - hero
44
+ - feature
45
+ - capability
46
+ - proof
47
+ - closing
48
+ - footer
49
+ feel:
50
+ - dark
51
+ - premium
52
+ - editorial
53
+ - depth
54
+ - cold
32
55
  palette:
33
56
  canvas: '#000000'
34
57
  ink: '#cccccc'
@@ -37,8 +60,17 @@ type:
37
60
  display: sui
38
61
  body: sui
39
62
  motion:
40
- devices: [reveal, pan, drift, wipe, tabs]
41
- notes: 'The supplied recording moves through a sequence of project names while irregular glass-like image portals change their internal imagery and edge light. Large condensed labels reveal beside each portal, then the composition wipes to the next case. The live capture confirms the hero portal and fixed header, but does not expose CSS animation records or individual input triggers.'
63
+ devices:
64
+ - reveal
65
+ - pan
66
+ - drift
67
+ - wipe
68
+ - tabs
69
+ notes: >-
70
+ The supplied recording moves through a sequence of project names while irregular glass-like image portals
71
+ change their internal imagery and edge light. Large condensed labels reveal beside each portal, then the
72
+ composition wipes to the next case. The live capture confirms the hero portal and fixed header, but does
73
+ not expose CSS animation records or individual input triggers.
42
74
  ported_from: null
43
75
  ---
44
76
 
@@ -5,7 +5,9 @@ tier: inspiration
5
5
  source:
6
6
  kind: public-site
7
7
  url: https://ventriloc.ca/en
8
- license: Captured from a Refero-hosted recording and the live site; description only, no redistribution of the source.
8
+ license: >-
9
+ Captured from a Refero-hosted recording and the live site; description only, no redistribution of the
10
+ source.
9
11
  captured:
10
12
  method: frames
11
13
  at: '2026-09-16'
@@ -14,14 +16,23 @@ captured:
14
16
  - method: frames
15
17
  input: /tmp/ventriloc-refero.mp4
16
18
  artifacts:
17
- - strip.webp
19
+ - key: entries/ventriloc/1/strip.webp
20
+ bytes: 225040
21
+ sha256: 7f9e5830cb7fbd754cc82132dba195e7fccec7c6893f5c7e6cf9200712e5de90
18
22
  frames: 36
19
23
  duration_ms: 6567
20
24
  media:
21
25
  frames: 36
22
26
  duration_ms: 6567
23
- roles: [hero, proof, feature]
24
- feel: [light, premium, saas, minimal]
27
+ roles:
28
+ - hero
29
+ - proof
30
+ - feature
31
+ feel:
32
+ - light
33
+ - premium
34
+ - saas
35
+ - minimal
25
36
  palette:
26
37
  canvas: '#f5f5f5'
27
38
  ink: '#202020'
@@ -30,8 +41,13 @@ type:
30
41
  display: PolySans, sans-serif
31
42
  body: Inter, sans-serif
32
43
  motion:
33
- devices: [reveal, pan]
34
- notes: Eyeballed from 36 sampled frames, not measured. Dashboard cards and chart lines animate into the right side of the hero while the headline stays anchored on the left; later sampled frames show the metric states changing. Individual triggers are not visible.
44
+ devices:
45
+ - reveal
46
+ - pan
47
+ notes: >-
48
+ Eyeballed from 36 sampled frames, not measured. Dashboard cards and chart lines animate into the right
49
+ side of the hero while the headline stays anchored on the left; later sampled frames show the metric
50
+ states changing. Individual triggers are not visible.
35
51
  ported_from: null
36
52
  ---
37
53
 
@@ -14,14 +14,28 @@ captured:
14
14
  - method: frames
15
15
  input: /home/yojahny/Desktop/inspiration/evivid.mp4
16
16
  artifacts:
17
- - strip.webp
17
+ - key: entries/vivid-and-co/1/strip.webp
18
+ bytes: 190158
19
+ sha256: 881005f80102d0e249067ce9aa89a61c6afaf576f4d5867687f2209081a3caa7
18
20
  frames: 36
19
21
  duration_ms: 36833
20
22
  media:
21
23
  frames: 36
22
24
  duration_ms: 36833
23
- roles: [hero, explainer, capability, proof, closing, footer]
24
- feel: [dark, premium, editorial, minimal, depth, cold]
25
+ roles:
26
+ - hero
27
+ - explainer
28
+ - capability
29
+ - proof
30
+ - closing
31
+ - footer
32
+ feel:
33
+ - dark
34
+ - premium
35
+ - editorial
36
+ - minimal
37
+ - depth
38
+ - cold
25
39
  palette:
26
40
  canvas: '#101010'
27
41
  ink: '#fffdf9'
@@ -30,8 +44,19 @@ type:
30
44
  display: '"Neue Montreal"'
31
45
  body: '"Neue Montreal", sans-serif'
32
46
  motion:
33
- devices: [reveal, pan, drift, stagger, wipe]
34
- notes: 'The supplied recording shows a refractive block assembly slowly rotating and drifting through the opening statements, while large type changes in staged fragments around it. Each chapter uses a restrained fade or wipe into a new text composition; the prismatic light field remains as a connecting layer. The live capture records a long sequence of SVG animations across the page. It confirms that the chromatic object and surrounding graphic treatment are animated, but this capture does not establish individual interaction triggers.'
47
+ devices:
48
+ - reveal
49
+ - pan
50
+ - drift
51
+ - stagger
52
+ - wipe
53
+ notes: >-
54
+ The supplied recording shows a refractive block assembly slowly rotating and drifting through the opening
55
+ statements, while large type changes in staged fragments around it. Each chapter uses a restrained fade or
56
+ wipe into a new text composition; the prismatic light field remains as a connecting layer. The live
57
+ capture records a long sequence of SVG animations across the page. It confirms that the chromatic object
58
+ and surrounding graphic treatment are animated, but this capture does not establish individual interaction
59
+ triggers.
35
60
  ported_from: null
36
61
  ---
37
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yojahny/wp-design-library",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Design reference corpus and MCP server for claude-wp-builder",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,10 +55,12 @@
55
55
  "docker-compose.yml",
56
56
  "docker/",
57
57
  "entries/",
58
+ "src/artifacts.mjs",
58
59
  "src/cli/add.mjs",
59
60
  "src/cli/check.mjs",
60
61
  "src/cli/export.mjs",
61
62
  "src/cli/index.mjs",
63
+ "src/cli/migrate-artifacts.mjs",
62
64
  "src/cli/motion.mjs",
63
65
  "src/cli/refresh.mjs",
64
66
  "src/cli/save.mjs",
@@ -70,6 +72,7 @@
70
72
  "src/mcp/",
71
73
  "src/motion.mjs",
72
74
  "src/paths.mjs",
75
+ "src/r2.mjs",
73
76
  "src/ui/",
74
77
  "src/vocab.mjs",
75
78
  "vocab.yaml",
@@ -0,0 +1,31 @@
1
+ // The seam every reader goes through. Callers ask for a file; whether it came from the cache or
2
+ // from R2 is not their business. The cache mirrors motion's .library/media so there is one place
3
+ // on disk where fetched bytes live.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { isArtifactKey, objectUrl, readConfig, sha256 } from './r2.mjs';
7
+
8
+ export function cachePath(p, key) {
9
+ if (!isArtifactKey(key)) throw new Error(`invalid artifact key: ${key}`);
10
+ return path.join(p.data, '.library', 'media', key);
11
+ }
12
+
13
+ // config is resolved lazily, inside the cache MISS, and through readConfig — which asks for
14
+ // R2_PUBLIC_BASE_URL and nothing else. As a default PARAMETER (`config = r2Config()`) it evaluated
15
+ // on entry, before the cache-hit branch below, so a warm cache was unusable without credentials
16
+ // and a read-only host had to hold R2_SECRET_ACCESS_KEY to render a thumbnail. Same bug class
17
+ // migrate-artifacts.mjs documents at length; the fix had not reached this file.
18
+ export async function artifactFile(p, artifact, { config, fetcher = fetch } = {}) {
19
+ const file = cachePath(p, artifact.key);
20
+ if (fs.existsSync(file) && fs.statSync(file).size === artifact.bytes) return file;
21
+ config ??= readConfig();
22
+ const url = objectUrl(config, artifact.key);
23
+ const response = await fetcher(url, { signal: AbortSignal.timeout(30000) });
24
+ if (!response.ok) throw new Error(`${artifact.key}: R2 returned HTTP ${response.status}`);
25
+ const body = Buffer.from(await response.arrayBuffer());
26
+ if (body.length !== artifact.bytes) throw new Error(`${artifact.key}: expected ${artifact.bytes} bytes, got ${body.length}`);
27
+ if (sha256(body) !== artifact.sha256) throw new Error(`${artifact.key}: digest does not match what the entry records`);
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ fs.writeFileSync(file, body);
30
+ return file;
31
+ }
package/src/cli/add.mjs CHANGED
@@ -8,6 +8,7 @@ import { attachCapture } from '../ingest/attach.mjs';
8
8
  import { measure } from '../ingest/measure.mjs';
9
9
  import { loadVocab, resolveTerm } from '../vocab.mjs';
10
10
  import { parseEntry } from '../entry.mjs';
11
+ import { artifactKey, putAndVerify, r2Config, s3Upload } from '../r2.mjs';
11
12
 
12
13
  function opt(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
13
14
 
@@ -40,9 +41,9 @@ async function stripFromFrames(framePaths, out) {
40
41
  }
41
42
 
42
43
  // URL/file:// input: measure the live page with system Chrome instead of sampling
43
- // frames from a recording. Scratch-dir-first, same as the frame path: a total
44
- // failure (nothing captured at all) must leave nothing under entries/.
45
- async function addFromUrl({ p, input, slug, title, source, dir, record, force, attach }) {
44
+ // frames from a recording. Scratch-dir-first, same as the frame path: nothing under
45
+ // entries/ is created OR destroyed until every byte is captured AND uploaded.
46
+ async function addFromUrl({ p, input, slug, title, source, dir, record, force, attach, config, upload, fetcher }) {
46
47
  const fs = await import('node:fs');
47
48
  const os = await import('node:os');
48
49
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'wpdl-add-'));
@@ -82,27 +83,43 @@ async function addFromUrl({ p, input, slug, title, source, dir, record, force, a
82
83
  await stripFromFrames(result.frames, scratchStrip);
83
84
  }
84
85
 
86
+ // Upload BEFORE the wipe, straight out of scratch. putAndVerify proves the object is publicly
87
+ // readable and byte-identical, so an entry can never name a key nobody can fetch — but it is a
88
+ // network round trip, and it throws for reasons this process does not control (absent
89
+ // credentials, a reset connection, a digest mismatch). Uploading after the wipe made every one
90
+ // of those a data-loss bug: `--force` deleted the entry and put nothing in its place.
91
+ // config/upload/fetcher are resolved lazily, here, and not as addEntry's defaults, so a refusal
92
+ // earlier in the flow never needs R2 credentials.
93
+ const names = ['strip.webp'];
94
+ if (result.ok) names.push('measure.json', 'screen-1440.png', 'screen-390.png');
95
+ const types = { '.webp': 'image/webp', '.png': 'image/png', '.json': 'application/json' };
96
+ const artifacts = [];
97
+ try {
98
+ config ??= r2Config();
99
+ if (!upload) upload = await s3Upload(config);
100
+ for (const name of names) {
101
+ const body = fs.readFileSync(path.join(scratch, name));
102
+ const key = artifactKey(slug, index, name);
103
+ artifacts.push(await putAndVerify({ config, key, body, contentType: types[path.extname(name)], upload, fetcher }));
104
+ }
105
+ } catch (err) {
106
+ fs.rmSync(scratch, { recursive: true, force: true });
107
+ throw err;
108
+ }
109
+
85
110
  // Gated on index, not force directly: index===1 is the same decision the attach-vs-recreate
86
111
  // branch below acts on, so the two can never disagree — a regression that breaks the line
87
112
  // above (force stops forcing index to 1) also stops this wipe from firing, instead of wiping
88
113
  // a directory out from under a capture the buggy code is about to attach to. It fires only
89
- // here, below the compositing, so nothing between measure() and a written strip can destroy
90
- // an existing entry and leave nothing in its place; what follows is copies out of scratch.
114
+ // here, below the compositing AND below the upload, so nothing that can fail stands between
115
+ // the wipe and a written entry; what follows is copies out of scratch.
91
116
  if (index === 1) fs.rmSync(dir, { recursive: true, force: true });
92
117
  const outDir = captureDir(dir, index);
93
118
  fs.mkdirSync(outDir, { recursive: true });
94
119
  const strip = path.join(outDir, 'strip.webp');
95
- fs.copyFileSync(scratchStrip, strip);
96
- if (result.ok) {
97
- for (const f of ['measure.json', 'screen-1440.png', 'screen-390.png']) {
98
- fs.copyFileSync(path.join(scratch, f), path.join(outDir, f));
99
- }
100
- }
120
+ for (const name of names) fs.copyFileSync(path.join(scratch, name), path.join(outDir, name));
101
121
  fs.rmSync(scratch, { recursive: true, force: true });
102
122
 
103
- const artifactNames = ['strip.webp'];
104
- if (result.ok) artifactNames.push('measure.json', 'screen-1440.png', 'screen-390.png');
105
- const artifacts = artifactNames.map((f) => path.relative(dir, path.join(outDir, f)));
106
123
  const capture = { method: captured.method, input, artifacts };
107
124
 
108
125
  if (index > 1) {
@@ -114,7 +131,7 @@ async function addFromUrl({ p, input, slug, title, source, dir, record, force, a
114
131
  return { dir: d.dir, strip, blanks: d.blanks, measured: result.ok };
115
132
  }
116
133
 
117
- export async function addEntry({ input, slug, title, source, force = false, frames, record = false, attach = false }) {
134
+ export async function addEntry({ input, slug, title, source, force = false, frames, record = false, attach = false, config, upload, fetcher = fetch }) {
118
135
  const p = paths();
119
136
  if (!input || !slug || !title || !source?.kind || !source?.license) throw new Error('add: input, slug, title, --source and --license are required');
120
137
  // Refused here (draft time), not first at save: resolveTerm is the single vocab gate
@@ -123,14 +140,14 @@ export async function addEntry({ input, slug, title, source, force = false, fram
123
140
  return { refused: `source.kind: unknown source term "${source.kind}"` };
124
141
  }
125
142
  const dir = path.join(p.entries, slug);
126
- const { mkdirSync, mkdtempSync, renameSync, copyFileSync, unlinkSync, rmSync } = await import('node:fs');
143
+ const { mkdirSync, mkdtempSync, renameSync, copyFileSync, unlinkSync, rmSync, readFileSync } = await import('node:fs');
127
144
  const os = await import('node:os');
128
145
  const exists = existsSync(path.join(dir, 'entry.md'));
129
146
  if (exists && !force && !attach) {
130
147
  return { refused: `entry ${slug} exists; pass attach to add a capture, or force to overwrite` };
131
148
  }
132
149
  if (URL_RE.test(input)) {
133
- return addFromUrl({ p, input, slug, title, source, dir, record, force, attach });
150
+ return addFromUrl({ p, input, slug, title, source, dir, record, force, attach, config, upload, fetcher });
134
151
  }
135
152
  // Sample into a scratch dir first: a refusal must leave nothing under entries/.
136
153
  const scratch = mkdtempSync(path.join(os.tmpdir(), 'wpdl-add-'));
@@ -146,13 +163,28 @@ export async function addEntry({ input, slug, title, source, force = false, fram
146
163
  // force always wipes and recreates as capture 1, exactly as it does today, regardless of
147
164
  // attach; only a plain attach (no force) merges onto the existing capture list.
148
165
  const index = (!force && attach) ? nextCaptureIndex(dir) : 1;
166
+ // Same upload-before-anything-is-destroyed contract as the URL path: the bytes are already in
167
+ // scratch, and putAndVerify's network round trip must not stand between the wipe and a written
168
+ // entry. Resolved lazily, after every refusal above, so those never need R2 credentials.
169
+ let stripArtifact;
170
+ try {
171
+ config ??= r2Config();
172
+ if (!upload) upload = await s3Upload(config);
173
+ stripArtifact = await putAndVerify({
174
+ config, key: artifactKey(slug, index, 'strip.webp'), body: readFileSync(scratchStrip),
175
+ contentType: 'image/webp', upload, fetcher,
176
+ });
177
+ } catch (err) {
178
+ rmSync(scratch, { recursive: true, force: true });
179
+ throw err;
180
+ }
181
+
149
182
  // Gated on index, not force directly: index===1 is the same decision the attach-vs-recreate
150
183
  // branch below acts on, so the two can never disagree — a regression that breaks the line
151
184
  // above (force stops forcing index to 1) also stops this wipe from firing, instead of wiping
152
185
  // a directory out from under a capture the buggy code is about to attach to. The wipe happens
153
- // only here — after sampleFrames has already succeeded into scratch (a sampling failure above
154
- // is already handled by the catch block) — so it can never destroy an existing entry and
155
- // leave nothing in its place.
186
+ // only here — after sampleFrames has succeeded into scratch AND after the upload has been
187
+ // verified — so it can never destroy an existing entry and leave nothing in its place.
156
188
  if (index === 1) rmSync(dir, { recursive: true, force: true });
157
189
  const outDir = captureDir(dir, index);
158
190
  mkdirSync(outDir, { recursive: true });
@@ -165,9 +197,8 @@ export async function addEntry({ input, slug, title, source, force = false, fram
165
197
  }
166
198
  rmSync(scratch, { recursive: true, force: true });
167
199
 
168
- const rel = path.relative(dir, strip);
169
200
  const capture = {
170
- method: 'frames', input, artifacts: [rel],
201
+ method: 'frames', input, artifacts: [stripArtifact],
171
202
  frames: media.frames, duration_ms: media.durationMs,
172
203
  };
173
204
  const mediaFields = { frames: media.frames, duration_ms: media.durationMs };
@@ -183,7 +214,7 @@ export async function addEntry({ input, slug, title, source, force = false, fram
183
214
  return { dir: d.dir, strip, blanks: d.blanks, frames: media.frames };
184
215
  }
185
216
 
186
- export async function addInbox({ source }) {
217
+ export async function addInbox({ source, config, upload, fetcher }) {
187
218
  const p = paths();
188
219
  const fs = await import('node:fs');
189
220
  const inbox = path.join(p.data, 'inbox');
@@ -204,7 +235,7 @@ export async function addInbox({ source }) {
204
235
  const kind = meta.kind ?? source?.kind, license = meta.license ?? source?.license;
205
236
  if (!kind || !license) { out.push({ file, slug, status: 'refused', reason: 'no source kind/license: add a <file>.json sidecar or pass --source and --license' }); continue; }
206
237
  try {
207
- const r = await addEntry({ input: full, slug, title: meta.title ?? slug.replace(/-/g, ' '), source: { kind, url: meta.url ?? source?.url, license } });
238
+ const r = await addEntry({ input: full, slug, title: meta.title ?? slug.replace(/-/g, ' '), source: { kind, url: meta.url ?? source?.url, license }, config, upload, fetcher });
208
239
  out.push(r.refused ? { file, slug, status: 'refused', reason: r.refused } : { file, slug, status: 'drafted', frames: r.frames });
209
240
  } catch (e) { out.push({ file, slug, status: 'refused', reason: e.message }); }
210
241
  }
package/src/cli/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { buildIndex } from '../index/build.mjs';
4
4
 
5
5
  export async function run() {
6
6
  const p = paths();
7
- const r = await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models });
7
+ const r = await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models, p });
8
8
  for (const m of r.refused) process.stderr.write(`refused ${m}\n`);
9
9
  process.stdout.write(`indexed ${r.entries} entries, refused: ${r.refused.length}, arms: ${r.arms.join(',')}\n`);
10
10
  for (const s of r.skipped) process.stderr.write(`embeddings: skipped (${s.reason})\n`);
@@ -0,0 +1,92 @@
1
+ // One-way move. Nothing is recomputed: agreement blocks, observed colours and captured.at record
2
+ // what was true at capture time, and re-deriving them to match today's files would rewrite history
3
+ // to fit the present. This only changes where the bytes live.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import yaml from 'js-yaml';
7
+ import { paths } from '../paths.mjs';
8
+ import { listEntries, parseEntry } from '../entry.mjs';
9
+ import { artifactKey, putAndVerify, r2Config, s3Upload, sha256 } from '../r2.mjs';
10
+
11
+ const TYPES = { '.webp': 'image/webp', '.png': 'image/png', '.json': 'application/json' };
12
+
13
+ // config is resolved lazily, only once we know a real upload is about to happen -- the same
14
+ // contract add.mjs already established (`config ??= r2Config()`, "so those never need R2
15
+ // credentials"). A `config = r2Config()` default *parameter* would evaluate eagerly on every
16
+ // call, --dry-run included, since JS runs a default expression whenever the argument is
17
+ // omitted; that would make a dry run impossible without credentials, defeating its purpose
18
+ // as a no-credentials preflight.
19
+ export async function migrateArtifacts({ p = paths(), config, upload, fetcher = fetch, dryRun = false } = {}) {
20
+ if (!dryRun) config ??= r2Config();
21
+ if (!upload && !dryRun) upload = await s3Upload(config);
22
+ // `records` carries every {key, bytes, sha256} the run produced, in both modes. Counts alone
23
+ // could not be diffed, which is the one thing a preflight is for -- and they left the dry run's
24
+ // digest unobservable, so nothing could tell a real hash from a placeholder.
25
+ const report = { entries: 0, uploaded: 0, skipped: 0, failed: [], records: [] };
26
+ for (const dir of listEntries(p.entries)) {
27
+ const entry = parseEntry(dir);
28
+ const slug = path.basename(dir);
29
+ // An entry written before captures[] existed has no field to read at all -- attach.mjs's
30
+ // existingCaptureFrom used to synthesize this on the fly ("migration writes them" is the
31
+ // comment left in its place), so this is the last point capture 1 can be recovered instead
32
+ // of leaving the entry permanently invalid. Only what is actually recorded is used: method
33
+ // (captured.method) and frames/duration_ms (media), if present. `input` is deliberately
34
+ // never invented -- source.url is a fair guess for a URL-sourced entry and actively wrong
35
+ // for a local-file one (media.frames/duration_ms without a recorded path), and validateEntry
36
+ // never reads capture.input, so omitting it honestly signals "not recorded".
37
+ const captured = entry.fm.captured ?? (entry.fm.captured = {});
38
+ if (captured.captures == null) {
39
+ const method = captured.method;
40
+ const artifacts = ['strip.webp'];
41
+ if (method === 'measured' || method === 'both') artifacts.push('measure.json', 'screen-1440.png', 'screen-390.png');
42
+ const capture = { method, artifacts };
43
+ if (entry.fm.media?.frames != null) capture.frames = entry.fm.media.frames;
44
+ if (entry.fm.media?.duration_ms != null) capture.duration_ms = entry.fm.media.duration_ms;
45
+ captured.captures = [capture];
46
+ }
47
+ const captures = captured.captures;
48
+ report.entries++;
49
+ try {
50
+ for (const [i, capture] of captures.entries()) {
51
+ const migrated = [];
52
+ for (const artifact of capture.artifacts ?? []) {
53
+ if (artifact && typeof artifact === 'object' && artifact.key) { migrated.push(artifact); report.skipped++; continue; }
54
+ const rel = String(artifact);
55
+ const file = path.join(dir, rel);
56
+ const body = fs.readFileSync(file);
57
+ const key = artifactKey(slug, i + 1, path.basename(rel));
58
+ // A dry run already has the bytes in hand, so it reports the real digest putAndVerify
59
+ // would record, not a placeholder -- a preflight worth diffing against the real run,
60
+ // which is why the record goes into the report and not only into the entry.
61
+ const record = dryRun
62
+ ? { key, bytes: body.length, sha256: sha256(body) }
63
+ : await putAndVerify({ config, key, body, contentType: TYPES[path.extname(rel)], upload, fetcher });
64
+ migrated.push(record);
65
+ report.records.push(record);
66
+ report.uploaded++;
67
+ }
68
+ capture.artifacts = migrated;
69
+ }
70
+ if (!dryRun) {
71
+ fs.writeFileSync(path.join(dir, 'entry.md'),
72
+ `---\n${yaml.dump(entry.fm, { lineWidth: 110, noRefs: true })}---\n\n${entry.body}\n`);
73
+ for (const capture of captures) {
74
+ for (const a of capture.artifacts) {
75
+ const local = path.join(dir, path.basename(a.key));
76
+ if (fs.existsSync(local)) fs.rmSync(local);
77
+ }
78
+ }
79
+ fs.rmSync(path.join(dir, 'captures'), { recursive: true, force: true });
80
+ }
81
+ } catch (err) {
82
+ report.failed.push({ slug, reason: err.message });
83
+ }
84
+ }
85
+ return report;
86
+ }
87
+
88
+ export async function run(args) {
89
+ const report = await migrateArtifacts({ dryRun: args.includes('--dry-run') });
90
+ process.stdout.write(JSON.stringify(report) + '\n');
91
+ return report.failed.length ? 1 : 0;
92
+ }