@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.
- package/.dockerignore +11 -0
- package/.env.example +7 -0
- package/Dockerfile +27 -0
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/bin/library.mjs +19 -0
- package/docker/entrypoint.sh +11 -0
- package/docker-compose.yml +18 -0
- package/entries/.gitkeep +0 -0
- package/entries/ais-community-dark-depth/entry.md +76 -0
- package/entries/ais-community-dark-depth/strip.png +0 -0
- package/package.json +26 -0
- package/src/cli/add.mjs +163 -0
- package/src/cli/check.mjs +26 -0
- package/src/cli/export.mjs +41 -0
- package/src/cli/index.mjs +12 -0
- package/src/cli/refresh.mjs +63 -0
- package/src/cli/save.mjs +16 -0
- package/src/cli/serve.mjs +64 -0
- package/src/cli/ui.mjs +18 -0
- package/src/entry.mjs +69 -0
- package/src/index/build.mjs +78 -0
- package/src/index/embed.mjs +69 -0
- package/src/index/query.mjs +103 -0
- package/src/index/schema-vec.sql +4 -0
- package/src/index/schema.sql +10 -0
- package/src/ingest/draft.mjs +32 -0
- package/src/ingest/frames.mjs +93 -0
- package/src/ingest/measure.mjs +158 -0
- package/src/ingest/save.mjs +11 -0
- package/src/ingest/url-guard.mjs +76 -0
- package/src/mcp/http.mjs +72 -0
- package/src/mcp/prompts.mjs +147 -0
- package/src/mcp/resources.mjs +24 -0
- package/src/mcp/server.mjs +28 -0
- package/src/mcp/tools.mjs +152 -0
- package/src/paths.mjs +21 -0
- package/src/ui/app.js +34 -0
- package/src/ui/build.mjs +122 -0
- package/src/ui/serve.mjs +38 -0
- package/src/ui/templates/entry.html +31 -0
- package/src/ui/templates/index.html +32 -0
- package/src/vocab.mjs +34 -0
- package/tests/README.md +21 -0
- package/tests/checks/cli-check.sh +13 -0
- package/tests/checks/docker.sh +10 -0
- package/tests/checks/entry.sh +55 -0
- package/tests/checks/export.sh +16 -0
- package/tests/checks/fetch-on-start.sh +26 -0
- package/tests/checks/frames-dense.sh +26 -0
- package/tests/checks/http.sh +22 -0
- package/tests/checks/hygiene.sh +29 -0
- package/tests/checks/inbox.sh +27 -0
- package/tests/checks/index-degrade.sh +32 -0
- package/tests/checks/index.sh +36 -0
- package/tests/checks/ingest-mp4.sh +47 -0
- package/tests/checks/ingest.sh +39 -0
- package/tests/checks/licence-gate.sh +24 -0
- package/tests/checks/mcp-stdout.sh +31 -0
- package/tests/checks/measure.sh +75 -0
- package/tests/checks/minors.sh +84 -0
- package/tests/checks/prompt-add-entry.sh +28 -0
- package/tests/checks/resources.sh +44 -0
- package/tests/checks/rrf.sh +120 -0
- package/tests/checks/seed-sync.sh +22 -0
- package/tests/checks/similar.sh +62 -0
- package/tests/checks/ui-build.sh +59 -0
- package/tests/checks/vocab.sh +30 -0
- package/tests/fixtures/entry-ok/entry.md +41 -0
- package/tests/fixtures/entry-ok/strip.png +0 -0
- package/tests/fixtures/page/index.html +47 -0
- package/tests/fixtures/three-frame.webp +0 -0
- package/tests/run.sh +10 -0
- package/vocab.yaml +16 -0
package/.dockerignore
ADDED
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.
|
package/bin/library.mjs
ADDED
|
@@ -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:
|
package/entries/.gitkeep
ADDED
|
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.
|
|
Binary file
|
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
|
+
}
|