@typeroll/mcp-server 0.25.0 → 0.25.2

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.
@@ -6,7 +6,7 @@ function v(version) {
6
6
  export const deployTools = [
7
7
  {
8
8
  name: 'trigger_deploy',
9
- description: "Enqueue a deploy of the currently active version. Returns a job_id immediately; poll get_deploy_status until the status leaves queued/running. With `dry_run: true`, the deploy runs through every step EXCEPT the hosting-adapter upload — useful for validating a structural change (new collection routes, schema bump, mode switch) without risking the live site. A dry_run job that succeeds proves the build is clean; one that fails returns the same astro stack-trace in get_deploy_status.error as a real deploy would.",
9
+ description: "Enqueue a deploy (static-page build → hosting) of the currently active version. Returns a job_id immediately; poll get_deploy_status until the status leaves queued/running. NOT for previewing iterative design/content edits — that's what get_preview_link is for (it renders live from the DB with no build). Reserve trigger_deploy for publishing, a stakeholder link to the compiled site, or pre-merge validation. With `dry_run: true`, the deploy runs through every step EXCEPT the hosting-adapter upload — useful for validating a structural change (new collection routes, schema bump, mode switch) without risking the live site. A dry_run job that succeeds proves the build is clean; one that fails returns the same astro stack-trace in get_deploy_status.error as a real deploy would.",
10
10
  inputSchema: {
11
11
  environment: z.enum(['production', 'staging']).optional(),
12
12
  dry_run: z.boolean().optional().describe('Run the build but skip the CDN upload. The job ends in succeeded/failed the same way a real deploy does.'),
@@ -6,7 +6,7 @@ function v(version) {
6
6
  export const previewTools = [
7
7
  {
8
8
  name: 'get_preview_link',
9
- description: 'Mint a short-lived signed URL the user (or your own browser tool) can open to SEE the rendered preview. Internal links inside the preview keep the same token attached, so the agent can navigate the whole site from one mint. Default TTL 15 min, max 24h. Target the preview at a page (page_id), a collection item (collection_name + item_id, resolves via route_template), or a raw slug. Omit all to land on the home page.',
9
+ description: 'Mint a signed URL the user (or your own browser tool) can open to SEE the rendered preview. Renders LIVE from the database with NO build, so reloading shows every edit immediately — this is the PREFERRED way to preview design/content changes as you iterate (and to verify your own edits); reach for this, not trigger_deploy, when previewing. Mint it ONCE with the long TTL (ttl_seconds: 86400 = 24h max) and REUSE that single URL across edits — internal links keep the token so it navigates the whole branch, and it stays valid until the TTL lapses (re-mint only then, never per edit). Default TTL 15 min, max 24h. Target a page (page_id), a collection item (collection_name + item_id, resolves via route_template), or a raw slug; omit all to land on the home page. (For a permanently-bookmarkable link to the COMPILED static site, deploy and share the {branch}.{project}.pages.dev alias instead — that only refreshes on trigger_deploy.)',
10
10
  inputSchema: {
11
11
  page_id: z.string().optional(),
12
12
  slug: z.string().optional(),
package/dist/version.js CHANGED
@@ -1,7 +1,11 @@
1
- // Single source of truth for the server version reported in the MCP
2
- // `initialize` handshake. Derived from package.json at runtime so it can
3
- // never drift from the published version (bumping package.json is enough).
4
- import { createRequire } from 'node:module';
5
- const require = createRequire(import.meta.url);
6
- const pkg = require('../package.json');
7
- export const VERSION = pkg.version;
1
+ // Server version reported in the MCP `initialize` handshake.
2
+ //
3
+ // MUST be a plain literal NOT a runtime `require('../package.json')`. This
4
+ // module is bundled into the portal's /api/mcp route (hosted MCP), where
5
+ // `import.meta.url` points at the bundled file and `../package.json` does not
6
+ // resolve a require there throws at module load and 500s the whole route.
7
+ // The literal works in every context (standalone npm package + bundled portal).
8
+ //
9
+ // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
+ // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
+ export const VERSION = '0.25.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,301 @@
1
+ ---
2
+ name: tr-design-review
3
+ description: Use to review a deployed/previewed Typeroll page like a designer — a MEASURED multi-dimension pass (responsive, a11y, functional, content, SEO, performance) that emits a per-dimension scorecard and an explicit OK verdict. Run it before telling the user a design is approved; it's the "how" for tr-redesign-branch's approval round.
4
+ ---
5
+
6
+ # Review a design — measured, not glanced
7
+
8
+ A design review is a MEASUREMENT, not a look. The failure mode is reporting
9
+ "looks good" off a couple of screenshots — which silently misses overflow at
10
+ untested widths, sub-AA contrast, broken/blank images, and small touch targets.
11
+ This skill is the deterministic routine: per dimension, a check you RUN (a
12
+ browser-eval snippet or a curl), and a scorecard you fill with PASS / FAIL /
13
+ UNTESTED. Never report "approved" off a partial pass — list what you didn't test
14
+ as caveats.
15
+
16
+ `tr-redesign-branch` step 6 lists the dimensions (the "what"). This is the "how".
17
+
18
+ ## Cardinal rule: a screenshot is evidence, not proof
19
+
20
+ **Full-page screenshots lie about lazy-loaded images.** A page with
21
+ `loading="lazy"` images below the fold will screenshot with BLANK boxes where
22
+ those images sit — they hadn't entered the viewport when the capture fired. If
23
+ you trust that, you will report a non-existent "empty illustration box" gap.
24
+ (This has happened — on a real review, across three variants at once.)
25
+
26
+ So, always:
27
+
28
+ - **Before any full-page capture**, scroll the whole page to trigger lazy loads
29
+ and let it settle (snippet in §5), THEN screenshot.
30
+ - **Verify every suspected blank/broken image via the DOM** (`naturalWidth` after
31
+ scroll), never from the screenshot. A real broken image has `complete === true
32
+ && naturalWidth === 0`; a lazy one that just hasn't loaded has `complete ===
33
+ false` — scroll it into view and re-check before calling it broken.
34
+
35
+ ## Setup
36
+
37
+ 1. Get a URL for the version under review. While iterating, use the DB-live
38
+ `get_preview_link` (mint once at `ttl_seconds: 86400`, reuse) — it renders
39
+ from the DB with no build, so fixes show on reload without re-deploying, and
40
+ it's the loop for the review-fix-recheck cycle. For a FINAL bit-for-bit check
41
+ of the compiled output before merge, deploy once (`trigger_deploy
42
+ version="<branch>"` → poll `get_deploy_status` → use the immutable
43
+ `deploy_url`, a Cloudflare Pages hash URL). Review the SAME url end to end.
44
+ 2. The snippets below run in a browser tool's "evaluate JavaScript" (Playwright /
45
+ chrome-devtools / puppeteer MCP). **One origin per eval:** the iframe trick
46
+ needs same-origin, so run each variant's snippet on its own page (different
47
+ `*.pages.dev` hashes are cross-origin → `contentDocument` is null).
48
+ 3. If you run several variants with one shared browser profile, do them
49
+ SEQUENTIALLY — parallel browser agents on one profile contaminate each other's
50
+ tabs/screenshots.
51
+
52
+ ## The dimensions — run each, record the result
53
+
54
+ ### 1. Responsive — width ladder 390/768/1024/1440/1920, zero overflow
55
+
56
+ Measure horizontal overflow at every width in ONE eval using same-origin iframes
57
+ (each iframe is its own layout viewport, so `@media` fires correctly — no 15
58
+ resizes):
59
+
60
+ ```js
61
+ async () => {
62
+ const url = location.href, out = [];
63
+ for (const w of [390,768,1024,1440,1920]) {
64
+ const f = document.createElement('iframe');
65
+ f.style.cssText = `width:${w}px;height:2400px;border:0;position:fixed;left:-99999px;top:0`;
66
+ document.body.appendChild(f);
67
+ await new Promise(r => { f.onload = r; f.src = url; });
68
+ await new Promise(r => setTimeout(r, 700));
69
+ const d = f.contentDocument, culprits = [];
70
+ for (const el of d.body.querySelectorAll('*')) {
71
+ const r = el.getBoundingClientRect();
72
+ if (r.right > w + 1 && r.width <= w + 40 && r.width > 4)
73
+ culprits.push(el.tagName.toLowerCase() + '.' + (el.className||'').toString().slice(0,40));
74
+ }
75
+ out.push({ w, hOverflow: d.documentElement.scrollWidth - w, n: culprits.length, sample: [...new Set(culprits)].slice(0,6) });
76
+ f.remove();
77
+ }
78
+ return out;
79
+ }
80
+ ```
81
+
82
+ PASS = `hOverflow <= 0` at every width. A decorative element flagged while
83
+ `hOverflow` is 0 is clipped by an `overflow:hidden` parent (no scrollbar) — a
84
+ non-issue. Then eyeball one tablet (768) capture for stacking — but capture
85
+ AFTER the scroll-settle in §5.
86
+
87
+ ### 2. Accessibility — compute contrast, don't eyeball
88
+
89
+ ```js
90
+ () => {
91
+ const L = c => { const a = c.map(v => (v/=255, v<=.03928?v/12.92:((v+.055)/1.055)**2.4)); return .2126*a[0]+.7152*a[1]+.0722*a[2]; };
92
+ const P = c => { const m = c.match(/rgba?\(([^)]+)\)/); if(!m) return null; const p = m[1].split(',').map(parseFloat); return {rgb:[p[0],p[1],p[2]], a:p[3]??1}; };
93
+ const R = (f,b) => { const x=L(f),y=L(b),h=Math.max(x,y),l=Math.min(x,y); return (h+.05)/(l+.05); };
94
+ const bg = el => { let e=el; while(e){ const s=getComputedStyle(e); if(s.backgroundImage!=='none') return {img:1}; const c=P(s.backgroundColor); if(c&&c.a>.5) return {rgb:c.rgb}; e=e.parentElement; } return {rgb:[255,255,255]}; };
95
+ const bad=[], seen=new Set();
96
+ for (const el of document.body.querySelectorAll('*')) {
97
+ const t=[...el.childNodes].filter(n=>n.nodeType===3&&n.textContent.trim()).map(n=>n.textContent.trim()).join(' ');
98
+ if(!t) continue;
99
+ const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) continue;
100
+ const s=getComputedStyle(el); if(s.visibility==='hidden'||s.display==='none'||+s.opacity<.1) continue;
101
+ const fg=P(s.color); if(!fg) continue;
102
+ const b=bg(el); if(b.img) continue; // can't compute over an image — eyeball hero text separately
103
+ const cr=R(fg.rgb,b.rgb), fs=parseFloat(s.fontSize), fw=+s.fontWeight||400;
104
+ const need = (fs>=24||(fs>=18.66&&fw>=700)) ? 3 : 4.5;
105
+ if (cr<need) { const k=t.slice(0,30)+cr.toFixed(2); if(seen.has(k))continue; seen.add(k);
106
+ bad.push({txt:t.slice(0,45), ratio:+cr.toFixed(2), need, fs:Math.round(fs), fw, color:s.color, bg:'rgb('+b.rgb.join(',')+')'}); }
107
+ }
108
+ return { failures: bad.length, items: bad.slice(0,15) };
109
+ }
110
+ ```
111
+
112
+ PASS = 0 failures (AA: body ≥4.5:1, large/UI ≥3:1). Fix a failure by deepening
113
+ the offending colour token. Text over an image background is skipped — eyeball
114
+ those (hero overlays) for legibility separately.
115
+
116
+ Structure + alt + landmarks, same eval session:
117
+
118
+ ```js
119
+ () => {
120
+ const h=[...document.querySelectorAll('h1,h2,h3,h4')].map(e=>+e.tagName[1]);
121
+ const skips=h.map((v,i)=>i&&v-h[i-1]>1?`${h[i-1]}->${v}`:0).filter(Boolean);
122
+ const imgs=[...document.querySelectorAll('img')];
123
+ const inputs=[...document.querySelectorAll('input:not([type=hidden]),textarea,select')];
124
+ const labelFor=new Set([...document.querySelectorAll('label[for]')].map(l=>l.getAttribute('for')));
125
+ return {
126
+ h1: h.filter(x=>x===1).length, levelSkips: skips,
127
+ imgsMissingAlt: imgs.filter(i=>i.getAttribute('alt')===null).length,
128
+ landmarks: ['header','nav','main','footer'].filter(t=>document.querySelector(t)),
129
+ unlabeledInputs: inputs.filter(i=>!(i.id&&labelFor.has(i.id))&&!i.getAttribute('aria-label')).map(i=>i.name||i.id),
130
+ };
131
+ }
132
+ ```
133
+
134
+ PASS = exactly one `h1`, `levelSkips` empty, `imgsMissingAlt` 0, all four
135
+ landmarks present, `unlabeledInputs` empty. (Decorative images SHOULD have
136
+ `alt=""` — that's not "missing".)
137
+
138
+ Touch targets — interactive elements ≥44px at mobile. Run in a 390px iframe;
139
+ EXCLUDE `aria-hidden` (the form honeypot is a visible-sized but hidden input —
140
+ counting it is a false positive) and inline text links inside `p`/`li`:
141
+
142
+ ```js
143
+ async () => {
144
+ const f=document.createElement('iframe');
145
+ f.style.cssText='width:390px;height:2400px;border:0;position:fixed;left:-99999px;top:0';
146
+ document.body.appendChild(f);
147
+ await new Promise(r=>{ f.onload=r; setTimeout(r,3000); f.src=location.href; });
148
+ await new Promise(r=>setTimeout(r,700));
149
+ const d=f.contentDocument, small=[];
150
+ if(d) for (const el of d.querySelectorAll('a,button,input:not([type=hidden]),textarea,select,[role=button]')) {
151
+ const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) continue;
152
+ const s=getComputedStyle(el); if(s.display==='none'||s.visibility==='hidden'||+s.opacity<.1) continue;
153
+ if(el.getAttribute('aria-hidden')==='true') continue;
154
+ if(el.tagName==='A'&&el.closest('p,li')) continue;
155
+ if(r.height<44||r.width<44) small.push({tag:el.tagName.toLowerCase(), txt:(el.innerText||el.value||el.getAttribute('aria-label')||'').trim().slice(0,24), w:Math.round(r.width), h:Math.round(r.height)});
156
+ }
157
+ f.remove(); return { undersized: small };
158
+ }
159
+ ```
160
+
161
+ Also confirm `:focus-visible` and `prefers-reduced-motion` exist (grep the page
162
+ HTML: `grep -c 'focus-visible' page.html`, `grep -c 'prefers-reduced-motion'`).
163
+ Note honestly: presence in CSS ≠ verified per-element — tab through live if you
164
+ claim keyboard focus works.
165
+
166
+ ### 3. Functional — console, links, form
167
+
168
+ - **Console:** read the browser tool's console messages after load. PASS = 0
169
+ errors/warnings.
170
+ - **Links:** PASS = no `href="#"`/empty; every in-page `#anchor` has a matching
171
+ `id`.
172
+ - **Form (markup — does NOT prove a live submit):** curl the page and verify the
173
+ `<form>` `action` is the real submit endpoint, the hidden `_token` is
174
+ non-empty, the honeypot is present + `aria-hidden`, required fields have
175
+ `required`, the email field is `type="email"`. State explicitly that you did
176
+ NOT submit (a live POST creates a real submission) unless you actually did.
177
+
178
+ ### 4. Content — verbatim, no placeholders
179
+
180
+ `grep -Ei 'lorem|ipsum|\{\{|placeholder|TODO|FIXME' page.html` → 0. Copy matches
181
+ the live page (the source of truth) verbatim.
182
+
183
+ ### 5. Broken / blank images (the anti-lazy-load check — run THIS before trusting any screenshot)
184
+
185
+ ```js
186
+ async () => {
187
+ const H=document.body.scrollHeight;
188
+ for(let y=0;y<=H;y+=400){ window.scrollTo(0,y); await new Promise(r=>setTimeout(r,120)); }
189
+ window.scrollTo(0,0); await new Promise(r=>setTimeout(r,1500));
190
+ const imgs=[...document.querySelectorAll('img')];
191
+ const empty=[]; // genuinely empty boxes: large, no text/img/svg/bg-image
192
+ for (const el of document.querySelectorAll('div,section,figure')) {
193
+ const r=el.getBoundingClientRect(); if(r.width<160||r.height<140) continue;
194
+ if((el.innerText||'').trim()||el.querySelector('img,svg,picture,canvas,video')) continue;
195
+ if(getComputedStyle(el).backgroundImage!=='none') continue;
196
+ empty.push({cls:(el.className||'').toString().slice(0,36), w:Math.round(r.width), h:Math.round(r.height)});
197
+ }
198
+ return {
199
+ broken: imgs.filter(i=>i.complete&&i.naturalWidth===0).map(i=>i.src.slice(-45)), // real failures
200
+ stillLoading: imgs.filter(i=>!i.complete).map(i=>i.src.slice(-45)), // lazy, scroll first
201
+ emptyBoxes: empty.slice(0,8), // true placeholders
202
+ };
203
+ }
204
+ ```
205
+
206
+ PASS = `broken` empty, `emptyBoxes` empty. A non-empty `emptyBoxes` is a genuine
207
+ unfilled illustration slot (fill it — pages shouldn't be text deserts). NOW
208
+ capture screenshots (the page is scrolled-and-settled, images loaded).
209
+
210
+ ### 5b. Clipped artwork — the logo (and any brand image) cut off by its own frame
211
+
212
+ The single most-repeated visual bug: the header logo rendered with its top/edges
213
+ sliced. It produces ZERO page overflow (§1 misses it), the image isn't broken
214
+ (§5 misses it), and at full-page screenshot scale a few clipped pixels are easy
215
+ to glance past. So MEASURE it: does the artwork's rendered content touch the edge
216
+ of its own box on any side? Content flush against the frame (gap ≈ 0) = clipped
217
+ or about-to-clip. Don't just check the logo — check it, then trust the number.
218
+
219
+ For a raster/`<img>` logo, draw it to a same-origin canvas and scan the border
220
+ rows/cols for opaque pixels (cross-origin taints the canvas — fetch the asset to
221
+ a localhost file first, as in §setup, or measure on the asset directly):
222
+
223
+ ```js
224
+ async (url) => { // url = the logo's currentSrc, served same-origin
225
+ const img = new Image(); await new Promise((r,e)=>{img.onload=r;img.onerror=e;img.src=url;});
226
+ const h = 64, w = Math.round(h*img.naturalWidth/img.naturalHeight);
227
+ const c = document.createElement('canvas'); c.width=w; c.height=h;
228
+ const x = c.getContext('2d'); x.drawImage(img,0,0,w,h);
229
+ const d = x.getImageData(0,0,w,h).data, op=(px)=>d[px*4+3]>20;
230
+ let top=h,bot=0,left=w,right=0;
231
+ for(let y=0;y<h;y++)for(let xx=0;xx<w;xx++)if(op(y*w+xx)){top=Math.min(top,y);bot=Math.max(bot,y);left=Math.min(left,xx);right=Math.max(right,xx);}
232
+ return { topGap:top, bottomGap:h-1-bot, leftGap:left, rightGap:right }; // any 0 → flush/clipped
233
+ }
234
+ ```
235
+
236
+ PASS = every gap ≥ ~2% of the dimension. A `0` on any side means the artwork (or
237
+ its stroke) sits on the frame — for an SVG that's a viewBox trimmed flush to the
238
+ art (look for `-trim`/`-tight` in the filename); the fix is to re-export the SVG
239
+ with viewBox padding (e.g. widen `viewBox` by ~8% each side) so the stroke never
240
+ touches the edge. Verify the fix by re-running this with the patched asset. (A
241
+ heavy `stroke-width` + `paint-order="stroke"` outline makes a flush viewBox clip
242
+ visibly — and small header renders make it worse, so also check the logo isn't
243
+ shrunk below ~64–72px in the header.)
244
+
245
+ Also confirm no ANCESTOR clips the logo: walk the logo's parents for
246
+ `overflow:hidden|clip` combined with a fixed height or negative/overlap margin —
247
+ and always judge the logo from a screenshot of the header REGION in context,
248
+ never the logo element in isolation (an element screenshot re-renders the full
249
+ art and hides the clip).
250
+
251
+ **The inverse bug — an image FLOATING inside its frame (don't blame the file).**
252
+ A full-bleed illustration that renders with a margin of empty frame around it
253
+ usually isn't a bad asset — it's CSS. In blocks-mode the site-template's global
254
+ `:where(.page-content) img{ margin:1rem 0 }` (and a default `border-radius`)
255
+ leaks onto any `<img>` you didn't reset, so a framed hero/figure gets a 1rem gap
256
+ inside its frame and looks like it "floats". Before re-cropping or regenerating,
257
+ **open the actual image file** (`curl` the `.avif`/`.png`) — if the motif fills
258
+ the file edge-to-edge, the float is CSS: set `margin:0` (and `border-radius:0`)
259
+ on the framed `<img>` (e.g. `.your-frame img{margin:0}` or a blanket
260
+ `.your-scope img{margin:0}`). Measure it: the `<img>`'s `getBoundingClientRect`
261
+ should equal its frame's inner box (no gap). Only when the *file itself* has
262
+ built-in background margin (motif ≪ frame) is cropping the right fix.
263
+
264
+ ### 6. Findable (SEO/meta) — curl, fast
265
+
266
+ `<title>` (≤60 chars) + meta description present + sensible; `og:title/description/image`;
267
+ `canonical`; `favicon` + `apple-touch-icon`; `<html lang>`; branches must be
268
+ `noindex`. One curl + greps covers it.
269
+
270
+ ### 7. Fast (performance)
271
+
272
+ PASS signals: no render-blocking JS you didn't add; responsive variants
273
+ (`srcset` + AVIF/WebP) so a 1024px asset isn't shipped to a 380px slot;
274
+ `width`/`height` or aspect-ratio set (no layout shift); **below-fold images
275
+ `loading="lazy"`, above-fold `eager`**. Flag a section that eager-loads every
276
+ image, or a multi-hundred-KB original served when a small AVIF variant exists.
277
+
278
+ ### 8. Cross-browser
279
+
280
+ The same CSS renders differently per engine. Re-check in another engine if you
281
+ can. If only Chromium is available, say so as UNTESTED and statically flag risky
282
+ props: `backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on
283
+ mobile (prefer `100svh`), `position:sticky` inside `overflow`.
284
+
285
+ ## Deliver a scorecard + an explicit verdict
286
+
287
+ Report a table — one row per dimension, value PASS / FAIL(detail) / UNTESTED —
288
+ then a one-line verdict. Rules:
289
+
290
+ - "Approved" requires PASS on responsive, a11y, functional, content, SEO,
291
+ performance. Untested dimensions (commonly cross-browser, live form submit) are
292
+ listed as CAVEATS, not silently dropped — an OK with caveats is honest; an
293
+ unqualified "approved" off a partial pass is not.
294
+ - Brand FIT (palette/voice matching `brand.md`) is a direction judgment, not a
295
+ pass/fail defect — call it out separately so the user decides direction.
296
+ - If you fixed anything mid-review, just reload the DB-live preview and re-run
297
+ the affected dimension before signing off — no re-deploy needed (deploy only
298
+ for the final compiled-output check, if any).
299
+
300
+ See `tr-redesign-branch` for the surrounding branch → preview → approve → merge
301
+ flow; this skill is its measured approval round.
@@ -71,6 +71,34 @@ Save the response's `id` — pass it as `version=<id>` on every
71
71
  subsequent call. Branches default `robots_blocked: true` so a
72
72
  half-finished redesign won't be indexed.
73
73
 
74
+ ### 2b. Write the design spec (REQUIRED — before you build)
75
+
76
+ Every redesign branch MUST carry a written design spec. Without it the
77
+ design choices live only in the agent's head, so a later "just tweak the
78
+ illustration style / palette" means re-deriving everything by hand (this
79
+ gap cost a real project a full reverse-engineering pass). Write it BEFORE
80
+ building so it guides the work, and keep it in sync as the design evolves.
81
+
82
+ Save it as a markdown doc with the project (e.g. `design-spec.md`, or
83
+ `prompts/design-system.md`) — or, if there's no local working dir, as an
84
+ unlisted page on the branch. It must capture:
85
+
86
+ - **Palette** — every role + hex (background, surface, primary, accent,
87
+ text, borders), and where the variant *diverges* from the brand and why.
88
+ - **Typography** — fonts + weights/sizes per role.
89
+ - **Illustration / imagery style** — the exact image-gen prompt prefix
90
+ (tone, palette, formspråk, framing rules), so the imagery can be
91
+ regenerated or restyled on its own without touching layout. State the
92
+ business/concept constraints the imagery must respect (a wrong-concept
93
+ image is worse than none).
94
+ - **Section structure** — the page's sections in order + each one's
95
+ treatment (band colour, layout).
96
+ - **Rationale** — one line per major choice: *why* this direction.
97
+
98
+ When the user later says "adjust just the illustrations" or "change the
99
+ palette", you edit the spec first, then apply — the spec is the source of
100
+ truth for the design intent.
101
+
74
102
  ### 3. Iterate on the branch
75
103
 
76
104
  For each redesign step:
@@ -122,43 +150,86 @@ bulk_replace_text pattern="OldCo" replacement="NewCo" dry_run=false version="<br
122
150
 
123
151
  ### 6. Approval round
124
152
 
125
- **First, self-review the visualsappearance AND readability.** Structural
126
- checks (copy present, no overflow, images return 200) are NOT a design review
127
- and must never be reported as "approved". Screenshot the deployed branch at
128
- desktop (~1440px) AND mobile (~390px) and actually look:
129
-
130
- - **Logo & brand marks:** fully visible (not cut off by a header's
131
- `overflow:hidden` + an overlap/negative margin), legible with real contrast
132
- against their *actual* background, and brand-compliante.g. a light/yellow
133
- wordmark must not sit bare on a light surface (give it its plate/backing).
134
- Screenshot the rendered HEADER REGION **in page context** never the logo
135
- element in isolation: an element screenshot renders the full SVG and hides
136
- layout clipping, so a logo that's cut in half on the page looks perfect.
137
- - **Contrast & readability:** every text-on-background pairing (headings, body,
138
- buttons, cards on colored bands), not just the obvious ones.
139
- - **Layout:** no horizontal scroll (`scrollWidth === clientWidth` at 360–390px),
140
- no mid-word breaks, nothing clipped or overflowing, clean alignment / spacing /
141
- hierarchy. Confirm every image rendered (scroll lazy ones into view first).
142
- - **Both breakpoints:** a grid fine on desktop can fail to collapse on mobile
143
- (see `tr-responsive` gotchas). Check mobile explicitly.
144
- - **Decoration robustness, in the real browser:** every decorative shape / glow /
145
- gradient is clean at real size in the actual target browser (Chrome) — no
146
- hairline seam between a shape divider and its section, no glow clipped to a hard
147
- edge, no faces cropped by a frame, no gradient fade-cutoff at a section join.
148
- These don't show in a thumbnail (see "Restraint beats decoration" above).
153
+ **Self-review is a multi-DIMENSION pass, not a glance and most of it you
154
+ MEASURE, not eyeball.** Structural checks (copy present, images return 200) are
155
+ NOT a design review; never report "approved" off them. Don't just list the bugs
156
+ you happened to notice walk every dimension below on the DB-live preview
157
+ (a reused `get_preview_link`; browser tool + DOM reads), fix what you find,
158
+ reload, re-check. No re-deploy between fixes the preview renders from the DB.
159
+
160
+ **Use `tr-design-review` (`read_skill tr-design-review`) for the HOW** it has
161
+ the per-dimension measurement snippets (overflow ladder, computed contrast, touch
162
+ targets, the anti-lazy-load broken-image check) and the scorecard + verdict
163
+ format. The dimension summary below is the "what"; that skill is the runnable
164
+ routine. In particular: scroll-and-settle to trigger lazy images BEFORE any
165
+ full-page screenshot, or you'll report blank boxes that aren't real.
166
+
167
+ 1. **Responsive** screenshot across a width ladder (mobile / tablet / laptop /
168
+ desktop / wide ≈390 / 768 / 1024 / 1440 / 1920px) AND sweep the page's own
169
+ `@media` breakpoints (read the page-scoped `<style>`; resize a few px below +
170
+ above each). At EVERY width: `document.documentElement.scrollWidth <=
171
+ clientWidth` (no horizontal scroll), grids flip cleanly, nothing squished /
172
+ orphaned / overlapping, no mid-word breaks. Two sizes is not enough bugs hide
173
+ in between. Also check a short/landscape viewport and 200% browser zoom.
174
+ 2. **Visual & brand** logo FULLY VISIBLE (not clipped by a header
175
+ `overflow:hidden` + overlap margin) and brand-compliant; screenshot the header
176
+ IN CONTEXT, never the logo element in isolation (that hides clipping).
177
+ Decoration robust at real size in the real browser: no hairline seam at a
178
+ divider (use `core/section` `divider_top`/`divider_bottom` — don't hand-roll a
179
+ band), no glow clipped to a hard edge, no `object-fit:cover` cropping faces, no
180
+ gradient fade-cutoff. Typography: body line-length ~45–75ch, consistent scale,
181
+ no awkward widows on headings. Palette adherence (no off-brand colours);
182
+ consistent spacing / alignment / radius / shadow.
183
+ 3. **Accessibility — MEASURE, don't eyeball** — compute actual contrast ratios
184
+ (WCAG AA: body ≥4.5:1, large/UI ≥3:1) and fix failures by deepening the
185
+ offending colour token; meaningful `alt` on every image; exactly one `<h1>` +
186
+ no skipped heading levels; visible `:focus-visible` on every interactive
187
+ element; every input has an associated `<label>`; touch targets ≥44px on
188
+ mobile; semantic landmarks (header/nav/main/footer) + nav `aria-label`; honour
189
+ `prefers-reduced-motion`.
190
+ 4. **Functional** — the form actually works (POST action correct, hidden token
191
+ non-empty, honeypot present + hidden; a long name/email doesn't break layout);
192
+ every link + in-page anchor resolves (each `#anchor` has a matching `id`; no
193
+ `href="#"`/`""`); ZERO console errors/warnings; interactions (menu toggle,
194
+ hover/focus/active) work.
195
+ 5. **Content** — no unrendered `{{…}}` tokens in the DOM; no placeholder/lorem;
196
+ copy still matches the source of truth (the live page) verbatim.
197
+ 6. **Findable (SEO/meta)** — `<title>` + meta description present + sensible;
198
+ `og:title`/`og:description`/`og:image`; canonical; favicon + apple-touch-icon;
199
+ `<html lang>`; `noindex` correct (branches must be noindex).
200
+ 7. **Fast (performance)** — images at sane sizes (not a 2048px file shown at
201
+ 380px without a responsive variant), modern format (avif/webp), `width`/`height`
202
+ or aspect-ratio set (no layout shift), below-fold lazy / above-fold eager.
203
+ 8. **Cross-browser** — the same CSS renders differently per engine (the divider
204
+ seam was Chrome-only; WebKit/Firefox have their own). Re-check in another engine
205
+ if you can; if only Chromium is available, statically flag risky props
206
+ (`backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on mobile →
207
+ prefer `100svh`, `sticky` inside `overflow`).
149
208
 
150
209
  Fix what you find and re-check before involving the user. "Looks structurally
151
- fine" ≠ "looks good" never tell the user a design is approved/perfect off
152
- metrics alone.
153
-
154
- Then send the user a final preview link:
155
-
156
- ```
157
- get_preview_link page_id=home version="<branch>" ttl_seconds=86400
158
- ```
159
-
160
- The 24h TTL gives them time to share with stakeholders. Wait for an
161
- explicit "looks good, ship it."
210
+ fine" ≠ "looks good", and "looks good in Chrome at 1440" "works for everyone,
211
+ everywhere" — never report a design as approved/perfect off a glance or a partial
212
+ pass.
213
+
214
+ Then give the user the **DB-live preview link** to review — and use the SAME
215
+ link for your own verification:
216
+
217
+ - **While iterating (default):** a reused `get_preview_link` (mint once at
218
+ `ttl_seconds: 86400`). It renders from the database with NO build, so every
219
+ edit shows on reload, and one link navigates the whole branch (internal
220
+ links keep the token). The token URL is stable across edits — re-mint only
221
+ when the 24h lapses, never per edit. Do NOT deploy just to let the user (or
222
+ yourself) see a change.
223
+ - **When they want the COMPILED static site** (a permanent bookmark, a
224
+ stakeholder link to the built output, or a final pre-merge check): deploy
225
+ the branch once (`trigger_deploy version="<branch>"`) and share the stable
226
+ alias `https://<branch>.<project>.pages.dev` (the `<project>` is the part
227
+ after the hash in the returned `deploy_url`). Branch deploys are
228
+ `robots_blocked`, so it won't be indexed. The immutable per-deploy
229
+ `<hash>.pages.dev` is for your own one-off checks (a new hash each deploy).
230
+
231
+ Default: review/iterate on the reused DB-live preview; deploy only for the
232
+ compiled output or merge. Wait for an explicit "looks good, ship it."
162
233
 
163
234
  ### 7. Merge + deploy
164
235
 
@@ -185,9 +256,11 @@ disk cost is tiny.)
185
256
  - **Skipping discovery.** "Modernize" without first reading the site
186
257
  produces a confidently-out-of-place result. Always sample existing
187
258
  pages.
188
- - **Editing the in-portal preview URL by mistake.** That's the user's
189
- own preview, not yours. `get_preview_link` returns a signed
190
- external URL always use that for sharing.
259
+ - **Deploying to preview.** Don't `trigger_deploy` after every edit just to
260
+ see the change that builds static pages and the URL is only as fresh as
261
+ the last build. Iterate on a reused DB-live `get_preview_link` (renders from
262
+ the DB, reflects edits on reload); deploy only for the compiled static
263
+ output or merge (steps 6–7).
191
264
  - **Auto-merge.** Don't `merge_branch` without explicit user sign-off.
192
265
  Once merged, the only undo is another branch + reverse edits.
193
266
  - **Header rewrites that drop the brand block.** Even when the