@typeroll/mcp-server 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -27,6 +27,7 @@ import { siteTools } from './tools/sites.js';
27
27
  import { domainTools } from './tools/domain.js';
28
28
  import { skillTools } from './tools/skills.js';
29
29
  import { fail } from './tools/helpers.js';
30
+ import { VERSION } from './version.js';
30
31
  const PERM_RANK = { read: 0, write: 1, admin: 2 };
31
32
  /**
32
33
  * Classify a tool by name into the minimum permission needed. We keep this
@@ -48,7 +49,7 @@ function effectFor(name) {
48
49
  }
49
50
  return 'write';
50
51
  }
51
- const DEFAULT_INFO = { name: 'typeroll', version: '0.22.0' };
52
+ const DEFAULT_INFO = { name: 'typeroll', version: VERSION };
52
53
  /**
53
54
  * Server-level instructions — returned in the MCP `initialize` response and
54
55
  * surfaced to the model by every client (Claude Code stdio AND the hosted
@@ -236,13 +236,20 @@ export const pageTools = [
236
236
  // the block-tree mutation tools (add/update/move/remove/convert).
237
237
  {
238
238
  name: 'get_page_preview',
239
- description: 'Get rendered HTML for one page (header-authed read). Returns { rendered_html, internal_links[] }. For a clickable preview URL use get_preview_link instead.',
239
+ description: "Get the WHOLE page rendered as one self-contained HTML document — header partial + block-rendered body + footer partial, with the site's settings CSS variables, global styles, and the tree-shaken block-CSS bundle all inlined, exactly as deployed. This is the single artifact for UNDERSTANDING what a page looks like and how its CSS actually cascades (get_page_blocks gives the editable block tree; this gives the rendered result). Returns { rendered_html, internal_links[] }. Pass annotate:true to tag every element with data-block-id + data-block-type so you can map the rendered HTML back to the block to edit. For a clickable preview URL use get_preview_link instead.",
240
240
  inputSchema: {
241
241
  page_id: z.string(),
242
+ annotate: z
243
+ .boolean()
244
+ .optional()
245
+ .describe('Tag every block root with data-block-id (the authored block id) + data-block-type so you can map a rendered element back to the exact block to mutate. Off by default.'),
242
246
  version: versionParam,
243
247
  },
244
248
  handler: withErrorBoundary(async (args, { client, siteId }) => {
245
- const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/preview`, v(args.version));
249
+ const query = { ...(v(args.version) ?? {}) };
250
+ if (args.annotate)
251
+ query.annotate = 'true';
252
+ const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/preview`, query);
246
253
  return ok(res);
247
254
  }),
248
255
  },
@@ -0,0 +1,11 @@
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.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
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,298 @@
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 stable URL for the version under review: `trigger_deploy version="<branch>"`
38
+ → poll `get_deploy_status` → use the immutable `deploy_url` (a Cloudflare Pages
39
+ hash URL — it never changes, so the URL you OK is bit-for-bit what ships if
40
+ merged). Or `get_preview_link` for an in-portal render. Review the SAME url end
41
+ to end.
42
+ 2. The snippets below run in a browser tool's "evaluate JavaScript" (Playwright /
43
+ chrome-devtools / puppeteer MCP). **One origin per eval:** the iframe trick
44
+ needs same-origin, so run each variant's snippet on its own page (different
45
+ `*.pages.dev` hashes are cross-origin → `contentDocument` is null).
46
+ 3. If you run several variants with one shared browser profile, do them
47
+ SEQUENTIALLY — parallel browser agents on one profile contaminate each other's
48
+ tabs/screenshots.
49
+
50
+ ## The dimensions — run each, record the result
51
+
52
+ ### 1. Responsive — width ladder 390/768/1024/1440/1920, zero overflow
53
+
54
+ Measure horizontal overflow at every width in ONE eval using same-origin iframes
55
+ (each iframe is its own layout viewport, so `@media` fires correctly — no 15
56
+ resizes):
57
+
58
+ ```js
59
+ async () => {
60
+ const url = location.href, out = [];
61
+ for (const w of [390,768,1024,1440,1920]) {
62
+ const f = document.createElement('iframe');
63
+ f.style.cssText = `width:${w}px;height:2400px;border:0;position:fixed;left:-99999px;top:0`;
64
+ document.body.appendChild(f);
65
+ await new Promise(r => { f.onload = r; f.src = url; });
66
+ await new Promise(r => setTimeout(r, 700));
67
+ const d = f.contentDocument, culprits = [];
68
+ for (const el of d.body.querySelectorAll('*')) {
69
+ const r = el.getBoundingClientRect();
70
+ if (r.right > w + 1 && r.width <= w + 40 && r.width > 4)
71
+ culprits.push(el.tagName.toLowerCase() + '.' + (el.className||'').toString().slice(0,40));
72
+ }
73
+ out.push({ w, hOverflow: d.documentElement.scrollWidth - w, n: culprits.length, sample: [...new Set(culprits)].slice(0,6) });
74
+ f.remove();
75
+ }
76
+ return out;
77
+ }
78
+ ```
79
+
80
+ PASS = `hOverflow <= 0` at every width. A decorative element flagged while
81
+ `hOverflow` is 0 is clipped by an `overflow:hidden` parent (no scrollbar) — a
82
+ non-issue. Then eyeball one tablet (768) capture for stacking — but capture
83
+ AFTER the scroll-settle in §5.
84
+
85
+ ### 2. Accessibility — compute contrast, don't eyeball
86
+
87
+ ```js
88
+ () => {
89
+ 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]; };
90
+ 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}; };
91
+ 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); };
92
+ 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]}; };
93
+ const bad=[], seen=new Set();
94
+ for (const el of document.body.querySelectorAll('*')) {
95
+ const t=[...el.childNodes].filter(n=>n.nodeType===3&&n.textContent.trim()).map(n=>n.textContent.trim()).join(' ');
96
+ if(!t) continue;
97
+ const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) continue;
98
+ const s=getComputedStyle(el); if(s.visibility==='hidden'||s.display==='none'||+s.opacity<.1) continue;
99
+ const fg=P(s.color); if(!fg) continue;
100
+ const b=bg(el); if(b.img) continue; // can't compute over an image — eyeball hero text separately
101
+ const cr=R(fg.rgb,b.rgb), fs=parseFloat(s.fontSize), fw=+s.fontWeight||400;
102
+ const need = (fs>=24||(fs>=18.66&&fw>=700)) ? 3 : 4.5;
103
+ if (cr<need) { const k=t.slice(0,30)+cr.toFixed(2); if(seen.has(k))continue; seen.add(k);
104
+ 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(',')+')'}); }
105
+ }
106
+ return { failures: bad.length, items: bad.slice(0,15) };
107
+ }
108
+ ```
109
+
110
+ PASS = 0 failures (AA: body ≥4.5:1, large/UI ≥3:1). Fix a failure by deepening
111
+ the offending colour token. Text over an image background is skipped — eyeball
112
+ those (hero overlays) for legibility separately.
113
+
114
+ Structure + alt + landmarks, same eval session:
115
+
116
+ ```js
117
+ () => {
118
+ const h=[...document.querySelectorAll('h1,h2,h3,h4')].map(e=>+e.tagName[1]);
119
+ const skips=h.map((v,i)=>i&&v-h[i-1]>1?`${h[i-1]}->${v}`:0).filter(Boolean);
120
+ const imgs=[...document.querySelectorAll('img')];
121
+ const inputs=[...document.querySelectorAll('input:not([type=hidden]),textarea,select')];
122
+ const labelFor=new Set([...document.querySelectorAll('label[for]')].map(l=>l.getAttribute('for')));
123
+ return {
124
+ h1: h.filter(x=>x===1).length, levelSkips: skips,
125
+ imgsMissingAlt: imgs.filter(i=>i.getAttribute('alt')===null).length,
126
+ landmarks: ['header','nav','main','footer'].filter(t=>document.querySelector(t)),
127
+ unlabeledInputs: inputs.filter(i=>!(i.id&&labelFor.has(i.id))&&!i.getAttribute('aria-label')).map(i=>i.name||i.id),
128
+ };
129
+ }
130
+ ```
131
+
132
+ PASS = exactly one `h1`, `levelSkips` empty, `imgsMissingAlt` 0, all four
133
+ landmarks present, `unlabeledInputs` empty. (Decorative images SHOULD have
134
+ `alt=""` — that's not "missing".)
135
+
136
+ Touch targets — interactive elements ≥44px at mobile. Run in a 390px iframe;
137
+ EXCLUDE `aria-hidden` (the form honeypot is a visible-sized but hidden input —
138
+ counting it is a false positive) and inline text links inside `p`/`li`:
139
+
140
+ ```js
141
+ async () => {
142
+ const f=document.createElement('iframe');
143
+ f.style.cssText='width:390px;height:2400px;border:0;position:fixed;left:-99999px;top:0';
144
+ document.body.appendChild(f);
145
+ await new Promise(r=>{ f.onload=r; setTimeout(r,3000); f.src=location.href; });
146
+ await new Promise(r=>setTimeout(r,700));
147
+ const d=f.contentDocument, small=[];
148
+ if(d) for (const el of d.querySelectorAll('a,button,input:not([type=hidden]),textarea,select,[role=button]')) {
149
+ const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) continue;
150
+ const s=getComputedStyle(el); if(s.display==='none'||s.visibility==='hidden'||+s.opacity<.1) continue;
151
+ if(el.getAttribute('aria-hidden')==='true') continue;
152
+ if(el.tagName==='A'&&el.closest('p,li')) continue;
153
+ 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)});
154
+ }
155
+ f.remove(); return { undersized: small };
156
+ }
157
+ ```
158
+
159
+ Also confirm `:focus-visible` and `prefers-reduced-motion` exist (grep the page
160
+ HTML: `grep -c 'focus-visible' page.html`, `grep -c 'prefers-reduced-motion'`).
161
+ Note honestly: presence in CSS ≠ verified per-element — tab through live if you
162
+ claim keyboard focus works.
163
+
164
+ ### 3. Functional — console, links, form
165
+
166
+ - **Console:** read the browser tool's console messages after load. PASS = 0
167
+ errors/warnings.
168
+ - **Links:** PASS = no `href="#"`/empty; every in-page `#anchor` has a matching
169
+ `id`.
170
+ - **Form (markup — does NOT prove a live submit):** curl the page and verify the
171
+ `<form>` `action` is the real submit endpoint, the hidden `_token` is
172
+ non-empty, the honeypot is present + `aria-hidden`, required fields have
173
+ `required`, the email field is `type="email"`. State explicitly that you did
174
+ NOT submit (a live POST creates a real submission) unless you actually did.
175
+
176
+ ### 4. Content — verbatim, no placeholders
177
+
178
+ `grep -Ei 'lorem|ipsum|\{\{|placeholder|TODO|FIXME' page.html` → 0. Copy matches
179
+ the live page (the source of truth) verbatim.
180
+
181
+ ### 5. Broken / blank images (the anti-lazy-load check — run THIS before trusting any screenshot)
182
+
183
+ ```js
184
+ async () => {
185
+ const H=document.body.scrollHeight;
186
+ for(let y=0;y<=H;y+=400){ window.scrollTo(0,y); await new Promise(r=>setTimeout(r,120)); }
187
+ window.scrollTo(0,0); await new Promise(r=>setTimeout(r,1500));
188
+ const imgs=[...document.querySelectorAll('img')];
189
+ const empty=[]; // genuinely empty boxes: large, no text/img/svg/bg-image
190
+ for (const el of document.querySelectorAll('div,section,figure')) {
191
+ const r=el.getBoundingClientRect(); if(r.width<160||r.height<140) continue;
192
+ if((el.innerText||'').trim()||el.querySelector('img,svg,picture,canvas,video')) continue;
193
+ if(getComputedStyle(el).backgroundImage!=='none') continue;
194
+ empty.push({cls:(el.className||'').toString().slice(0,36), w:Math.round(r.width), h:Math.round(r.height)});
195
+ }
196
+ return {
197
+ broken: imgs.filter(i=>i.complete&&i.naturalWidth===0).map(i=>i.src.slice(-45)), // real failures
198
+ stillLoading: imgs.filter(i=>!i.complete).map(i=>i.src.slice(-45)), // lazy, scroll first
199
+ emptyBoxes: empty.slice(0,8), // true placeholders
200
+ };
201
+ }
202
+ ```
203
+
204
+ PASS = `broken` empty, `emptyBoxes` empty. A non-empty `emptyBoxes` is a genuine
205
+ unfilled illustration slot (fill it — pages shouldn't be text deserts). NOW
206
+ capture screenshots (the page is scrolled-and-settled, images loaded).
207
+
208
+ ### 5b. Clipped artwork — the logo (and any brand image) cut off by its own frame
209
+
210
+ The single most-repeated visual bug: the header logo rendered with its top/edges
211
+ sliced. It produces ZERO page overflow (§1 misses it), the image isn't broken
212
+ (§5 misses it), and at full-page screenshot scale a few clipped pixels are easy
213
+ to glance past. So MEASURE it: does the artwork's rendered content touch the edge
214
+ of its own box on any side? Content flush against the frame (gap ≈ 0) = clipped
215
+ or about-to-clip. Don't just check the logo — check it, then trust the number.
216
+
217
+ For a raster/`<img>` logo, draw it to a same-origin canvas and scan the border
218
+ rows/cols for opaque pixels (cross-origin taints the canvas — fetch the asset to
219
+ a localhost file first, as in §setup, or measure on the asset directly):
220
+
221
+ ```js
222
+ async (url) => { // url = the logo's currentSrc, served same-origin
223
+ const img = new Image(); await new Promise((r,e)=>{img.onload=r;img.onerror=e;img.src=url;});
224
+ const h = 64, w = Math.round(h*img.naturalWidth/img.naturalHeight);
225
+ const c = document.createElement('canvas'); c.width=w; c.height=h;
226
+ const x = c.getContext('2d'); x.drawImage(img,0,0,w,h);
227
+ const d = x.getImageData(0,0,w,h).data, op=(px)=>d[px*4+3]>20;
228
+ let top=h,bot=0,left=w,right=0;
229
+ 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);}
230
+ return { topGap:top, bottomGap:h-1-bot, leftGap:left, rightGap:right }; // any 0 → flush/clipped
231
+ }
232
+ ```
233
+
234
+ PASS = every gap ≥ ~2% of the dimension. A `0` on any side means the artwork (or
235
+ its stroke) sits on the frame — for an SVG that's a viewBox trimmed flush to the
236
+ art (look for `-trim`/`-tight` in the filename); the fix is to re-export the SVG
237
+ with viewBox padding (e.g. widen `viewBox` by ~8% each side) so the stroke never
238
+ touches the edge. Verify the fix by re-running this with the patched asset. (A
239
+ heavy `stroke-width` + `paint-order="stroke"` outline makes a flush viewBox clip
240
+ visibly — and small header renders make it worse, so also check the logo isn't
241
+ shrunk below ~64–72px in the header.)
242
+
243
+ Also confirm no ANCESTOR clips the logo: walk the logo's parents for
244
+ `overflow:hidden|clip` combined with a fixed height or negative/overlap margin —
245
+ and always judge the logo from a screenshot of the header REGION in context,
246
+ never the logo element in isolation (an element screenshot re-renders the full
247
+ art and hides the clip).
248
+
249
+ **The inverse bug — an image FLOATING inside its frame (don't blame the file).**
250
+ A full-bleed illustration that renders with a margin of empty frame around it
251
+ usually isn't a bad asset — it's CSS. In blocks-mode the site-template's global
252
+ `:where(.page-content) img{ margin:1rem 0 }` (and a default `border-radius`)
253
+ leaks onto any `<img>` you didn't reset, so a framed hero/figure gets a 1rem gap
254
+ inside its frame and looks like it "floats". Before re-cropping or regenerating,
255
+ **open the actual image file** (`curl` the `.avif`/`.png`) — if the motif fills
256
+ the file edge-to-edge, the float is CSS: set `margin:0` (and `border-radius:0`)
257
+ on the framed `<img>` (e.g. `.your-frame img{margin:0}` or a blanket
258
+ `.your-scope img{margin:0}`). Measure it: the `<img>`'s `getBoundingClientRect`
259
+ should equal its frame's inner box (no gap). Only when the *file itself* has
260
+ built-in background margin (motif ≪ frame) is cropping the right fix.
261
+
262
+ ### 6. Findable (SEO/meta) — curl, fast
263
+
264
+ `<title>` (≤60 chars) + meta description present + sensible; `og:title/description/image`;
265
+ `canonical`; `favicon` + `apple-touch-icon`; `<html lang>`; branches must be
266
+ `noindex`. One curl + greps covers it.
267
+
268
+ ### 7. Fast (performance)
269
+
270
+ PASS signals: no render-blocking JS you didn't add; responsive variants
271
+ (`srcset` + AVIF/WebP) so a 1024px asset isn't shipped to a 380px slot;
272
+ `width`/`height` or aspect-ratio set (no layout shift); **below-fold images
273
+ `loading="lazy"`, above-fold `eager`**. Flag a section that eager-loads every
274
+ image, or a multi-hundred-KB original served when a small AVIF variant exists.
275
+
276
+ ### 8. Cross-browser
277
+
278
+ The same CSS renders differently per engine. Re-check in another engine if you
279
+ can. If only Chromium is available, say so as UNTESTED and statically flag risky
280
+ props: `backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on
281
+ mobile (prefer `100svh`), `position:sticky` inside `overflow`.
282
+
283
+ ## Deliver a scorecard + an explicit verdict
284
+
285
+ Report a table — one row per dimension, value PASS / FAIL(detail) / UNTESTED —
286
+ then a one-line verdict. Rules:
287
+
288
+ - "Approved" requires PASS on responsive, a11y, functional, content, SEO,
289
+ performance. Untested dimensions (commonly cross-browser, live form submit) are
290
+ listed as CAVEATS, not silently dropped — an OK with caveats is honest; an
291
+ unqualified "approved" off a partial pass is not.
292
+ - Brand FIT (palette/voice matching `brand.md`) is a direction judgment, not a
293
+ pass/fail defect — call it out separately so the user decides direction.
294
+ - If you fixed anything mid-review, re-deploy and re-run the affected dimension
295
+ before signing off.
296
+
297
+ See `tr-redesign-branch` for the surrounding branch → preview → approve → merge
298
+ 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,84 @@ 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-compliant e.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 deployed branch
157
+ (browser tool + DOM reads), fix what you find, re-deploy, re-check.
158
+
159
+ **Use `tr-design-review` (`read_skill tr-design-review`) for the HOW** it has
160
+ the per-dimension measurement snippets (overflow ladder, computed contrast, touch
161
+ targets, the anti-lazy-load broken-image check) and the scorecard + verdict
162
+ format. The dimension summary below is the "what"; that skill is the runnable
163
+ routine. In particular: scroll-and-settle to trigger lazy images BEFORE any
164
+ full-page screenshot, or you'll report blank boxes that aren't real.
165
+
166
+ 1. **Responsive** screenshot across a width ladder (mobile / tablet / laptop /
167
+ desktop / wide ≈390 / 768 / 1024 / 1440 / 1920px) AND sweep the page's own
168
+ `@media` breakpoints (read the page-scoped `<style>`; resize a few px below +
169
+ above each). At EVERY width: `document.documentElement.scrollWidth <=
170
+ clientWidth` (no horizontal scroll), grids flip cleanly, nothing squished /
171
+ orphaned / overlapping, no mid-word breaks. Two sizes is not enough — bugs hide
172
+ in between. Also check a short/landscape viewport and 200% browser zoom.
173
+ 2. **Visual & brand** logo FULLY VISIBLE (not clipped by a header
174
+ `overflow:hidden` + overlap margin) and brand-compliant; screenshot the header
175
+ IN CONTEXT, never the logo element in isolation (that hides clipping).
176
+ Decoration robust at real size in the real browser: no hairline seam at a
177
+ divider (use `core/section` `divider_top`/`divider_bottom` — don't hand-roll a
178
+ band), no glow clipped to a hard edge, no `object-fit:cover` cropping faces, no
179
+ gradient fade-cutoff. Typography: body line-length ~45–75ch, consistent scale,
180
+ no awkward widows on headings. Palette adherence (no off-brand colours);
181
+ consistent spacing / alignment / radius / shadow.
182
+ 3. **Accessibility — MEASURE, don't eyeball** — compute actual contrast ratios
183
+ (WCAG AA: body ≥4.5:1, large/UI ≥3:1) and fix failures by deepening the
184
+ offending colour token; meaningful `alt` on every image; exactly one `<h1>` +
185
+ no skipped heading levels; visible `:focus-visible` on every interactive
186
+ element; every input has an associated `<label>`; touch targets ≥44px on
187
+ mobile; semantic landmarks (header/nav/main/footer) + nav `aria-label`; honour
188
+ `prefers-reduced-motion`.
189
+ 4. **Functional** — the form actually works (POST action correct, hidden token
190
+ non-empty, honeypot present + hidden; a long name/email doesn't break layout);
191
+ every link + in-page anchor resolves (each `#anchor` has a matching `id`; no
192
+ `href="#"`/`""`); ZERO console errors/warnings; interactions (menu toggle,
193
+ hover/focus/active) work.
194
+ 5. **Content** — no unrendered `{{…}}` tokens in the DOM; no placeholder/lorem;
195
+ copy still matches the source of truth (the live page) verbatim.
196
+ 6. **Findable (SEO/meta)** — `<title>` + meta description present + sensible;
197
+ `og:title`/`og:description`/`og:image`; canonical; favicon + apple-touch-icon;
198
+ `<html lang>`; `noindex` correct (branches must be noindex).
199
+ 7. **Fast (performance)** — images at sane sizes (not a 2048px file shown at
200
+ 380px without a responsive variant), modern format (avif/webp), `width`/`height`
201
+ or aspect-ratio set (no layout shift), below-fold lazy / above-fold eager.
202
+ 8. **Cross-browser** — the same CSS renders differently per engine (the divider
203
+ seam was Chrome-only; WebKit/Firefox have their own). Re-check in another engine
204
+ if you can; if only Chromium is available, statically flag risky props
205
+ (`backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on mobile →
206
+ prefer `100svh`, `sticky` inside `overflow`).
149
207
 
150
208
  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."
209
+ fine" ≠ "looks good", and "looks good in Chrome at 1440" "works for everyone,
210
+ everywhere" — never report a design as approved/perfect off a glance or a partial
211
+ pass.
212
+
213
+ Then send the user a **stable preview URL** — and ALWAYS prefer a stable
214
+ one over a link that goes stale:
215
+
216
+ - **Stable (use this for sharing / repeat viewing):** the deployed branch's
217
+ alias URL, `https://<branch>.<project>.pages.dev`. It always serves that
218
+ branch's *latest* deploy, so the same link keeps working across every
219
+ re-deploy. Get it by deploying the branch once (`trigger_deploy
220
+ version="<branch>"`); the `<project>` segment is the part after the hash
221
+ in the returned `deploy_url` (e.g. `abc123.<project>.pages.dev`). Curl it
222
+ once to confirm it resolves. (Branch deploys are `robots_blocked`, so it
223
+ won't be indexed.)
224
+ - **Avoid as the shared link:** the immutable per-deploy hash URL (a new
225
+ hash every deploy — the user's bookmark dies) and a `get_preview_link`
226
+ token (expires). Those are fine for your own one-off in-editor checks,
227
+ not as the canonical link you hand the user.
228
+
229
+ Default rule: when the user will look more than once, give the stable alias.
230
+ Wait for an explicit "looks good, ship it."
162
231
 
163
232
  ### 7. Merge + deploy
164
233
 
@@ -185,9 +254,11 @@ disk cost is tiny.)
185
254
  - **Skipping discovery.** "Modernize" without first reading the site
186
255
  produces a confidently-out-of-place result. Always sample existing
187
256
  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 URLalways use that for sharing.
257
+ - **Handing out a link that goes stale.** Don't give the user the
258
+ immutable per-deploy hash URL or an expiring `get_preview_link` token as
259
+ the canonical link share the stable branch alias
260
+ `https://<branch>.<project>.pages.dev` (see step 6). `get_preview_link`
261
+ is for your own one-off in-editor checks.
191
262
  - **Auto-merge.** Don't `merge_branch` without explicit user sign-off.
192
263
  Once merged, the only undo is another branch + reverse edits.
193
264
  - **Header rewrites that drop the brand block.** Even when the