@yojahny/wp-design-library 0.3.0 → 0.5.1

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 (186) hide show
  1. package/.env.example +7 -0
  2. package/README.md +52 -9
  3. package/bin/library.mjs +11 -0
  4. package/entries/ada/entry.md +55 -0
  5. package/entries/ada/strip.webp +0 -0
  6. package/entries/air/captures/2/measure.json +325 -0
  7. package/entries/air/captures/2/strip.webp +0 -0
  8. package/entries/air/entry.md +62 -0
  9. package/entries/air/strip.webp +0 -0
  10. package/entries/ais-community-dark-depth/strip.webp +0 -0
  11. package/entries/apple-home/captures/2/measure.json +91 -0
  12. package/entries/apple-home/captures/2/strip.webp +0 -0
  13. package/entries/apple-home/entry.md +64 -0
  14. package/entries/apple-home/strip.webp +0 -0
  15. package/entries/apple-macbook-neo/captures/2/measure.json +570 -0
  16. package/entries/apple-macbook-neo/captures/2/strip.webp +0 -0
  17. package/entries/apple-macbook-neo/entry.md +62 -0
  18. package/entries/apple-macbook-neo/strip.webp +0 -0
  19. package/entries/apple-watch-ultra-4/entry.md +63 -0
  20. package/entries/apple-watch-ultra-4/measure.json +481 -0
  21. package/entries/apple-watch-ultra-4/strip.webp +0 -0
  22. package/entries/bpco/captures/2/strip.webp +0 -0
  23. package/entries/bpco/captures/3/strip.webp +0 -0
  24. package/entries/bpco/entry.md +81 -0
  25. package/entries/bpco/strip.webp +0 -0
  26. package/entries/column/captures/2/measure.json +996 -0
  27. package/entries/column/captures/2/strip.webp +0 -0
  28. package/entries/column/entry.md +82 -0
  29. package/entries/column/strip.webp +0 -0
  30. package/entries/cthdrl/captures/2/measure.json +251 -0
  31. package/entries/cthdrl/captures/2/strip.webp +0 -0
  32. package/entries/cthdrl/entry.md +86 -0
  33. package/entries/cthdrl/strip.webp +0 -0
  34. package/entries/dala-craftedbygc/entry.md +69 -0
  35. package/entries/dala-craftedbygc/strip.webp +0 -0
  36. package/entries/dock/captures/2/measure.json +206 -0
  37. package/entries/dock/captures/2/strip.webp +0 -0
  38. package/entries/dock/entry.md +62 -0
  39. package/entries/dock/strip.webp +0 -0
  40. package/entries/dub/captures/2/measure.json +37 -0
  41. package/entries/dub/captures/2/strip.webp +0 -0
  42. package/entries/dub/entry.md +61 -0
  43. package/entries/dub/strip.webp +0 -0
  44. package/entries/duolingo/captures/2/measure.json +138 -0
  45. package/entries/duolingo/captures/2/strip.webp +0 -0
  46. package/entries/duolingo/entry.md +65 -0
  47. package/entries/duolingo/strip.webp +0 -0
  48. package/entries/elevenlabs/captures/2/measure.json +160 -0
  49. package/entries/elevenlabs/captures/2/strip.webp +0 -0
  50. package/entries/elevenlabs/entry.md +62 -0
  51. package/entries/elevenlabs/strip.webp +0 -0
  52. package/entries/flying-papers/captures/2/measure.json +291 -0
  53. package/entries/flying-papers/captures/2/strip.webp +0 -0
  54. package/entries/flying-papers/entry.md +61 -0
  55. package/entries/flying-papers/strip.webp +0 -0
  56. package/entries/gsap/captures/2/measure.json +120 -0
  57. package/entries/gsap/captures/2/strip.webp +0 -0
  58. package/entries/gsap/entry.md +62 -0
  59. package/entries/gsap/strip.webp +0 -0
  60. package/entries/letters/captures/2/measure.json +20 -0
  61. package/entries/letters/captures/2/strip.webp +0 -0
  62. package/entries/letters/entry.md +62 -0
  63. package/entries/letters/strip.webp +0 -0
  64. package/entries/micro/captures/2/measure.json +852 -0
  65. package/entries/micro/captures/2/strip.webp +0 -0
  66. package/entries/micro/entry.md +61 -0
  67. package/entries/micro/strip.webp +0 -0
  68. package/entries/monopo-saigon/captures/2/measure.json +145 -0
  69. package/entries/monopo-saigon/captures/2/strip.webp +0 -0
  70. package/entries/monopo-saigon/entry.md +64 -0
  71. package/entries/monopo-saigon/strip.webp +0 -0
  72. package/entries/notion/captures/2/measure.json +338 -0
  73. package/entries/notion/captures/2/strip.webp +0 -0
  74. package/entries/notion/entry.md +66 -0
  75. package/entries/notion/strip.webp +0 -0
  76. package/entries/origin-financial/captures/2/measure.json +169 -0
  77. package/entries/origin-financial/captures/2/strip.webp +0 -0
  78. package/entries/origin-financial/entry.md +65 -0
  79. package/entries/origin-financial/strip.webp +0 -0
  80. package/entries/oryzo-ai/captures/2/measure.json +39 -0
  81. package/entries/oryzo-ai/captures/2/strip.webp +0 -0
  82. package/entries/oryzo-ai/entry.md +76 -0
  83. package/entries/oryzo-ai/strip.webp +0 -0
  84. package/entries/silencio/captures/2/strip.webp +0 -0
  85. package/entries/silencio/entry.md +114 -0
  86. package/entries/silencio/motion/product-story/frame-0.webp +0 -0
  87. package/entries/silencio/motion/product-story/frame-1.webp +0 -0
  88. package/entries/silencio/motion/product-story/frame-2.webp +0 -0
  89. package/entries/silencio/motion/product-story/frame-3.webp +0 -0
  90. package/entries/silencio/motion/product-story/frame-4.webp +0 -0
  91. package/entries/silencio/motion/product-story/frame-5.webp +0 -0
  92. package/entries/silencio/motion/product-story/frame-6.webp +0 -0
  93. package/entries/silencio/motion/product-story/frame-7.webp +0 -0
  94. package/entries/silencio/strip.webp +0 -0
  95. package/entries/steep/captures/2/measure.json +20 -0
  96. package/entries/steep/captures/2/strip.webp +0 -0
  97. package/entries/steep/entry.md +63 -0
  98. package/entries/steep/strip.webp +0 -0
  99. package/entries/stripe/captures/2/measure.json +892 -0
  100. package/entries/stripe/captures/2/strip.webp +0 -0
  101. package/entries/stripe/entry.md +61 -0
  102. package/entries/stripe/strip.webp +0 -0
  103. package/entries/superpower/captures/2/measure.json +179 -0
  104. package/entries/superpower/captures/2/strip.webp +0 -0
  105. package/entries/superpower/entry.md +64 -0
  106. package/entries/superpower/strip.webp +0 -0
  107. package/entries/superr/entry.md +54 -0
  108. package/entries/superr/strip.webp +0 -0
  109. package/entries/svz-weare/captures/2/measure.json +129 -0
  110. package/entries/svz-weare/captures/2/strip.webp +0 -0
  111. package/entries/svz-weare/entry.md +147 -0
  112. package/entries/svz-weare/motion/hero-transition/frame-0.webp +0 -0
  113. package/entries/svz-weare/motion/hero-transition/frame-1.webp +0 -0
  114. package/entries/svz-weare/motion/hero-transition/frame-2.webp +0 -0
  115. package/entries/svz-weare/motion/hero-transition/frame-3.webp +0 -0
  116. package/entries/svz-weare/motion/hero-transition/frame-4.webp +0 -0
  117. package/entries/svz-weare/motion/hero-transition/frame-5.webp +0 -0
  118. package/entries/svz-weare/motion/hero-transition/frame-6.webp +0 -0
  119. package/entries/svz-weare/motion/hero-transition/frame-7.webp +0 -0
  120. package/entries/svz-weare/strip.webp +0 -0
  121. package/entries/thoughtlab/captures/2/measure.json +93 -0
  122. package/entries/thoughtlab/captures/2/strip.webp +0 -0
  123. package/entries/thoughtlab/entry.md +62 -0
  124. package/entries/thoughtlab/strip.webp +0 -0
  125. package/entries/ventriloc/entry.md +54 -0
  126. package/entries/ventriloc/strip.webp +0 -0
  127. package/entries/vivid-and-co/entry.md +58 -0
  128. package/entries/vivid-and-co/strip.webp +0 -0
  129. package/package.json +24 -5
  130. package/src/cli/add.mjs +100 -24
  131. package/src/cli/motion.mjs +168 -0
  132. package/src/cli/refresh.mjs +6 -0
  133. package/src/cli/ui.mjs +2 -1
  134. package/src/entry.mjs +14 -1
  135. package/src/index/build.mjs +9 -3
  136. package/src/index/embed.mjs +1 -1
  137. package/src/ingest/agreement.mjs +33 -0
  138. package/src/ingest/attach.mjs +67 -0
  139. package/src/ingest/draft.mjs +5 -2
  140. package/src/ingest/frames.mjs +20 -6
  141. package/src/ingest/measure.mjs +34 -1
  142. package/src/ingest/observed.mjs +29 -0
  143. package/src/mcp/http.mjs +1 -1
  144. package/src/mcp/instructions.mjs +2 -1
  145. package/src/mcp/prompts.mjs +12 -1
  146. package/src/mcp/resources.mjs +14 -0
  147. package/src/mcp/tools.mjs +48 -5
  148. package/src/motion.mjs +94 -0
  149. package/src/ui/build.mjs +83 -7
  150. package/src/ui/serve.mjs +29 -4
  151. package/src/ui/templates/entry.html +9 -1
  152. package/entries/ais-community-dark-depth/strip.png +0 -0
  153. package/tests/README.md +0 -33
  154. package/tests/checks/cli-check.sh +0 -13
  155. package/tests/checks/docker.sh +0 -10
  156. package/tests/checks/entry-detail.sh +0 -52
  157. package/tests/checks/entry.sh +0 -55
  158. package/tests/checks/export.sh +0 -16
  159. package/tests/checks/fetch-on-start.sh +0 -26
  160. package/tests/checks/frames-dense.sh +0 -27
  161. package/tests/checks/http.sh +0 -22
  162. package/tests/checks/hygiene.sh +0 -29
  163. package/tests/checks/inbox.sh +0 -27
  164. package/tests/checks/index-degrade.sh +0 -32
  165. package/tests/checks/index.sh +0 -36
  166. package/tests/checks/ingest-mp4.sh +0 -47
  167. package/tests/checks/ingest.sh +0 -39
  168. package/tests/checks/instructions.sh +0 -31
  169. package/tests/checks/licence-gate.sh +0 -24
  170. package/tests/checks/mcp-stdout.sh +0 -31
  171. package/tests/checks/measure.sh +0 -79
  172. package/tests/checks/minors.sh +0 -84
  173. package/tests/checks/optional-deps.sh +0 -55
  174. package/tests/checks/prompt-add-entry.sh +0 -28
  175. package/tests/checks/recommend.sh +0 -107
  176. package/tests/checks/resources.sh +0 -47
  177. package/tests/checks/rrf.sh +0 -120
  178. package/tests/checks/seed-sync.sh +0 -22
  179. package/tests/checks/similar.sh +0 -62
  180. package/tests/checks/ui-build.sh +0 -59
  181. package/tests/checks/vocab.sh +0 -30
  182. package/tests/fixtures/entry-ok/entry.md +0 -41
  183. package/tests/fixtures/entry-ok/strip.png +0 -0
  184. package/tests/fixtures/page/index.html +0 -47
  185. package/tests/fixtures/three-frame.webp +0 -0
  186. package/tests/run.sh +0 -16
@@ -0,0 +1,93 @@
1
+ {
2
+ "url": "https://www.thoughtlab.com/",
3
+ "at": "2026-09-16",
4
+ "viewport": [
5
+ 1440,
6
+ 390
7
+ ],
8
+ "tokens": {
9
+ "canvas": "#000000",
10
+ "ink": "#cccccc",
11
+ "accent": "#ffffff",
12
+ "fontDisplay": "sui",
13
+ "fontBody": "sui"
14
+ },
15
+ "sections": [
16
+ {
17
+ "index": 0,
18
+ "tag": "header",
19
+ "id": "",
20
+ "class": "sh fix top left x pt30",
21
+ "top": 0,
22
+ "height": 72,
23
+ "animations": []
24
+ },
25
+ {
26
+ "index": 1,
27
+ "tag": "article",
28
+ "id": "home",
29
+ "class": "",
30
+ "top": 0,
31
+ "height": 8966,
32
+ "animations": []
33
+ },
34
+ {
35
+ "index": 2,
36
+ "tag": "section",
37
+ "id": "",
38
+ "class": "hero rel x",
39
+ "top": 0,
40
+ "height": 900,
41
+ "animations": []
42
+ },
43
+ {
44
+ "index": 3,
45
+ "tag": "section",
46
+ "id": "",
47
+ "class": "js-sel mb30 s:mb150",
48
+ "top": 900,
49
+ "height": 2700,
50
+ "animations": []
51
+ },
52
+ {
53
+ "index": 4,
54
+ "tag": "section",
55
+ "id": "",
56
+ "class": "caps mb60 s:mb150 rel",
57
+ "top": 4626,
58
+ "height": 626,
59
+ "animations": []
60
+ },
61
+ {
62
+ "index": 5,
63
+ "tag": "section",
64
+ "id": "",
65
+ "class": "news-list",
66
+ "top": 5469,
67
+ "height": 2219,
68
+ "animations": []
69
+ },
70
+ {
71
+ "index": 6,
72
+ "tag": "section",
73
+ "id": "",
74
+ "class": "h-contact",
75
+ "top": 7688,
76
+ "height": 819,
77
+ "animations": []
78
+ },
79
+ {
80
+ "index": 7,
81
+ "tag": "footer",
82
+ "id": "",
83
+ "class": "sf pb45 c-white",
84
+ "top": 8651,
85
+ "height": 315,
86
+ "animations": []
87
+ }
88
+ ],
89
+ "screenshots": {
90
+ "390": "screen-390.png",
91
+ "1440": "screen-1440.png"
92
+ }
93
+ }
@@ -0,0 +1,62 @@
1
+ ---
2
+ slug: thoughtlab
3
+ title: 'ThoughtLab: data-led consulting landing'
4
+ tier: inspiration
5
+ source:
6
+ kind: public-site
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.
9
+ captured:
10
+ method: both
11
+ at: '2026-09-16'
12
+ tool: wp-design-library@0.4.0
13
+ captures:
14
+ - method: frames
15
+ input: /tmp/thoughtlab-refero.mp4
16
+ artifacts:
17
+ - strip.webp
18
+ frames: 36
19
+ duration_ms: 24533
20
+ - method: both
21
+ input: https://www.thoughtlab.com/
22
+ artifacts:
23
+ - captures/2/strip.webp
24
+ - captures/2/measure.json
25
+ - captures/2/screen-1440.png
26
+ - captures/2/screen-390.png
27
+ media:
28
+ frames: 36
29
+ duration_ms: 24533
30
+ roles: [hero, feature, capability, proof, closing, footer]
31
+ feel: [dark, premium, editorial, depth, cold]
32
+ palette:
33
+ canvas: '#000000'
34
+ ink: '#cccccc'
35
+ accent: '#ff2257'
36
+ type:
37
+ display: sui
38
+ body: sui
39
+ 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.'
42
+ ported_from: null
43
+ ---
44
+
45
+ ## What it does
46
+
47
+ ThoughtLab presents category-first brand and product work as a dark portfolio sequence. One large organic portal acts as a moving canvas for selected cases, followed by capability, news, and contact sections.
48
+
49
+ ## Section roster
50
+
51
+ 1. Fixed logo, partnership claim, contact action, and dark hero with an irregular reflective portal.
52
+ 2. Selected-work sequence cycling Wander Jaunt, Varex Imaging, Capsule House, and other case imagery.
53
+ 3. Capability statement and proof/content sections.
54
+ 4. News list, contact prompt, and compact footer.
55
+
56
+ ## Why it works
57
+
58
+ The evolving portal gives every case a shared visual frame while keeping the page sparse. Black space, silver type, and a single hot-pink action make the content feel like a studio reel instead of a conventional agency grid.
59
+
60
+ ## Sources
61
+
62
+ The Refero MP4 at https://images.refero.design/styles/refero.design/video/504bf76a-aab7-459c-9496-1f520fd0324a.mp4 provides the 24.53-second project sequence and portal transitions. The live capture at https://www.thoughtlab.com/ supplies current desktop/mobile layout and measured tokens. Both sources show the same dark portfolio direction.
Binary file
@@ -0,0 +1,54 @@
1
+ ---
2
+ slug: ventriloc
3
+ title: 'Ventriloc: business intelligence platform'
4
+ tier: inspiration
5
+ source:
6
+ kind: public-site
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.
9
+ captured:
10
+ method: frames
11
+ at: '2026-09-16'
12
+ tool: wp-design-library@0.4.0
13
+ captures:
14
+ - method: frames
15
+ input: /tmp/ventriloc-refero.mp4
16
+ artifacts:
17
+ - strip.webp
18
+ frames: 36
19
+ duration_ms: 6567
20
+ media:
21
+ frames: 36
22
+ duration_ms: 6567
23
+ roles: [hero, proof, feature]
24
+ feel: [light, premium, saas, minimal]
25
+ palette:
26
+ canvas: '#f5f5f5'
27
+ ink: '#202020'
28
+ accent: '#828282'
29
+ type:
30
+ display: PolySans, sans-serif
31
+ body: Inter, sans-serif
32
+ 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.
35
+ ported_from: null
36
+ ---
37
+
38
+ ## What it does
39
+
40
+ Ventriloc presents business intelligence as a clean analytics-led growth service.
41
+
42
+ ## Section roster
43
+
44
+ 1. Split hero with analytics promise, actions, and animated chart cards.
45
+ 2. Partner-logo proof row.
46
+ 3. Dashboard metric demonstration.
47
+
48
+ ## Why it works
49
+
50
+ The restrained page gives its charts plenty of space, making the data cards feel like evidence rather than visual filler. The minimal treatment asks the headline to carry most of the personality.
51
+
52
+ ## Sources
53
+
54
+ The Refero MP4 supplies the sampled motion sequence. https://ventriloc.ca/en remains the source URL; its incomplete live render was not retained as a capture.
Binary file
@@ -0,0 +1,58 @@
1
+ ---
2
+ slug: vivid-and-co
3
+ title: 'Vivid & Co.: refractive creative-studio landing'
4
+ tier: inspiration
5
+ source:
6
+ kind: public-site
7
+ url: https://vividand.co/
8
+ license: Captured from the supplied recording and live site; description only, no redistribution of the source.
9
+ captured:
10
+ method: frames
11
+ at: '2026-09-16'
12
+ tool: wp-design-library@0.4.0
13
+ captures:
14
+ - method: frames
15
+ input: /home/yojahny/Desktop/inspiration/evivid.mp4
16
+ artifacts:
17
+ - strip.webp
18
+ frames: 36
19
+ duration_ms: 36833
20
+ media:
21
+ frames: 36
22
+ duration_ms: 36833
23
+ roles: [hero, explainer, capability, proof, closing, footer]
24
+ feel: [dark, premium, editorial, minimal, depth, cold]
25
+ palette:
26
+ canvas: '#101010'
27
+ ink: '#fffdf9'
28
+ accent: '#67d8ff'
29
+ type:
30
+ display: '"Neue Montreal"'
31
+ body: '"Neue Montreal", sans-serif'
32
+ 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.'
35
+ ported_from: null
36
+ ---
37
+
38
+ ## What it does
39
+
40
+ Vivid & Co. introduces a full-service agency and strategic advisor through large, declarative statements instead of a project grid. A black, lightly textured field carries minimal white type, small proof details, and a recurring translucent object whose rainbow edges add movement and depth.
41
+
42
+ ## Section roster
43
+
44
+ 1. Compact header with expertise, work, team, careers, and contact navigation.
45
+ 2. Hero that introduces Vivid & Co. beside the refractive block assembly.
46
+ 3. Brand-value statement and a large argument for integrated strategy, PR, and creative identity.
47
+ 4. Expertise and process sections with sparse text blocks, sector labels, and the moving object.
48
+ 5. Client and press proof row.
49
+ 6. Team introduction, people profile, advisor/investor list, and careers close.
50
+ 7. Contact-led footer.
51
+
52
+ ## Why it works
53
+
54
+ The interface has the pacing of a short title sequence. Each large statement gets an uncluttered frame, then the page uses small supporting copy and hairline dividers to reset the scale. The glass-like object concentrates the site’s color, so the rest of the layout can stay almost monochrome without feeling flat.
55
+
56
+ ## Sources
57
+
58
+ The supplied 36.83-second recording provides the complete desktop sequence, including the changing opening statements, object movement, expertise/process copy, proof row, team profile, advisors, and closing. The live capture at https://vividand.co/ supplies the current desktop and mobile layouts, measured type/tokens, and the page-wide SVG animation evidence. Both sources show the same black editorial direction and prismatic-object treatment.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yojahny/wp-design-library",
3
- "version": "0.3.0",
3
+ "version": "0.5.1",
4
4
  "description": "Design reference corpus and MCP server for claude-wp-builder",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,8 +55,27 @@
55
55
  "docker-compose.yml",
56
56
  "docker/",
57
57
  "entries/",
58
- "src/",
59
- "tests/",
60
- "vocab.yaml"
61
- ]
58
+ "src/cli/add.mjs",
59
+ "src/cli/check.mjs",
60
+ "src/cli/export.mjs",
61
+ "src/cli/index.mjs",
62
+ "src/cli/motion.mjs",
63
+ "src/cli/refresh.mjs",
64
+ "src/cli/save.mjs",
65
+ "src/cli/serve.mjs",
66
+ "src/cli/ui.mjs",
67
+ "src/entry.mjs",
68
+ "src/index/",
69
+ "src/ingest/",
70
+ "src/mcp/",
71
+ "src/motion.mjs",
72
+ "src/paths.mjs",
73
+ "src/ui/",
74
+ "src/vocab.mjs",
75
+ "vocab.yaml",
76
+ "!entries/**/screen-*.png"
77
+ ],
78
+ "devDependencies": {
79
+ "@aws-sdk/client-s3": "^3.1134.0"
80
+ }
62
81
  }
package/src/cli/add.mjs CHANGED
@@ -1,29 +1,48 @@
1
1
  import path from 'node:path';
2
+ import { existsSync } from 'node:fs';
2
3
  import sharp from 'sharp';
3
4
  import { paths } from '../paths.mjs';
4
- import { sampleFrames, compositeStrip } from '../ingest/frames.mjs';
5
+ import { sampleFrames, compositeStrip, writeStrip } from '../ingest/frames.mjs';
5
6
  import { writeDraft } from '../ingest/draft.mjs';
7
+ import { attachCapture } from '../ingest/attach.mjs';
6
8
  import { measure } from '../ingest/measure.mjs';
7
9
  import { loadVocab, resolveTerm } from '../vocab.mjs';
10
+ import { parseEntry } from '../entry.mjs';
8
11
 
9
12
  function opt(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
10
13
 
14
+ // captures/<n>/ where n is the new capture's 1-based position. Capture 1 has no directory —
15
+ // its files stay at the entry root, so existing entries and get_entry's `strip` are untouched.
16
+ function captureDir(dir, index) {
17
+ return index === 1 ? dir : path.join(dir, 'captures', String(index));
18
+ }
19
+
20
+ // The new capture's 1-based position. An entry written before captures[] existed has one
21
+ // capture recorded in its top-level fields, so it counts as 1 and the new one is 2. An entry
22
+ // that EXISTS can never yield 1: index===1 is the wipe trigger, so a hand-written
23
+ // `captures: []` would otherwise turn --attach into --force and destroy the entry.
24
+ function nextCaptureIndex(dir) {
25
+ if (!existsSync(path.join(dir, 'entry.md'))) return 1;
26
+ const { fm } = parseEntry(dir);
27
+ return Math.max(fm.captured?.captures?.length ?? 1, 1) + 1;
28
+ }
29
+
11
30
  const MEDIA = /\.(webp|gif|mp4|mov|webm|mkv)$/i;
12
31
  const URL_RE = /^(https?:\/\/|file:\/\/)/;
13
32
  function slugify(name) { return name.replace(/\.[^.]+$/, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }
14
33
 
15
- // Composites recorded scroll-stop screenshots into a strip.png the same way sampleFrames
34
+ // Composites recorded scroll-stop screenshots into a strip.webp the same way sampleFrames
16
35
  // does for video/animated-image input.
17
- async function stripFromFrames(framePaths, outPng) {
36
+ async function stripFromFrames(framePaths, out) {
18
37
  const tiles = await Promise.all(framePaths.map((f) => sharp(f).png().toBuffer()));
19
38
  const first = await sharp(framePaths[0]).metadata();
20
- await compositeStrip(tiles, first.width, first.height, outPng);
39
+ await compositeStrip(tiles, first.width, first.height, out);
21
40
  }
22
41
 
23
42
  // URL/file:// input: measure the live page with system Chrome instead of sampling
24
43
  // frames from a recording. Scratch-dir-first, same as the frame path: a total
25
44
  // failure (nothing captured at all) must leave nothing under entries/.
26
- async function addFromUrl({ p, input, slug, title, source, dir, record }) {
45
+ async function addFromUrl({ p, input, slug, title, source, dir, record, force, attach }) {
27
46
  const fs = await import('node:fs');
28
47
  const os = await import('node:os');
29
48
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'wpdl-add-'));
@@ -41,32 +60,61 @@ async function addFromUrl({ p, input, slug, title, source, dir, record }) {
41
60
 
42
61
  const today = new Date().toISOString().slice(0, 10);
43
62
  const tool = `wp-design-library@${p.version}`;
44
- fs.mkdirSync(dir, { recursive: true });
45
- const strip = path.join(dir, 'strip.png');
63
+ // force always wipes and recreates as capture 1, exactly as it does today, regardless of
64
+ // attach; only a plain attach (no force) merges onto the existing capture list.
65
+ const index = (!force && attach) ? nextCaptureIndex(dir) : 1;
66
+ // Composite into scratch first, same as the frame path: every byte of the new capture has to
67
+ // exist before anything under entries/ is touched. sharp can fail on a recorded frame well
68
+ // after measure() returned usable data, and the wipe below is unrecoverable.
69
+ const scratchStrip = path.join(scratch, 'strip.webp');
46
70
  let captured, palette, type;
47
71
  if (result.ok) {
48
72
  captured = { method: record ? 'both' : 'measured', at: today, tool };
49
73
  const t = result.measure.tokens;
50
74
  palette = { canvas: t.canvas || '', ink: t.ink || '', accent: t.accent || '' };
51
75
  type = { display: t.fontDisplay || '', body: t.fontBody || '' };
52
- if (record && result.frames?.length) await stripFromFrames(result.frames, strip);
53
- else fs.copyFileSync(result.screenshots[1440], strip);
54
- for (const f of ['measure.json', 'screen-1440.png', 'screen-390.png']) {
55
- fs.copyFileSync(path.join(scratch, f), path.join(dir, f));
56
- }
76
+ if (record && result.frames?.length) await stripFromFrames(result.frames, scratchStrip);
77
+ else await writeStrip(sharp(result.screenshots[1440]), scratchStrip);
57
78
  } else {
58
79
  // record succeeded but the token/section measurement itself failed: keep the
59
80
  // frames (spec §11), label the method accordingly, and record why.
60
81
  captured = { method: 'frames', at: today, tool, error: result.error };
61
- await stripFromFrames(result.frames, strip);
82
+ await stripFromFrames(result.frames, scratchStrip);
83
+ }
84
+
85
+ // Gated on index, not force directly: index===1 is the same decision the attach-vs-recreate
86
+ // branch below acts on, so the two can never disagree — a regression that breaks the line
87
+ // above (force stops forcing index to 1) also stops this wipe from firing, instead of wiping
88
+ // 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.
91
+ if (index === 1) fs.rmSync(dir, { recursive: true, force: true });
92
+ const outDir = captureDir(dir, index);
93
+ fs.mkdirSync(outDir, { recursive: true });
94
+ 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
+ }
62
100
  }
63
101
  fs.rmSync(scratch, { recursive: true, force: true });
64
102
 
65
- const d = writeDraft({ entriesDir: p.entries, slug, title, source: { ...source, url: source.url ?? input }, media: {}, captured, palette, type });
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
+ const capture = { method: captured.method, input, artifacts };
107
+
108
+ if (index > 1) {
109
+ const r = await attachCapture({ entriesDir: p.entries, slug, capture, palette, type, sourceUrl: input, tool });
110
+ return { dir: r.dir, strip, captures: r.captures.length, measured: result.ok };
111
+ }
112
+
113
+ const d = writeDraft({ entriesDir: p.entries, slug, title, source: { ...source, url: source.url ?? input }, media: {}, captured, palette, type, captures: [capture] });
66
114
  return { dir: d.dir, strip, blanks: d.blanks, measured: result.ok };
67
115
  }
68
116
 
69
- export async function addEntry({ input, slug, title, source, force = false, frames, record = false }) {
117
+ export async function addEntry({ input, slug, title, source, force = false, frames, record = false, attach = false }) {
70
118
  const p = paths();
71
119
  if (!input || !slug || !title || !source?.kind || !source?.license) throw new Error('add: input, slug, title, --source and --license are required');
72
120
  // Refused here (draft time), not first at save: resolveTerm is the single vocab gate
@@ -75,17 +123,18 @@ export async function addEntry({ input, slug, title, source, force = false, fram
75
123
  return { refused: `source.kind: unknown source term "${source.kind}"` };
76
124
  }
77
125
  const dir = path.join(p.entries, slug);
78
- const { mkdirSync, mkdtempSync, existsSync, renameSync, copyFileSync, unlinkSync, rmSync } = await import('node:fs');
126
+ const { mkdirSync, mkdtempSync, renameSync, copyFileSync, unlinkSync, rmSync } = await import('node:fs');
79
127
  const os = await import('node:os');
80
- if (existsSync(path.join(dir, 'entry.md')) && !force) {
81
- return { refused: `entry ${slug} exists; pass force to overwrite` };
128
+ const exists = existsSync(path.join(dir, 'entry.md'));
129
+ if (exists && !force && !attach) {
130
+ return { refused: `entry ${slug} exists; pass attach to add a capture, or force to overwrite` };
82
131
  }
83
132
  if (URL_RE.test(input)) {
84
- return addFromUrl({ p, input, slug, title, source, dir, record });
133
+ return addFromUrl({ p, input, slug, title, source, dir, record, force, attach });
85
134
  }
86
135
  // Sample into a scratch dir first: a refusal must leave nothing under entries/.
87
136
  const scratch = mkdtempSync(path.join(os.tmpdir(), 'wpdl-add-'));
88
- const scratchStrip = path.join(scratch, 'strip.png');
137
+ const scratchStrip = path.join(scratch, 'strip.webp');
89
138
  let media;
90
139
  try {
91
140
  media = await sampleFrames(input, scratchStrip, { frames });
@@ -94,8 +143,20 @@ export async function addEntry({ input, slug, title, source, force = false, fram
94
143
  if (err.message?.startsWith('ffmpeg')) return { refused: err.message };
95
144
  throw err;
96
145
  }
97
- mkdirSync(dir, { recursive: true });
98
- const strip = path.join(dir, 'strip.png');
146
+ // force always wipes and recreates as capture 1, exactly as it does today, regardless of
147
+ // attach; only a plain attach (no force) merges onto the existing capture list.
148
+ const index = (!force && attach) ? nextCaptureIndex(dir) : 1;
149
+ // Gated on index, not force directly: index===1 is the same decision the attach-vs-recreate
150
+ // branch below acts on, so the two can never disagree — a regression that breaks the line
151
+ // above (force stops forcing index to 1) also stops this wipe from firing, instead of wiping
152
+ // 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.
156
+ if (index === 1) rmSync(dir, { recursive: true, force: true });
157
+ const outDir = captureDir(dir, index);
158
+ mkdirSync(outDir, { recursive: true });
159
+ const strip = path.join(outDir, 'strip.webp');
99
160
  try {
100
161
  renameSync(scratchStrip, strip);
101
162
  } catch {
@@ -103,8 +164,22 @@ export async function addEntry({ input, slug, title, source, force = false, fram
103
164
  unlinkSync(scratchStrip);
104
165
  }
105
166
  rmSync(scratch, { recursive: true, force: true });
106
- const captured = { method: 'frames', at: new Date().toISOString().slice(0, 10), tool: `wp-design-library@${p.version}` };
107
- const d = writeDraft({ entriesDir: p.entries, slug, title, source, media: { frames: media.frames, duration_ms: media.durationMs }, captured });
167
+
168
+ const rel = path.relative(dir, strip);
169
+ const capture = {
170
+ method: 'frames', input, artifacts: [rel],
171
+ frames: media.frames, duration_ms: media.durationMs,
172
+ };
173
+ const mediaFields = { frames: media.frames, duration_ms: media.durationMs };
174
+ const tool = `wp-design-library@${p.version}`;
175
+
176
+ if (index > 1) {
177
+ const r = await attachCapture({ entriesDir: p.entries, slug, capture, media: mediaFields, tool });
178
+ return { dir: r.dir, strip, captures: r.captures.length, frames: media.frames };
179
+ }
180
+
181
+ const captured = { method: 'frames', at: new Date().toISOString().slice(0, 10), tool };
182
+ const d = writeDraft({ entriesDir: p.entries, slug, title, source, media: mediaFields, captured, captures: [capture] });
108
183
  return { dir: d.dir, strip, blanks: d.blanks, frames: media.frames };
109
184
  }
110
185
 
@@ -157,6 +232,7 @@ export async function run(args) {
157
232
  force: args.includes('--force'),
158
233
  frames,
159
234
  record: args.includes('--record'),
235
+ attach: args.includes('--attach'),
160
236
  });
161
237
  process.stdout.write(JSON.stringify(out) + '\n');
162
238
  return out.refused ? 1 : 0;