@yojahny/wp-design-library 0.1.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 (74) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +7 -0
  3. package/Dockerfile +27 -0
  4. package/LICENSE +21 -0
  5. package/README.md +382 -0
  6. package/bin/library.mjs +19 -0
  7. package/docker/entrypoint.sh +11 -0
  8. package/docker-compose.yml +18 -0
  9. package/entries/.gitkeep +0 -0
  10. package/entries/ais-community-dark-depth/entry.md +76 -0
  11. package/entries/ais-community-dark-depth/strip.png +0 -0
  12. package/package.json +26 -0
  13. package/src/cli/add.mjs +163 -0
  14. package/src/cli/check.mjs +26 -0
  15. package/src/cli/export.mjs +41 -0
  16. package/src/cli/index.mjs +12 -0
  17. package/src/cli/refresh.mjs +63 -0
  18. package/src/cli/save.mjs +16 -0
  19. package/src/cli/serve.mjs +64 -0
  20. package/src/cli/ui.mjs +18 -0
  21. package/src/entry.mjs +69 -0
  22. package/src/index/build.mjs +78 -0
  23. package/src/index/embed.mjs +69 -0
  24. package/src/index/query.mjs +103 -0
  25. package/src/index/schema-vec.sql +4 -0
  26. package/src/index/schema.sql +10 -0
  27. package/src/ingest/draft.mjs +32 -0
  28. package/src/ingest/frames.mjs +93 -0
  29. package/src/ingest/measure.mjs +158 -0
  30. package/src/ingest/save.mjs +11 -0
  31. package/src/ingest/url-guard.mjs +76 -0
  32. package/src/mcp/http.mjs +72 -0
  33. package/src/mcp/prompts.mjs +147 -0
  34. package/src/mcp/resources.mjs +24 -0
  35. package/src/mcp/server.mjs +28 -0
  36. package/src/mcp/tools.mjs +152 -0
  37. package/src/paths.mjs +21 -0
  38. package/src/ui/app.js +34 -0
  39. package/src/ui/build.mjs +122 -0
  40. package/src/ui/serve.mjs +38 -0
  41. package/src/ui/templates/entry.html +31 -0
  42. package/src/ui/templates/index.html +32 -0
  43. package/src/vocab.mjs +34 -0
  44. package/tests/README.md +21 -0
  45. package/tests/checks/cli-check.sh +13 -0
  46. package/tests/checks/docker.sh +10 -0
  47. package/tests/checks/entry.sh +55 -0
  48. package/tests/checks/export.sh +16 -0
  49. package/tests/checks/fetch-on-start.sh +26 -0
  50. package/tests/checks/frames-dense.sh +26 -0
  51. package/tests/checks/http.sh +22 -0
  52. package/tests/checks/hygiene.sh +29 -0
  53. package/tests/checks/inbox.sh +27 -0
  54. package/tests/checks/index-degrade.sh +32 -0
  55. package/tests/checks/index.sh +36 -0
  56. package/tests/checks/ingest-mp4.sh +47 -0
  57. package/tests/checks/ingest.sh +39 -0
  58. package/tests/checks/licence-gate.sh +24 -0
  59. package/tests/checks/mcp-stdout.sh +31 -0
  60. package/tests/checks/measure.sh +75 -0
  61. package/tests/checks/minors.sh +84 -0
  62. package/tests/checks/prompt-add-entry.sh +28 -0
  63. package/tests/checks/resources.sh +44 -0
  64. package/tests/checks/rrf.sh +120 -0
  65. package/tests/checks/seed-sync.sh +22 -0
  66. package/tests/checks/similar.sh +62 -0
  67. package/tests/checks/ui-build.sh +59 -0
  68. package/tests/checks/vocab.sh +30 -0
  69. package/tests/fixtures/entry-ok/entry.md +41 -0
  70. package/tests/fixtures/entry-ok/strip.png +0 -0
  71. package/tests/fixtures/page/index.html +47 -0
  72. package/tests/fixtures/three-frame.webp +0 -0
  73. package/tests/run.sh +10 -0
  74. package/vocab.yaml +16 -0
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ set -eu
3
+ cd "$(dirname "$0")/../.."
4
+ tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; pid=""
5
+ trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
6
+
7
+ # Item 1: --inbox, two files that slugify identically in one run -> the second refuses,
8
+ # naming the first file.
9
+ d1="$tmp/item1"; mkdir -p "$d1/inbox" "$d1/entries"
10
+ cp tests/fixtures/three-frame.webp "$d1/inbox/A.webp"
11
+ cp tests/fixtures/three-frame.webp "$d1/inbox/a.webp"
12
+ out=$(LIBRARY_DATA="$d1" node bin/library.mjs add --inbox --source public-site --license x 2>&1 || true)
13
+ echo "$out" | grep -qF '{"file":"a.webp","slug":"a","status":"refused","reason":"slug a collides with A.webp in this run"}' \
14
+ || { echo "FAIL: item1 same-run slug collision"; echo "$out"; exit 1; }
15
+
16
+ # Item 2: source.kind outside the vocabulary is refused at draft time (resolveTerm),
17
+ # both for `add` directly and for `--inbox`.
18
+ d2="$tmp/item2"; mkdir -p "$d2/entries"
19
+ out=$(LIBRARY_DATA="$d2" node bin/library.mjs add tests/fixtures/three-frame.webp --slug bad-kind --title t --source bogus --license x 2>&1 || true)
20
+ echo "$out" | grep -q 'unknown source term' || { echo "FAIL: item2 add refuses unknown source.kind"; echo "$out"; exit 1; }
21
+ [ ! -e "$d2/entries/bad-kind" ] || { echo "FAIL: item2 draft written despite unknown source.kind"; exit 1; }
22
+
23
+ d2b="$tmp/item2b"; mkdir -p "$d2b/inbox" "$d2b/entries"
24
+ cp tests/fixtures/three-frame.webp "$d2b/inbox/x.webp"
25
+ out=$(LIBRARY_DATA="$d2b" node bin/library.mjs add --inbox --source bogus --license x 2>&1 || true)
26
+ echo "$out" | grep -q '"reason":"source.kind: unknown source term' || { echo "FAIL: item2 inbox refuses unknown source.kind at draft time"; echo "$out"; exit 1; }
27
+ [ ! -e "$d2b/entries/x" ] || { echo "FAIL: item2 inbox draft written despite unknown source.kind"; exit 1; }
28
+
29
+ # Item 3: over http, a dangling symlink under inbox is refused, for both add and similar.
30
+ d3="$tmp/item3"; mkdir -p "$d3/inbox" "$d3/entries"
31
+ ln -s "$d3/inbox/does-not-exist.webp" "$d3/inbox/dangling.webp"
32
+ LIBRARY_DATA="$d3" PORT=4190 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$d3/log" 2>&1 & pid=$!
33
+ for i in $(seq 1 30); do curl -sf localhost:4190/healthz >/dev/null 2>&1 && break; sleep 0.2; done
34
+ init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}'
35
+ curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$init" >/dev/null
36
+ call_add="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"$d3/inbox/dangling.webp\",\"slug\":\"x\",\"title\":\"x\",\"source\":{\"kind\":\"open\",\"license\":\"MIT\"}}}}"
37
+ curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_add" \
38
+ | grep -q '"refused":"input does not exist"' || { echo "FAIL: http add did not refuse a dangling symlink under inbox"; exit 1; }
39
+ call_sim="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"similar\",\"arguments\":{\"image\":\"$d3/inbox/dangling.webp\"}}}"
40
+ curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_sim" \
41
+ | grep -q '"refused":"input does not exist"' || { echo "FAIL: http similar did not refuse a dangling symlink under inbox"; exit 1; }
42
+ kill $pid 2>/dev/null || true; pid=""
43
+
44
+ # Item 4: slugify('___.webp') derives an empty slug -> refused, naming --slug.
45
+ d4="$tmp/item4"; mkdir -p "$d4/inbox" "$d4/entries"
46
+ cp tests/fixtures/three-frame.webp "$d4/inbox/___.webp"
47
+ out=$(LIBRARY_DATA="$d4" node bin/library.mjs add --inbox --source public-site --license x 2>&1 || true)
48
+ echo "$out" | grep -qF '"reason":"cannot derive a slug from the file name; pass --slug"' \
49
+ || { echo "FAIL: item4 empty slug refused"; echo "$out"; exit 1; }
50
+
51
+ # Item 5: export --saved-only skips drafts (entries failing validateEntry) and reports
52
+ # how many on stderr; the tar still goes to stdout.
53
+ d5="$tmp/item5"; mkdir -p "$d5/entries"
54
+ cp -r tests/fixtures/entry-ok "$d5/entries/entry-ok"
55
+ LIBRARY_DATA="$d5" node bin/library.mjs add tests/fixtures/three-frame.webp --slug draft-one --title t --source own --license x >/dev/null
56
+ LIBRARY_DATA="$d5" node bin/library.mjs export --saved-only >"$d5/out.tar" 2>"$d5/err"
57
+ tar -tf "$d5/out.tar" | grep -q 'entries/entry-ok/entry.md' || { echo "FAIL: item5 saved entry missing from tar"; exit 1; }
58
+ tar -tf "$d5/out.tar" | grep -q 'entries/draft-one' && { echo "FAIL: item5 draft included in tar"; exit 1; }
59
+ grep -q 'skipped 1 drafts' "$d5/err" || { echo "FAIL: item5 skipped count on stderr"; cat "$d5/err"; exit 1; }
60
+ # all drafts -> a named refusal, never tar's own "empty archive" wording
61
+ rm -rf "$d5/entries/entry-ok"
62
+ LIBRARY_DATA="$d5" node bin/library.mjs export --saved-only >/dev/null 2>"$d5/err2" && { echo "FAIL: item5 all-drafts export exited 0"; exit 1; }
63
+ grep -q 'export: no saved entries to export' "$d5/err2" || { echo "FAIL: item5 all-drafts refusal not named"; cat "$d5/err2"; exit 1; }
64
+
65
+ # Item 6: over http, `add` with a URL refuses before Chrome ever launches — a plain
66
+ # http:// scheme, a literal private IP, and a hostname that resolves to loopback.
67
+ d6="$tmp/item6"; mkdir -p "$d6/entries"
68
+ LIBRARY_DATA="$d6" PORT=4190 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$d6/log" 2>&1 & pid=$!
69
+ for i in $(seq 1 30); do curl -sf localhost:4190/healthz >/dev/null 2>&1 && break; sleep 0.2; done
70
+ curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$init" >/dev/null
71
+ call_scheme="{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"http://127.0.0.1:1/\",\"slug\":\"ssrf-scheme\",\"title\":\"t\",\"source\":{\"kind\":\"public-site\",\"license\":\"x\"}}}}"
72
+ curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_scheme" \
73
+ | grep -qF 'only https://' || { echo "FAIL: item6 http:// scheme not refused"; exit 1; }
74
+ call_ip="{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"https://127.0.0.1:1/\",\"slug\":\"ssrf-ip\",\"title\":\"t\",\"source\":{\"kind\":\"public-site\",\"license\":\"x\"}}}}"
75
+ curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_ip" \
76
+ | grep -qF 'resolves to a private address' || { echo "FAIL: item6 literal private IP not refused"; exit 1; }
77
+ call_host="{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"https://localhost:1/\",\"slug\":\"ssrf-host\",\"title\":\"t\",\"source\":{\"kind\":\"public-site\",\"license\":\"x\"}}}}"
78
+ curl -s -X POST localhost:4190/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call_host" \
79
+ | grep -qF 'resolves to a private address' || { echo "FAIL: item6 localhost (resolves to loopback) not refused by the guard"; exit 1; }
80
+ kill $pid 2>/dev/null || true; pid=""
81
+ [ ! -e "$d6/entries/ssrf-scheme" ] && [ ! -e "$d6/entries/ssrf-ip" ] && [ ! -e "$d6/entries/ssrf-host" ] \
82
+ || { echo "FAIL: item6 a draft was written despite an SSRF refusal"; exit 1; }
83
+
84
+ echo PASS
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # The server ships the add-entry prompt: prompts/list names it, prompts/get renders the
3
+ # order of operations and the tagging rubric with the vocabulary read from vocab.yaml.
4
+ set -eu
5
+ cd "$(dirname "$0")/../.."
6
+ tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"' EXIT
7
+ mkdir -p "$tmp/entries"
8
+ {
9
+ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
10
+ printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
11
+ printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"prompts/list"}'
12
+ printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"add-entry","arguments":{"path":"/x/site.webp","source":"public-site"}}}'
13
+ sleep 1
14
+ } | LIBRARY_DATA="$tmp" timeout 10 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
15
+ grep -q '"name":"add-entry"' "$tmp/out" || { echo "FAIL: prompts/list lacks add-entry"; cat "$tmp/err"; exit 1; }
16
+ get=$(grep '"id":3' "$tmp/out")
17
+ need() { echo "$get" | grep -qF "$1" || { echo "FAIL: prompt text lacks: $1"; exit 1; }; }
18
+ need 'The session is the tagger'
19
+ need 'show the draft and wait for a yes'
20
+ need 'never remove a field to make it pass'
21
+ need 'Eyeballed from N sampled frames, not measured'
22
+ need 'added by a PR to `vocab.yaml`, never by an ingest'
23
+ need 'can only read files already in the server'
24
+ need '/x/site.webp'
25
+ need 'public-site'
26
+ # vocabulary is rendered from vocab.yaml, so every role term appears
27
+ for t in $(node --input-type=module -e 'import { loadVocab } from "./src/vocab.mjs"; console.log([...loadVocab().facets.role].join(" "))'); do need "$t"; done
28
+ echo PASS
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # The server ships two resources (vocab.yaml, an entry's entry.md) and the
3
+ # brief-from-library prompt: resources/list, resources/templates/list and
4
+ # resources/read cover both; prompts/list and prompts/get cover the prompt.
5
+ set -eu
6
+ cd "$(dirname "$0")/../.."
7
+ tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"' EXIT
8
+ mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
9
+ {
10
+ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
11
+ printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
12
+ printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"resources/list"}'
13
+ printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"resources/templates/list"}'
14
+ printf '%s\n' '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"library://vocab"}}'
15
+ printf '%s\n' '{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"library://entry/entry-ok"}}'
16
+ printf '%s\n' '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"library://entry/nope"}}'
17
+ printf '%s\n' '{"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"library://entry/bad.slug"}}'
18
+ printf '%s\n' '{"jsonrpc":"2.0","id":7,"method":"prompts/list"}'
19
+ printf '%s\n' '{"jsonrpc":"2.0","id":8,"method":"prompts/get","params":{"name":"brief-from-library","arguments":{"brief":"a hero and a pricing table, dark and premium"}}}'
20
+ sleep 1
21
+ } | LIBRARY_DATA="$tmp" timeout 10 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
22
+
23
+ grep '"id":2' "$tmp/out" | grep -q 'library://vocab' || { echo "FAIL: resources/list lacks library://vocab"; cat "$tmp/err"; exit 1; }
24
+ grep '"id":3' "$tmp/out" | grep -q 'library://entry/{slug}' || { echo "FAIL: resources/templates/list lacks library://entry/{slug}"; exit 1; }
25
+ grep '"id":4' "$tmp/out" | grep -q 'role:' || { echo "FAIL: vocab resource read lacks role:"; exit 1; }
26
+ grep '"id":5' "$tmp/out" | grep -q 'slug: entry-ok' || { echo "FAIL: entry resource read lacks slug: entry-ok"; exit 1; }
27
+ nope=$(grep '"id":6' "$tmp/out")
28
+ echo "$nope" | grep -q '"error"' || { echo "FAIL: unknown slug did not error"; exit 1; }
29
+ echo "$nope" | grep -q 'no entry nope' || { echo "FAIL: unknown slug error lacks 'no entry nope'"; exit 1; }
30
+ bad=$(grep '"id":9' "$tmp/out")
31
+ echo "$bad" | grep -q '"error"' || { echo "FAIL: bad slug did not error"; exit 1; }
32
+ echo "$bad" | grep -qF 'no entry bad.slug' || { echo "FAIL: bad slug error lacks 'no entry bad.slug'"; exit 1; }
33
+ grep '"id":7' "$tmp/out" | grep -q '"name":"brief-from-library"' || { echo "FAIL: prompts/list lacks brief-from-library"; exit 1; }
34
+ get=$(grep '"id":8' "$tmp/out")
35
+ echo "$get" | grep -qF 'limit: 3' || { echo "FAIL: prompt text lacks: limit: 3"; exit 1; }
36
+ echo "$get" | grep -qF '## References' || { echo "FAIL: prompt text lacks: ## References"; exit 1; }
37
+ echo "$get" | grep -qF 'ranks.fts' || { echo "FAIL: prompt text lacks the weak-vector-match rule (ranks.fts)"; exit 1; }
38
+ echo "$get" | grep -qF 'having no reference rather than filling it' || { echo "FAIL: prompt text lacks the weak-match-only role guidance"; exit 1; }
39
+ echo "$get" | grep -qi 'hero' || { echo "FAIL: prompt text lacks the word hero"; exit 1; }
40
+ # vocabulary is rendered from vocab.yaml, so every feel term appears
41
+ for t in $(node --input-type=module -e 'import { loadVocab } from "./src/vocab.mjs"; console.log([...loadVocab().facets.feel].join(" "))'); do
42
+ echo "$get" | grep -qF "$t" || { echo "FAIL: prompt text lacks feel term: $t"; exit 1; }
43
+ done
44
+ echo PASS
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ set -eu
3
+ cd "$(dirname "$0")/../.."
4
+ tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
5
+ export LIBRARY_EMBED=stub
6
+ mkdir -p "$tmp/entries"
7
+
8
+ # Four entries sharing role/feel/motion so tags never leak "parallax"/"bed" (both are
9
+ # also valid vocab terms elsewhere) into the FTS/vector text: only title+body
10
+ # distinguish them from the query "parallax bed". Keyword relevance (verified by
11
+ # querying bm25() directly): y is best (both terms in title and twice in body),
12
+ # z is medium (both terms once, in body only), w is weak (both terms once, in a
13
+ # title padded long with unrelated words so document-length normalisation costs it),
14
+ # x has no keyword match at all (never appears in the FTS arm's results).
15
+ #
16
+ # The stub embedder has no real semantics, so instead of hoping title/body text
17
+ # happens to produce a useful vector spread, the vectors are overwritten directly in
18
+ # vec_text after indexing so the two arms are made to actively disagree: x (worst
19
+ # keywords) gets the *closest* vector, y (best keywords) gets the *farthest*
20
+ # (negated) vector, z and w sit in between in keyword order. Only z is good on both
21
+ # arms, so only RRF's fusion — not either arm alone — puts it first.
22
+ mk() {
23
+ slug=$1; title=$2; body=$3
24
+ d="$tmp/entries/$slug"; mkdir -p "$d"
25
+ cp tests/fixtures/entry-ok/strip.png "$d/strip.png"
26
+ cat > "$d/entry.md" <<EOF
27
+ ---
28
+ slug: $slug
29
+ title: $title
30
+ tier: inspiration
31
+ source:
32
+ kind: open
33
+ url: https://example.com
34
+ license: MIT
35
+ captured:
36
+ method: frames
37
+ at: 2026-09-14
38
+ tool: wp-design-library@0.1.0
39
+ media:
40
+ frames: 3
41
+ duration_ms: 120
42
+ roles: [footer]
43
+ feel: [cold]
44
+ palette:
45
+ canvas: "#0b0f14"
46
+ ink: "#f4f6f8"
47
+ accent: "#6ea8ff"
48
+ type:
49
+ display: geometric sans, light
50
+ body: same family
51
+ motion:
52
+ devices: [pin]
53
+ notes: static footer element
54
+ ported_from: null
55
+ ---
56
+
57
+ $body
58
+ EOF
59
+ }
60
+ mk y "parallax bed hero" "the parallax bed rises here and the parallax bed falls there again"
61
+ mk z "quiet mountains view" "parallax bed"
62
+ mk w "parallax bed among many other unrelated words padding this title out considerably more" "clouds valleys rivers forests deserts glaciers coastlines"
63
+ mk x "quiet coastal footer element" "clouds valleys rivers forests deserts glaciers coastlines mountains"
64
+
65
+ LIBRARY_DATA="$tmp" node bin/library.mjs index >"$tmp/out" 2>&1
66
+ grep -q "arms: fts,vec" "$tmp/out" || { echo "FAIL: corpus did not index with the vec arm"; cat "$tmp/out"; exit 1; }
67
+
68
+ LIBRARY_DATA="$tmp" node --input-type=module -e '
69
+ import { openIndex, search } from "./src/index/query.mjs";
70
+ import { getEmbedder } from "./src/index/embed.mjs";
71
+ import { paths } from "./src/paths.mjs";
72
+ const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
73
+
74
+ const e = await getEmbedder({ models: "/x", mode: "stub" });
75
+ const q = await e.text("parallax bed");
76
+
77
+ function renorm(arr) {
78
+ let s = 0; for (const v of arr) s += v * v;
79
+ const n = Math.sqrt(s) || 1;
80
+ for (let i = 0; i < arr.length; i++) arr[i] /= n;
81
+ return arr;
82
+ }
83
+ const vecX = Float32Array.from(q); // rank 1: exact match
84
+ const vecZ = renorm(Float32Array.from(q).map((v, i) => (i === 0 ? v + 0.01 : v))); // rank 2: small nudge
85
+ const vecW = renorm(Float32Array.from(q).map((v, i) => (i === 0 ? v + 0.05 : v))); // rank 3: bigger nudge
86
+ const vecY = Float32Array.from(q).map((v) => -v); // rank 4: opposite direction
87
+
88
+ const dbw = openIndex(paths().index, { readonly: false });
89
+ const upd = dbw.prepare("UPDATE vec_text SET embedding = ? WHERE slug = ?");
90
+ upd.run(Buffer.from(vecX.buffer), "x");
91
+ upd.run(Buffer.from(vecZ.buffer), "z");
92
+ upd.run(Buffer.from(vecW.buffer), "w");
93
+ upd.run(Buffer.from(vecY.buffer), "y");
94
+ dbw.close();
95
+
96
+ const db2 = openIndex(paths().index);
97
+ const r = await search(db2, { query: "parallax bed", embedder: e });
98
+ must(r.arms.length === 2 && r.arms.includes("fts") && r.arms.includes("vec"), "arms is fts+vec");
99
+ must(r.hits[0].slug === "z", "z ranks first (mid on both beats top on one), got " + JSON.stringify(r.hits.map(h => h.slug)));
100
+ const top2 = r.hits.map(h => h.slug).slice(0, 2);
101
+ must(top2.includes("y"), "y appears in the top 2, got " + JSON.stringify(top2));
102
+ const xHit = r.hits.find(h => h.slug === "x");
103
+ must(!!xHit, "x is present in the results");
104
+ must(r.hits[0].slug !== "x", "x is not first");
105
+ must(xHit.ranks.fts === null, "x has no fts rank, got " + JSON.stringify(xHit.ranks));
106
+ must(xHit.ranks.vec === 1, "x is vec rank 1, got " + JSON.stringify(xHit.ranks));
107
+
108
+ const embOff = await getEmbedder({ models: "/x", mode: "off" });
109
+ must(embOff === null, "LIBRARY_EMBED=off resolves to no embedder");
110
+ const r2 = await search(db2, { query: "parallax bed", embedder: embOff });
111
+ must(r2.arms.length === 1 && r2.arms[0] === "fts", "off falls back to fts only");
112
+ must(Array.isArray(r2.skipped) && r2.skipped.length === 1 && r2.skipped[0].arm === "vec" && !!r2.skipped[0].reason, "skipped names the vec arm and a reason");
113
+
114
+ const badEmbedder = { arm: "vec", dims: e.dims, text: async () => { throw new Error("boom"); }, image: e.image };
115
+ const r3 = await search(db2, { query: "parallax bed", embedder: badEmbedder });
116
+ must(r3.arms.length === 1 && r3.arms[0] === "fts", "a failing embedder degrades to fts only, got " + JSON.stringify(r3.arms));
117
+ must(Array.isArray(r3.skipped) && r3.skipped.length === 1 && r3.skipped[0].arm === "vec" && r3.skipped[0].reason.includes("boom"), "skipped reason mentions boom, got " + JSON.stringify(r3.skipped));
118
+ must(Array.isArray(r3.hits) && r3.hits.length > 0, "hits are still returned despite the embedder failure");
119
+ '
120
+ echo PASS
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # On every start, entries present in the seed (the image's entries/) but missing from
3
+ # LIBRARY_DATA are copied in; entries already in the volume are never overwritten.
4
+ set -eu
5
+ cd "$(dirname "$0")/../.."
6
+ tmp=$(mktemp -d); pid=""; trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
7
+ mkdir -p "$tmp/seed" "$tmp/data/entries"
8
+ cp -r tests/fixtures/entry-ok "$tmp/seed/entry-ok"
9
+ cp -r tests/fixtures/entry-ok "$tmp/seed/entry-two"; sed -i 's/slug: entry-ok/slug: entry-two/' "$tmp/seed/entry-two/entry.md"
10
+ # volume already holds entry-ok with a local edit that must survive
11
+ cp -r tests/fixtures/entry-ok "$tmp/data/entries/entry-ok"; sed -i 's/Fixture hero with parallax bed/Volume edit kept/' "$tmp/data/entries/entry-ok/entry.md"
12
+ LIBRARY_DATA="$tmp/data" LIBRARY_SEED="$tmp/seed" PORT=4196 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
13
+ for i in $(seq 1 30); do curl -sf localhost:4196/healthz >/dev/null 2>&1 && break; sleep 0.2; done
14
+ curl -sf localhost:4196/healthz | grep -q '"entries":2' || { echo "FAIL: seed entry not synced into a non-empty volume"; cat "$tmp/log"; exit 1; }
15
+ grep -q "Volume edit kept" "$tmp/data/entries/entry-ok/entry.md" || { echo "FAIL: volume entry was overwritten"; exit 1; }
16
+ grep -q "seed: copied 1 new" "$tmp/log" || { echo "FAIL: sync not reported"; cat "$tmp/log"; exit 1; }
17
+ kill $pid; wait $pid 2>/dev/null || true; pid=""
18
+ # second start copies nothing
19
+ LIBRARY_DATA="$tmp/data" LIBRARY_SEED="$tmp/seed" PORT=4196 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$tmp/log2" 2>&1 & pid=$!
20
+ for i in $(seq 1 30); do curl -sf localhost:4196/healthz >/dev/null 2>&1 && break; sleep 0.2; done
21
+ grep -q "seed: copied 0 new" "$tmp/log2" || { echo "FAIL: second start should copy nothing"; cat "$tmp/log2"; exit 1; }
22
+ echo PASS
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+ set -eu
3
+ cd "$(dirname "$0")/../.."
4
+ tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; export LIBRARY_EMBED=stub; pid=""
5
+ trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
6
+
7
+ # a and b: byte-identical strip.png (both are plain copies of the fixture).
8
+ # c: a strip of a different size, so its stub vector differs.
9
+ mkdir -p "$tmp/entries"
10
+ cp -r tests/fixtures/entry-ok "$tmp/entries/a"; sed -i 's/slug: entry-ok/slug: a/' "$tmp/entries/a/entry.md"
11
+ cp -r tests/fixtures/entry-ok "$tmp/entries/b"; sed -i 's/slug: entry-ok/slug: b/' "$tmp/entries/b/entry.md"
12
+ cp -r tests/fixtures/entry-ok "$tmp/entries/c"; sed -i 's/slug: entry-ok/slug: c/' "$tmp/entries/c/entry.md"
13
+ printf x >> "$tmp/entries/c/strip.png"
14
+
15
+ # stub, over stdio: slug lookup and image lookup
16
+ {
17
+ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
18
+ printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
19
+ printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"similar","arguments":{"slug":"a"}}}'
20
+ printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"similar\",\"arguments\":{\"image\":\"$tmp/entries/a/strip.png\"}}}"
21
+ printf '%s\n' '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"similar","arguments":{}}}'
22
+ sleep 1
23
+ } | LIBRARY_DATA="$tmp" timeout 10 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
24
+
25
+ node --input-type=module -e '
26
+ import fs from "node:fs";
27
+ const lines = fs.readFileSync(process.argv[2], "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
28
+ const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
29
+ const byId = (id) => lines.find((l) => l.id === id)?.result?.structuredContent;
30
+
31
+ const bySlug = byId(2);
32
+ must(bySlug.arms.length === 1 && bySlug.arms[0] === "vec", "similar-by-slug arms is vec");
33
+ must(bySlug.hits[0].slug === "b" && bySlug.hits[0].distance === 0, "similar-by-slug returns b first at distance 0; got " + JSON.stringify(bySlug.hits));
34
+
35
+ const byImage = byId(3);
36
+ const slugs = byImage.hits.map((h) => h.slug);
37
+ must(slugs.includes("a") && slugs.includes("b"), "similar-by-image returns a and b; got " + JSON.stringify(slugs));
38
+
39
+ const noArg = byId(4);
40
+ must(!!noArg.refused, "similar with neither slug nor image is refused");
41
+ ' _ "$tmp/out"
42
+
43
+ # LIBRARY_EMBED=off: similar refuses, naming the arm
44
+ {
45
+ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
46
+ printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
47
+ printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"similar","arguments":{"slug":"a"}}}'
48
+ sleep 1
49
+ } | LIBRARY_DATA="$tmp" LIBRARY_EMBED=off timeout 10 node bin/library.mjs serve >"$tmp/out-off" 2>"$tmp/err-off" || true
50
+ grep '"id":2' "$tmp/out-off" | grep -q 'refused.*arm' || { echo "FAIL: LIBRARY_EMBED=off did not refuse similar, naming the arm"; cat "$tmp/out-off"; exit 1; }
51
+
52
+ # over http: image is confined to <data>/inbox
53
+ LIBRARY_DATA="$tmp" PORT=4191 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
54
+ for i in $(seq 1 30); do curl -sf localhost:4191/healthz >/dev/null 2>&1 && break; sleep 0.2; done
55
+ curl -sf localhost:4191/healthz | grep -q '"arms":\["fts","vec"\]' || { echo "FAIL: healthz arms with vec"; cat "$tmp/log"; exit 1; }
56
+ init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}'
57
+ curl -s -X POST localhost:4191/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$init" >/dev/null
58
+ call='{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"similar","arguments":{"image":"/etc/hostname"}}}'
59
+ curl -s -X POST localhost:4191/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call" \
60
+ | grep -q 'refused.*inbox' || { echo "FAIL: http similar did not refuse an image outside the inbox"; exit 1; }
61
+ kill $pid 2>/dev/null || true; pid=""
62
+ echo PASS
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ set -eu
3
+ cd "$(dirname "$0")/../.."
4
+ tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; pid=""; trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
5
+ mkdir -p "$tmp/entries"
6
+ cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
7
+ cp -r tests/fixtures/entry-ok "$tmp/entries/entry-draft"
8
+ # draft frontmatter slug carries a path-traversal + attribute-breakout payload; the
9
+ # directory name (entry-draft) stays safe. title is blanked so the card/entry-page title
10
+ # falls back to fm.slug (text, shown only through esc()) and exercises the quote-escaping
11
+ # fix. captured.at is newer than entry-ok's so the sort fix (not insertion order) puts it first.
12
+ sed -i 's/title: Fixture hero with parallax bed/title: ""/; s/roles: \[hero, closing\]/roles: []/; s#slug: entry-ok#slug: ../escape" onmouseover="x#; s/ at: 2026-09-14/ at: 2026-09-20/' "$tmp/entries/entry-draft/entry.md"
13
+
14
+ # `library ui` builds a static gallery straight from entries/, no index required first
15
+ LIBRARY_DATA="$tmp" node bin/library.mjs ui >"$tmp/out" 2>&1
16
+ ui="$tmp/ui"
17
+ [ -f "$ui/index.html" ] || { echo "FAIL: index.html missing"; cat "$tmp/out"; exit 1; }
18
+ [ -f "$ui/manifest.json" ] || { echo "FAIL: manifest.json missing"; cat "$tmp/out"; exit 1; }
19
+ [ "$(grep -c '"slug":' "$ui/manifest.json")" = "2" ] || { echo "FAIL: manifest does not have two rows"; cat "$ui/manifest.json"; exit 1; }
20
+ [ "$(grep -c '"draft": true' "$ui/manifest.json")" = "1" ] || { echo "FAIL: manifest does not mark exactly one draft"; cat "$ui/manifest.json"; exit 1; }
21
+ grep -q 'Fixture hero with parallax bed' "$ui/entry/entry-ok.html" || { echo "FAIL: entry page missing title"; exit 1; }
22
+ grep -q '<h2>What it does</h2>' "$ui/entry/entry-ok.html" || { echo "FAIL: markdown heading not converted"; exit 1; }
23
+ [ -f "$ui/strips/entry-ok.png" ] || { echo "FAIL: strip not copied"; exit 1; }
24
+ for t in hero proof feature process offer testimonial faq closing capability explainer page-head footer; do
25
+ grep -q "value=\"$t\"" "$ui/index.html" || { echo "FAIL: role term $t missing from index.html filters"; exit 1; }
26
+ done
27
+
28
+ # fm.slug must never reach a path: nothing escaped outDir or the entry/ dir via ../
29
+ # (the payload is `../escape" onmouseover="x`, so any file carrying "escape" anywhere under $tmp is the leak)
30
+ [ -z "$(find "$tmp" -name '*escape*' -print -quit)" ] || { echo "FAIL: fm.slug reached a filesystem path"; find "$tmp" -name '*escape*'; exit 1; }
31
+ # fm.slug must never reach an unescaped attribute: a raw " right before onmouseover=
32
+ # means the quote broke out; a safely-escaped &quot; is fine and expected here
33
+ grep -Fq '" onmouseover="' "$ui/index.html" && { echo "FAIL: fm.slug quote broke out of an attribute"; exit 1; }
34
+ grep -q '"slug": "entry-draft"' "$ui/manifest.json" || { echo "FAIL: manifest does not list the draft under its directory name"; cat "$ui/manifest.json"; exit 1; }
35
+
36
+ # captured.at round-trips js-yaml as a Date; sort must land on the ISO string, newest first
37
+ node -e '
38
+ const rows = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
39
+ if (rows[0].slug !== "entry-draft") { console.error(JSON.stringify(rows.map((r) => [r.slug, r.captured]))); process.exit(1); }
40
+ ' "$ui/manifest.json" || { echo "FAIL: newer captured.at entry is not first in manifest.json"; exit 1; }
41
+
42
+ # a real file one level above the ui root (LIBRARY_DATA itself), so a successful
43
+ # traversal would actually read something back instead of 404ing on its own
44
+ echo '{"leak":true}' > "$tmp/package.json"
45
+
46
+ # `serve --http` builds the same gallery on start and serves it at /, safely
47
+ LIBRARY_DATA="$tmp" PORT=4193 LIBRARY_TOKEN=t0k timeout 10 node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
48
+ for i in $(seq 1 30); do curl -sf localhost:4193/healthz >/dev/null 2>&1 && break; sleep 0.2; done
49
+ code=$(curl -s -o /dev/null -w '%{http_code}' localhost:4193/)
50
+ [ "$code" = "200" ] || { echo "FAIL: GET / -> $code"; cat "$tmp/log"; exit 1; }
51
+ curl -s localhost:4193/ | grep -q 'id="grid"' || { echo "FAIL: GET / missing the grid"; exit 1; }
52
+ code=$(curl -s -o /dev/null -w '%{http_code}' localhost:4193/entry/entry-ok.html)
53
+ [ "$code" = "200" ] || { echo "FAIL: GET /entry/entry-ok.html -> $code"; exit 1; }
54
+ code=$(curl -s -o /dev/null -w '%{http_code}' --path-as-is localhost:4193/../package.json)
55
+ [ "$code" = "404" ] || { echo "FAIL: GET /../package.json -> $code (traversal not blocked)"; exit 1; }
56
+ code=$(curl -s -o /dev/null -w '%{http_code}' --path-as-is localhost:4193/%2e%2e/package.json)
57
+ [ "$code" = "404" ] || { echo "FAIL: GET /%2e%2e/package.json -> $code (traversal not blocked)"; exit 1; }
58
+ curl -sf localhost:4193/healthz | grep -q '"ok":true' || { echo "FAIL: healthz not json"; exit 1; }
59
+ echo PASS
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ set -eu
3
+ cd "$(dirname "$0")/../.."
4
+ tmpdir=$(mktemp -d); trap 'rm -rf "$tmpdir"' EXIT
5
+ tmp="$tmpdir/bad-vocab-aliases.yaml"
6
+ VOCAB_TMP="$tmp" node --input-type=module -e '
7
+ import { loadVocab, resolveTerm } from "./src/vocab.mjs";
8
+ import { writeFileSync } from "fs";
9
+ const v = loadVocab();
10
+ const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
11
+ must(v.facets.role.has("hero"), "role/hero present");
12
+ must(resolveTerm(v, "role", "plans") === "offer", "alias plans -> offer");
13
+ must(resolveTerm(v, "role", "offer") === "offer", "canonical passes through");
14
+ must(resolveTerm(v, "feel", "neon") === null, "unknown term is null");
15
+ must(resolveTerm(v, "role", "dark") === null, "term from another facet is null");
16
+ must(Object.keys(v.facets).length === 5, "exactly five facets");
17
+ const tmpFile = process.env.VOCAB_TMP;
18
+ writeFileSync(tmpFile, "role: [hero]\nfeel: [dark]\nmotion: [reveal]\nsource: [own]\ntier: [inspiration]\naliases: pricing\n");
19
+ try {
20
+ loadVocab(tmpFile);
21
+ console.error("FAIL: bad aliases did not throw");
22
+ process.exit(1);
23
+ } catch (err) {
24
+ if (!err.message.includes("aliases")) {
25
+ console.error("FAIL: error message missing aliases: " + err.message);
26
+ process.exit(1);
27
+ }
28
+ }
29
+ '
30
+ echo PASS
@@ -0,0 +1,41 @@
1
+ ---
2
+ slug: entry-ok
3
+ title: Fixture hero with parallax bed
4
+ tier: inspiration
5
+ source:
6
+ kind: open
7
+ url: https://example.com
8
+ license: MIT
9
+ captured:
10
+ method: frames
11
+ at: 2026-09-14
12
+ tool: wp-design-library@0.1.0
13
+ media:
14
+ frames: 3
15
+ duration_ms: 120
16
+ roles: [hero, closing]
17
+ feel: [dark, depth]
18
+ palette:
19
+ canvas: "#0b0f14"
20
+ ink: "#f4f6f8"
21
+ accent: "#6ea8ff"
22
+ type:
23
+ display: geometric sans, light
24
+ body: same family
25
+ motion:
26
+ devices: [parallax, reveal]
27
+ notes: bed moves slower than the frame
28
+ ported_from: null
29
+ ---
30
+
31
+ ## What it does
32
+
33
+ A dark hero whose background bed scrolls slower than the foreground frame.
34
+
35
+ ## Section roster
36
+
37
+ hero, cta
38
+
39
+ ## Why it works
40
+
41
+ Depth from one cheap device.
@@ -0,0 +1,47 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>measure fixture</title>
6
+ <style>
7
+ * { box-sizing: border-box; }
8
+ html, body { margin: 0; }
9
+ body {
10
+ background: #0b1218;
11
+ color: #f2f5f8;
12
+ font-family: Arial;
13
+ }
14
+ h1 { font-family: Georgia; margin: 0 0 16px; }
15
+ p { font-family: Arial; margin: 0 0 16px; }
16
+ a, button { color: #7fb6ff; }
17
+ button { background: transparent; border: 1px solid currentColor; font: inherit; padding: 8px 16px; }
18
+ section { height: 1000px; padding: 48px; }
19
+
20
+ @keyframes reveal {
21
+ from { opacity: 0; transform: translateY(24px); }
22
+ to { opacity: 1; transform: translateY(0); }
23
+ }
24
+ .reveal-target {
25
+ animation: reveal linear both;
26
+ animation-timeline: view();
27
+ animation-range: entry 0% entry 40%;
28
+ }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <section class="hero">
33
+ <h1>Hero heading</h1>
34
+ <p>Static hero copy that sets the scene.</p>
35
+ <a href="#learn">Learn more</a>
36
+ </section>
37
+ <section class="feature">
38
+ <h1>Feature heading</h1>
39
+ <p class="reveal-target">This paragraph reveals as it enters the viewport.</p>
40
+ </section>
41
+ <section class="closing">
42
+ <h1>Closing heading</h1>
43
+ <p>Closing copy.</p>
44
+ <button type="button">Get started</button>
45
+ </section>
46
+ </body>
47
+ </html>
Binary file
package/tests/run.sh ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -u
3
+ cd "$(dirname "$0")/.."
4
+ pass=0; fail=0
5
+ for f in tests/checks/*.sh; do
6
+ if out=$(bash "$f" 2>&1); then pass=$((pass+1)); echo "PASS $f"
7
+ else fail=$((fail+1)); echo "FAIL $f"; echo "$out" | tail -20; fi
8
+ done
9
+ echo "PASS=$pass FAIL=$fail"
10
+ [ "$fail" -eq 0 ]
package/vocab.yaml ADDED
@@ -0,0 +1,16 @@
1
+ # Five facets, one level deep. A term is added by PR, never by an ingest.
2
+ role: [hero, proof, feature, process, offer, testimonial, faq, closing, capability, explainer, page-head, footer]
3
+ feel: [dark, light, premium, playful, editorial, brutalist, saas, depth, minimal, warm, cold]
4
+ # The eleven data-motion device names from claude-wp-builder's motion.js,
5
+ # then observed-only terms: things seen in reference not a device.
6
+ motion: [reveal, pin, pan, wipe, kinetic, parallax, count, drift, tilt, magnet, spotlight, stagger, marquee, stack, tabs]
7
+ source: [own, open, paid, public-site]
8
+ tier: [inspiration, ported]
9
+ aliases:
10
+ offer: [pricing, plans, tiers, offer-table]
11
+ proof: [logos, social-proof]
12
+ testimonial: [testimonials]
13
+ closing: [cta, call-to-action]
14
+ feature: [features]
15
+ dark: [dark-mode]
16
+ parallax: [depth-scroll]