@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
package/.dockerignore ADDED
@@ -0,0 +1,11 @@
1
+ node_modules
2
+ .library
3
+ inbox
4
+ models
5
+ dist
6
+ .git
7
+ tests
8
+ .superpowers
9
+ .tokensave
10
+ .claude
11
+ .env
package/.env.example ADDED
@@ -0,0 +1,7 @@
1
+ LIBRARY_TOKEN=change-me
2
+ LIBRARY_DATA=/data
3
+ PORT=4180
4
+ LIBRARY_FETCH_MODELS=0 # 1 = fetch the vector-search models on server start (one-time, ~450 MB); set back to 0 after
5
+ LIBRARY_MODELS=/data/models # where the models land on the /data volume
6
+ LIBRARY_EMBED= # empty = real models when present, stub = deterministic vectors for checks, off = FTS only
7
+ CHROME_SANDBOX=0 # 1 = let Chrome use its own sandbox; only where user namespaces are allowed (not the default container)
package/Dockerfile ADDED
@@ -0,0 +1,27 @@
1
+ FROM node:22-bookworm
2
+ # Base tools in their own layer so a Chrome failure is attributable.
3
+ RUN apt-get update && apt-get install -y --no-install-recommends wget gnupg ca-certificates ffmpeg fonts-liberation \
4
+ && rm -rf /var/lib/apt/lists/*
5
+ ARG WITH_CHROME=1
6
+ # Chrome is for URL measurement (Phase 2). Set build arg WITH_CHROME=0 to skip it. Google ships amd64 only, so on any other
7
+ # architecture the image says so and continues; the measure step refuses loudly at run time.
8
+ RUN if [ "$WITH_CHROME" = "1" ] && [ "$(dpkg --print-architecture)" = "amd64" ]; then \
9
+ wget -qO- https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google.gpg \
10
+ && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list \
11
+ && apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \
12
+ && rm -rf /var/lib/apt/lists/* ; \
13
+ else echo "chrome: skipped (WITH_CHROME=$WITH_CHROME, arch $(dpkg --print-architecture))"; fi
14
+ WORKDIR /app
15
+ COPY package*.json ./
16
+ RUN npm ci --omit=dev
17
+ COPY . .
18
+ # Vector-search models are not baked into the image: they land on the /data volume
19
+ # under models/ and are fetched at container start when LIBRARY_FETCH_MODELS=1.
20
+ ENV LIBRARY_DATA=/data PORT=4180 CHROME_PATH=/usr/bin/google-chrome NODE_ENV=production
21
+ EXPOSE 4180
22
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
23
+ CMD node -e "fetch('http://localhost:4180/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
24
+ # Starts as root (needed to chown a volume from an older, root-owned run) and drops to
25
+ # `node` via setpriv before exec'ing the real command; see docker/entrypoint.sh.
26
+ ENTRYPOINT ["docker/entrypoint.sh"]
27
+ CMD ["node", "bin/library.mjs", "serve", "--http"]
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yojahny Chavez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,382 @@
1
+ # wp-design-library
2
+
3
+ A library of design references for `claude-wp-builder`. You feed it recordings
4
+ of sites you like; it stores a description of each one, tags, a contact sheet,
5
+ and an honest note on the motion; and the plugin's `/wp-demo` reads it over MCP
6
+ when it writes a brief, so every demo starts from real references instead of
7
+ from memory.
8
+
9
+ Two tiers. **Inspiration** entries describe a reference (any licence). **Ported**
10
+ entries carry real HTML/CSS under the plugin's motion contract and are allowed
11
+ only for `own` or `open` sources; the licence gate refuses anything else.
12
+
13
+ ## What you need
14
+
15
+ - Node 22 or newer.
16
+ - For video input: an animated **webp**, **gif**, or **mp4/mov/webm/mkv** (needs
17
+ the system `ffmpeg`/`ffprobe`). Record a scroll of the page with any screen
18
+ recorder and hand the file straight to `add`.
19
+ - For URL input (`add https://…`, `refresh`): a system **Chrome or Chromium**
20
+ binary, pointed at by `CHROME_PATH`. See "Adding a live site" below.
21
+ - A Claude Code session, for the tagging step. The server never guesses what a
22
+ design feels like; the session that runs the ingest does.
23
+
24
+ ```bash
25
+ git clone git@github.com:yojahny55/wp-design-library.git
26
+ cd wp-design-library && npm install
27
+ npm test # 24 checks, all should PASS (measure.sh SKIPs without a system Chrome)
28
+ ```
29
+
30
+ ## Adding an entry
31
+
32
+ Adding is two steps by design: the tool does the mechanical part, a Claude
33
+ session does the judgement part.
34
+
35
+ ### 1. Ingest: `library add`
36
+
37
+ ```bash
38
+ node bin/library.mjs add ~/Desktop/site.webp \
39
+ --slug acme-dark-hero \
40
+ --title "Acme: dark landing with a pinned product hero" \
41
+ --source public-site \
42
+ --url https://acme.example \
43
+ --license "captured from the live site; description only"
44
+ ```
45
+
46
+ | Flag | Meaning |
47
+ |---|---|
48
+ | first argument | path to the webp/gif |
49
+ | `--slug` | lowercase, digits and dashes; becomes the folder name |
50
+ | `--title` | one line, what it is |
51
+ | `--source` | `own`, `open`, `paid` or `public-site` (see Licence below) |
52
+ | `--url` | where it came from (optional for `own`) |
53
+ | `--license` | free text, required; say what you may do with it |
54
+ | `--force` | overwrite an existing draft (otherwise a second `add` is refused) |
55
+
56
+ This writes `entries/<slug>/strip.png` (6–12 sampled frames) and a draft
57
+ `entries/<slug>/entry.md` with the mechanical fields filled and every
58
+ judgement field blank. The output lists those blanks:
59
+
60
+ ```
61
+ roles, feel, motion.devices, motion.notes, type.display, type.body, body,
62
+ palette.canvas, palette.ink, palette.accent
63
+ ```
64
+
65
+ ### Adding a live site
66
+
67
+ Point `add` at an `http://`/`https://` URL instead of a file and it measures the live
68
+ page with the system Chrome (`playwright-core`) instead of sampling a recording:
69
+ computed-style tokens (canvas, ink, accent, the display and body font stacks), a
70
+ per-section animation inventory (CSS scroll-driven timelines included), and two
71
+ screenshots (1440/390). `palette` and `type` are filled mechanically from those
72
+ tokens — the raw font-family strings, not a human's words — and the draft still
73
+ waits for a session to fill `roles`, `feel`, `motion.devices`/`motion.notes` and `body`.
74
+
75
+ ```bash
76
+ CHROME_PATH=/usr/bin/google-chrome node bin/library.mjs add https://acme.example \
77
+ --slug acme-live-hero \
78
+ --title "Acme: dark landing" \
79
+ --source public-site \
80
+ --license "measured from the live site; description only" \
81
+ --record
82
+ ```
83
+
84
+ | Flag | Meaning |
85
+ |---|---|
86
+ | `--record` | also scroll top to bottom in 12 steps at 1440 and build `strip.png` from those stops (`captured.method: both`); without it, `strip.png` is the single 1440 screenshot and `captured.method: measured` |
87
+
88
+ `CHROME_PATH` must point at a Chrome or Chromium binary. Without it (or with a path
89
+ that does not exist) `add` refuses with `chrome not available: …` and nothing is
90
+ written under `entries/`. If `--record` captures its frames but the token/section
91
+ measurement itself then fails, the draft is still written — `captured.method: frames`,
92
+ with the reason in `captured.error`.
93
+
94
+ #### Keeping a live entry current: `library refresh`
95
+
96
+ ```bash
97
+ node bin/library.mjs refresh acme-live-hero
98
+ ```
99
+
100
+ Re-measures `source.url` with the same system Chrome, rewrites `measure.json` and
101
+ `captured.at`, and reindexes. Every judgement field (`roles`, `feel`, `palette`,
102
+ `type`, `motion`, `body`…) and `strip.png` are left exactly as they were. Refuses an
103
+ entry with no `source.url`, or one whose `captured.method` is `frames` (nothing came
104
+ from a URL to re-measure). Over MCP, the same as the `refresh` tool: `{ slug }`.
105
+
106
+ ### Inbox: draft everything in the drop folder
107
+
108
+ Drop recordings into `<data>/inbox` (webp, gif, mp4, mov, webm, mkv) and draft
109
+ them all in one pass, without typing a slug or title per file:
110
+
111
+ ```bash
112
+ node bin/library.mjs add --inbox --source public-site --license "captured from the live site; description only"
113
+ ```
114
+
115
+ The slug comes from the file name (lowercased, non `[a-z0-9]` runs collapsed
116
+ to a dash, trimmed) and the title from the slug with dashes turned back into
117
+ spaces. A sidecar `<file>.json` next to a recording — `{"kind", "url",
118
+ "license", "title"}` — wins over the flags for that file; a file with neither
119
+ a sidecar nor `--source`/`--license` is refused, one line named, and the run
120
+ still processes every other file. A file whose slug already has an entry is
121
+ skipped, not overwritten. Two files that derive the same slug in one run are
122
+ not both drafted: the second is refused, naming the first. A file name that
123
+ derives no slug at all (`___.webp`) is refused rather than silently dropped.
124
+ A `kind` outside `source.kind`'s vocabulary is refused at draft time, the
125
+ same as `add`, not first at `save`. Output is one JSON line per file:
126
+
127
+ ```
128
+ {"file":"acme-dark-hero.webp","slug":"acme-dark-hero","status":"drafted","frames":8}
129
+ ```
130
+
131
+ `status` is `drafted`, `skipped`, or `refused` (with a `reason`); the process
132
+ exits 1 if any file was refused. `--inbox` never saves — every draft still
133
+ waits for a tagging session and `library save`.
134
+
135
+ ### From a Claude Code session: `/mcp__wp-design-library__add-entry`
136
+
137
+ With the server registered in a session (the `claude-wp-builder` plugin registers it,
138
+ or add it to any project's `.mcp.json`), the whole add is one slash command:
139
+
140
+ ```
141
+ /mcp__wp-design-library__add-entry path=/abs/site.webp source=public-site
142
+ ```
143
+
144
+ The server renders the prompt with the current vocabulary, the session ingests,
145
+ reads the strip, fills every judgement field per the rubric, shows you the draft
146
+ and waits for a yes, saves, and offers the commit and PR. The rubric lives in
147
+ `src/mcp/prompts.mjs`; the plugin that consumes the library ships nothing for it.
148
+
149
+ ### 2. Tag: fill the blanks, then `library save`
150
+
151
+ Open the strip and the draft in a Claude Code session and ask it to fill the
152
+ blanks. The same works from the plugin: with the MCP server registered, say
153
+ "add `~/Desktop/site.webp` to the design library as `acme-dark-hero`" and the
154
+ session calls `add`, reads the strip, fills the draft, and calls `save_entry`.
155
+
156
+ What goes where, with the values checked against `vocab.yaml`:
157
+
158
+ | Field | What to write |
159
+ |---|---|
160
+ | `roles` | the page sections, from the role list: `hero, proof, feature, process, offer, testimonial, faq, closing, capability, explainer, page-head, footer` |
161
+ | `feel` | 3–6 tags from the feel list: `dark, light, premium, playful, editorial, brutalist, saas, depth, minimal, warm, cold` |
162
+ | `palette` | three hex values, eyeballed from the strip: canvas, ink, accent |
163
+ | `type` | display and body: family feel, weight, tracking, in words |
164
+ | `motion.devices` | from the motion list: `reveal, pin, pan, wipe, kinetic, parallax, count, drift, tilt, magnet, spotlight, stagger, marquee, stack, tabs` |
165
+ | `motion.notes` | what was **seen**, per section; say it was eyeballed, and name what you could not tell |
166
+ | body | three headed sections: `## What it does`, `## Section roster`, `## Why it works` |
167
+
168
+ Aliases work (`pricing` → `offer`, `cta` → `closing`, `testimonials` →
169
+ `testimonial`); anything outside the vocabulary is refused with the facet and
170
+ term named. A term is added by editing `vocab.yaml` in a PR, never by an
171
+ ingest.
172
+
173
+ Then:
174
+
175
+ ```bash
176
+ node bin/library.mjs save acme-dark-hero
177
+ ```
178
+
179
+ `save` validates every field, applies the licence gate, and reindexes. On
180
+ refusal it prints one line per problem and exits 1. On success:
181
+
182
+ ```
183
+ {"ok":true,"slug":"acme-dark-hero","errors":[]}
184
+ ```
185
+
186
+ Tip: a 12-frame strip is one frame every two seconds and can miss a device that
187
+ lives in one section. If the recording is long, sample more frames yourself
188
+ (any image tool) and read two sheets before writing the motion note.
189
+
190
+ ### 3. Ship it
191
+
192
+ ```bash
193
+ git checkout -b content/acme-dark-hero
194
+ git add entries/acme-dark-hero
195
+ git commit -m "content: acme dark hero"
196
+ git push -u origin content/acme-dark-hero # then open a PR
197
+ ```
198
+
199
+ After the merge, redeploy the hosted instance. On every start the server copies
200
+ entries it does not have yet into the volume and never overwrites what is
201
+ already there, so the new entry appears without touching the volume by hand.
202
+
203
+ ## Licence and the two tiers
204
+
205
+ `source.kind` says where a reference came from and decides what may be built
206
+ from it:
207
+
208
+ | kind | meaning | may become `tier: ported` |
209
+ |---|---|---|
210
+ | `own` | you made or recorded it | yes |
211
+ | `open` | openly licensed (MIT, CC, …), named in `license` | yes |
212
+ | `paid` | a paid or proprietary resource, kit media, template pack | no |
213
+ | `public-site` | a live site you did not make | no |
214
+
215
+ A ported entry adds `ported/section.html`, `ported/section.css`, a README, and
216
+ `ported_from: <inspiration slug>`. `save` and `index` refuse `tier: ported` on
217
+ a `paid` or `public-site` source, and there is no override flag.
218
+
219
+ ## Searching
220
+
221
+ The plugin does this for you, but from the CLI:
222
+
223
+ ```bash
224
+ node bin/library.mjs index # rebuild the index from entries/
225
+ node bin/library.mjs check # validate every entry, 1 line per problem
226
+ ```
227
+
228
+ Over MCP, six tools: `search` (keyword + facet filters, aliases resolved),
229
+ `get_entry`, `similar`, `add`, `refresh`, `save_entry`. `search` and `similar` carry
230
+ `arms` (which retrieval arms ran; `["fts"]` in Phase 1); any tool may instead respond
231
+ with `refused` and the reason.
232
+
233
+ ## Vector search
234
+
235
+ `index` also builds a `vec` arm — two `sqlite-vec` tables (`vec_text` 384-d,
236
+ `vec_image` 768-d) alongside the FTS5 one — when an embedder is available.
237
+ `getEmbedder` picks one of three arms:
238
+
239
+ - `LIBRARY_EMBED=stub` — a deterministic, offline, hash-derived vector. No
240
+ semantics, no download; it exists so checks can exercise the `vec` arm
241
+ without a network.
242
+ - unset, both model dirs present under `paths().models` — the real
243
+ `@huggingface/transformers` embedder (`Xenova/all-MiniLM-L6-v2` for text,
244
+ `Xenova/siglip-base-patch16-224` for images).
245
+ - `LIBRARY_EMBED=off`, or unset with a model dir missing — no embedder;
246
+ `index` builds `fts` only and reports why on stderr
247
+ (`embeddings: skipped (<reason>)`).
248
+
249
+ Fetch the real models once with:
250
+
251
+ ```bash
252
+ node bin/library.mjs check --fetch-models # downloads into <models>, prints both dirs
253
+ ```
254
+
255
+ On the container, set `LIBRARY_FETCH_MODELS=1` instead: `serve --http` fetches the
256
+ models onto the `/data` volume between the seed sync and the index build, logs the
257
+ attempt to stderr, and continues either way — the MCP API never depends on it. It's a
258
+ one-time download (~450 MB); set the flag back to `0` once the volume has both model
259
+ dirs so every later start skips it. Swapping a model requires `library index` (or a
260
+ restart) afterward — the index is disposable and does not notice a model change on
261
+ its own.
262
+
263
+ Known ceiling: the vector arm has no relevance floor, so a query with no keyword
264
+ match still returns its nearest neighbours, distinguishable only by `ranks.fts === null`.
265
+
266
+ ## Running the server
267
+
268
+ ```bash
269
+ node bin/library.mjs serve # stdio, for a local Claude Code session
270
+ LIBRARY_TOKEN=… node bin/library.mjs serve --http # Streamable HTTP on :4180
271
+ ```
272
+
273
+ `serve` builds the index on start, so a fresh clone answers `search` at once.
274
+ Over HTTP, `/mcp` needs `Authorization: Bearer <LIBRARY_TOKEN>`, `/healthz`
275
+ reports the entry count, and `add`/`similar` may only read files under
276
+ `<data>/inbox`, including a path that turns out not to exist (a dangling
277
+ symlink included).
278
+
279
+ `node bin/library.mjs export > entries.tar` tars `<data>/entries` to stdout
280
+ (the entry count goes to stderr) — a way to pull every entry back out of a
281
+ running volume without shelling into the container (buffered in memory, 1 GiB
282
+ ceiling). The tar includes drafts whose judgement fields are still blank; save
283
+ or delete them before committing an export into `entries/`, or every server
284
+ start will log them as refused. `export --saved-only` leaves drafts (an entry
285
+ whose `entry.md` fails validation) out of the tar and prints how many were
286
+ skipped to stderr.
287
+
288
+ | Env | Default | Purpose |
289
+ |---|---|---|
290
+ | `LIBRARY_DATA` | repo root | where `entries/` and the index live (the volume in Docker) |
291
+ | `LIBRARY_SEED` | `<repo>/entries` | entries copied into `LIBRARY_DATA` on start when missing |
292
+ | `LIBRARY_TOKEN` | none | required for `--http`; the server refuses to start without it |
293
+ | `PORT` | `4180` | HTTP port |
294
+ | `LIBRARY_EMBED` | none (real embedder if models present, else skipped) | `stub` for the offline deterministic embedder, `off` to skip the `vec` arm |
295
+ | `LIBRARY_MODELS` | `<data>/models` | where the `@huggingface/transformers` model files live |
296
+ | `LIBRARY_FETCH_MODELS` | `0` | `1` = `serve --http` fetches the models onto `LIBRARY_MODELS` on start (one-time, ~450 MB); set back to `0` after |
297
+ | `CHROME_PATH` | none | path to a Chrome/Chromium binary; required by `add <url>` and `refresh` (the Docker image sets it to `/usr/bin/google-chrome`) |
298
+ | `CHROME_SANDBOX` | `0` | `1` = let Chrome use its own sandbox; only where user namespaces are allowed (not the default container, see "Known ceilings") |
299
+
300
+ ## Gallery
301
+
302
+ A static gallery so a human can browse what the MCP server serves: a grid of
303
+ every entry, filterable by the same five facets as `search` (role, feel,
304
+ motion, source, tier), and a per-entry page with the strip, tags, licence and
305
+ prose. Drafts (an entry whose `entry.md` fails validation) are included with a
306
+ `draft` badge, behind a `Show drafts` toggle — never hidden silently.
307
+
308
+ ```bash
309
+ node bin/library.mjs ui # builds <data>/ui from entries/
310
+ node bin/library.mjs ui --serve # ...and serves it on :4180 (no auth)
311
+ node bin/library.mjs ui --serve --port 4181
312
+ ```
313
+
314
+ `serve --http` builds the same gallery on every start and serves it at `/`
315
+ (a build failure logs to stderr and the MCP API starts anyway — the gallery
316
+ never gates it). `library ui --serve` serves the identical files standalone,
317
+ without `/mcp` or `/healthz`. Both paths refuse to serve anything outside
318
+ `<data>/ui`, including an encoded `/%2e%2e/`.
319
+
320
+ `manifest.json` is a build artefact — `tests/checks/ui-build.sh` asserts against it,
321
+ and the server otherwise just serves it as-is alongside the HTML. Because the
322
+ gallery is rebuilt at server start, not on every save, an entry saved over MCP
323
+ does not appear at `/` until the next restart.
324
+
325
+ ### Registering in claude-wp-builder
326
+
327
+ The plugin ships `.mcp.json` pointing at `npx -y @yojahny/wp-design-library serve`.
328
+ To use the hosted instance instead, put in your project's `.mcp.json`:
329
+
330
+ ```json
331
+ "wp-design-library": {
332
+ "type": "http",
333
+ "url": "https://wp-design-library.yojahny.dev/mcp",
334
+ "headers": { "Authorization": "Bearer <token>" }
335
+ }
336
+ ```
337
+
338
+ ## Deploying (Coolify)
339
+
340
+ Docker Compose build pack, compose file at `/docker-compose.yml`, one service
341
+ `library`, one volume at `/data`, `LIBRARY_TOKEN` as a secret. Build arg
342
+ `WITH_CHROME=0` skips the Chrome install on hosts that cannot reach Google's
343
+ apt repo (Chrome is for `add <url>`/`refresh`, see "Adding a live site" above —
344
+ without it, both refuse with `chrome not available`). Verify with
345
+ `curl https://<domain>/healthz`.
346
+
347
+ The container runs as `node` (uid 1000), not root: `docker/entrypoint.sh` starts as
348
+ root only to `chown` an existing (possibly root-owned) `/data` volume, then drops to
349
+ `node` via `setpriv` before exec'ing the server.
350
+
351
+ ## Known ceilings
352
+
353
+ - **Chrome runs without its own sandbox inside the container.** Docker's default
354
+ seccomp profile blocks the user namespaces Chrome's sandbox needs, so
355
+ `chromiumSandbox: true` would fail to launch there; the non-root container user is
356
+ the isolation instead. Set `CHROME_SANDBOX=1` only where user namespaces are
357
+ allowed (e.g. bare metal, not the default container).
358
+ - **The URL guard checks the address once, before launch.** Hosted `add` and
359
+ `refresh` refuse a host that resolves to a private address at call time. A DNS
360
+ answer that changes between that lookup and Chrome's own resolution, or a public
361
+ page that redirects to a private address, is followed inside Chrome and not
362
+ re-checked. The bearer token is the boundary those two cases rely on.
363
+ - **The entrypoint chowns `/data` on every start.** It walks the whole volume
364
+ (models and entries) so an old root-owned volume keeps working with no operator
365
+ step; start latency grows with the corpus. Replace it with an ownership probe if it
366
+ ever shows up in the health-check window.
367
+
368
+ ## Layout
369
+
370
+ ```
371
+ entries/<slug>/entry.md frontmatter + prose (the corpus, reviewable in PRs)
372
+ entries/<slug>/strip.png contact sheet
373
+ vocab.yaml controlled vocabulary, five facets, aliases
374
+ src/ingest/ frames, draft, save
375
+ src/index/ SQLite FTS5 build and query
376
+ src/mcp/ tool table, stdio and HTTP transports
377
+ src/ui/ static gallery: build, templates, app.js, the shared static server
378
+ tests/checks/*.sh one contract per script, PASS or non-zero
379
+ ```
380
+
381
+ The index (`.library/index.sqlite`) is disposable; delete it and any `serve`
382
+ or `index` rebuilds it from the markdown.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ const cmds = {
3
+ check: () => import('../src/cli/check.mjs'),
4
+ index: () => import('../src/cli/index.mjs'),
5
+ serve: () => import('../src/cli/serve.mjs'),
6
+ add: () => import('../src/cli/add.mjs'),
7
+ refresh: () => import('../src/cli/refresh.mjs'),
8
+ save: () => import('../src/cli/save.mjs'),
9
+ export: () => import('../src/cli/export.mjs'),
10
+ ui: () => import('../src/cli/ui.mjs'),
11
+ };
12
+ const [cmd, ...args] = process.argv.slice(2);
13
+ if (!cmds[cmd]) {
14
+ process.stderr.write(`usage: library <${Object.keys(cmds).join('|')}> [args]\n`);
15
+ process.exit(64);
16
+ }
17
+ const mod = await cmds[cmd]();
18
+ const code = await mod.run(args);
19
+ process.exit(typeof code === 'number' ? code : 0);
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ # Adopts a root-owned /data volume (from an older container run, or a fresh
3
+ # Docker-created volume) onto the `node` user, then drops root before exec'ing
4
+ # the real command. Not root already (e.g. `docker run --user`) -> nothing to do.
5
+ set -e
6
+ if [ "$(id -u)" = "0" ]; then
7
+ mkdir -p /data
8
+ chown -R node:node /data || true
9
+ exec setpriv --reuid=node --regid=node --init-groups "$@"
10
+ fi
11
+ exec "$@"
@@ -0,0 +1,18 @@
1
+ services:
2
+ library:
3
+ build: .
4
+ ports:
5
+ - "4180:4180"
6
+ environment:
7
+ LIBRARY_TOKEN: ${LIBRARY_TOKEN:?LIBRARY_TOKEN must be set}
8
+ LIBRARY_DATA: /data
9
+ PORT: 4180
10
+ LIBRARY_FETCH_MODELS: ${LIBRARY_FETCH_MODELS:-0}
11
+ LIBRARY_MODELS: ${LIBRARY_MODELS:-/data/models}
12
+ LIBRARY_EMBED: ${LIBRARY_EMBED:-}
13
+ CHROME_SANDBOX: ${CHROME_SANDBOX:-0}
14
+ volumes:
15
+ - library-data:/data
16
+ restart: unless-stopped
17
+ volumes:
18
+ library-data:
File without changes
@@ -0,0 +1,76 @@
1
+ ---
2
+ slug: ais-community-dark-depth
3
+ title: 'AI Automation Society: dark community landing with mountain parallax hero'
4
+ tier: inspiration
5
+ source:
6
+ kind: paid
7
+ url: https://github.com/nateherkai/scroll-craft/blob/main/media/ais.webp
8
+ license: scroll-craft media file; description only, no redistribution of the source
9
+ captured:
10
+ method: frames
11
+ at: '2026-09-14'
12
+ tool: wp-design-library@0.1.0
13
+ media:
14
+ frames: 228
15
+ duration_ms: 22800
16
+ roles: [hero, explainer, capability, feature, proof, offer, faq, closing, footer]
17
+ feel: [dark, premium, saas, depth, minimal, cold]
18
+ palette:
19
+ canvas: '#0b1218'
20
+ ink: '#f2f5f8'
21
+ accent: '#7fb6ff'
22
+ type:
23
+ display: geometric sans (Montserrat-like), medium weight, tight tracking, two-line headlines with a soft break
24
+ body: same family, regular, small and muted at roughly 60% ink
25
+ motion:
26
+ devices: [parallax, drift, reveal, stagger, pin, stack, tabs]
27
+ notes: >-
28
+ Eyeballed from 12 sampled frames, not measured. The hero is a layered scene: a
29
+ dark-blue sky gradient, two mountain silhouettes and a treeline, with the
30
+ app frame in front. On scroll the frame rises faster than the bed and the
31
+ bed than the sky (parallax, three depths), and the frame keeps drifting up
32
+ into the next section before the about copy takes over. The about copy is
33
+ painted dim and brightens as it enters (reveal on the text block, not on a
34
+ wrapper). Then the stacked cards, the page's second set piece: four
35
+ feature cards (Courses, Weekly calls, Rooms, Certification) each pin at
36
+ the same screen position while the next slides up over it, so the reader
37
+ scrolls through a deck rather than down a list; the hand-off is a slide
38
+ from below, not a fade, and the covered card stays put underneath. Before
39
+ them, the "One place to learn" panel is a tabbed card (Classroom, Live
40
+ calls, Events, Leaderboards) whose active tab changes on its own. The
41
+ three pricing cards and the video cards arrive as staggered reveals. The
42
+ closing section repeats the hero scene and frame at a smaller scale, so
43
+ the page ends where it began.
44
+ ported_from: null
45
+ ---
46
+
47
+ ## What it does
48
+
49
+ A community landing page in a single dark world. The mountain scene is the
50
+ only illustration and it carries the whole feel; everything else is glassy
51
+ dark cards with hairline borders and one cool accent. Copy is short and
52
+ confident, section labels are small pill badges above each headline, and the
53
+ same sentence shape ("Where 450,000 people learn to use AI for real work")
54
+ returns at the end as "Start where 450,000 others started".
55
+
56
+ ## Section roster
57
+
58
+ hero (scene + floating app frame), explainer (two paragraphs of dimmed long
59
+ copy, no image), capability ("One place to learn": tabbed panel with a call
60
+ grid), feature (the stacked deck: Courses step by step with tiled chips,
61
+ Weekly calls with a call grid, Rooms with a photo, Certification with a
62
+ photo, each card pinning while the next covers it), offer (three tiers: free, monthly, cohort, with check lists), faq
63
+ (accordion with a side card), feature (video card row "Practical AI, every
64
+ week"), closing (hero scene repeated, small), footer (four columns).
65
+
66
+ ## Why it works
67
+
68
+ One illustration at three depths gives the page more presence than any number
69
+ of stock photos would, and because it is dark the glassy cards read as lit
70
+ from the scene. The dimmed-then-bright copy makes a wall of text feel like a
71
+ reveal instead of a paragraph. The stacked deck turns four feature cards into
72
+ one continuous motion, so the middle of the page has a set piece of its own
73
+ instead of a row of equal boxes. Reusing the hero scene at the close makes the
74
+ CTA feel like a return rather than a new ask. Cost to weigh: everything
75
+ depends on that one scene being good; with a weak illustration the page is
76
+ just dark cards.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@yojahny/wp-design-library",
3
+ "version": "0.1.0",
4
+ "description": "Design reference corpus and MCP server for claude-wp-builder",
5
+ "type": "module",
6
+ "bin": { "library": "bin/library.mjs" },
7
+ "engines": { "node": ">=22" },
8
+ "scripts": {
9
+ "test": "bash tests/run.sh",
10
+ "check": "node bin/library.mjs check",
11
+ "index": "node bin/library.mjs index",
12
+ "serve": "node bin/library.mjs serve"
13
+ },
14
+ "dependencies": {
15
+ "@huggingface/transformers": "4.2.0",
16
+ "@modelcontextprotocol/sdk": "^1.20.0",
17
+ "better-sqlite3": "^12.2.0",
18
+ "js-yaml": "^4.1.0",
19
+ "playwright-core": "1.63.0",
20
+ "sharp": "^0.34.3",
21
+ "sqlite-vec": "0.1.9",
22
+ "zod": "^3.25.0"
23
+ },
24
+ "license": "MIT",
25
+ "private": false
26
+ }