9 Commits
Author SHA1 Message Date
Leon Lin 12af471f62 tasteskill v2.1.5: community fixes (#172)
tasteskill v2.1.5: community fixes
2026-10-25 01:42:19 +02:00
af8a897996 v2.1.5: community fixes (#156, #161, #163, #171) and an encoding check
- probe.mjs: text boxes are clipped to the window their element's overflow
  allows, so ellipsised lines no longer fail edge-text (#156).
- type.json: Boska, Erode and Sentient request their italic weight (401)
  (#161; the fix from #166, redone without the encoding change).
- SKILL.md: the redraw rule names its third reason (#163); the Gate says to
  inspect the production build (#171).
- inspect.mjs: dev-server WARN for Vite, webpack and Next dev pages (#171).
- tests/text-encoding.mjs + CI: catches UTF-8 saved as Latin-1; CI also
  runs the inspector file-name tests and parses the scene scripts.

Co-Authored-By: ketpatil77 <243740572+ketpatil77@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:40:02 +02:00
Leon Lin f99d059146 docs(taste-skill): keyboard reach, accessible names, and controls that explain themselves (#170)
docs(taste-skill): keyboard reach, accessible names, and controls that explain themselves
2026-10-25 01:32:53 +02:00
Leon Lin fd25565e10 fix(inspect): keep screenshots for widths sharing a viewport class (#165)
fix(inspect): keep screenshots for widths sharing a viewport class
2026-10-25 01:32:43 +02:00
Leon Lin 5fac2066df Escape text passed to innerHTML in charts.js and the Stage.split fallback (#169)
Escape text passed to innerHTML in charts.js and the Stage.split fallback
2026-10-25 01:32:27 +02:00
MrBinnacle 643565c72d docs(taste-skill): keep the help text that states what a field accepts 2026-10-24 18:02:09 -04:00
Matthew Gruber 114deccee7 docs(taste-skill): keyboard reach, accessible names, and controls that explain themselves 2026-10-24 14:59:36 -04:00
PablolakotekandClaude Opus 5.5 1dcd3a2644 Escape text passed to innerHTML in charts.js and the Stage.split fallback
Fixes #160. Labels, series names, format()/note() results and ring
sub-labels in the line tooltip, ring, plan and bullet now go through a
small esc() helper before reaching innerHTML; the markup and styles are
unchanged. bullet() had the same pattern and was not listed in the
issue. The Stage.split fallback builds its word spans with
createElement/textContent instead of re-parsing the heading's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:56:47 +02:00
ketpatil77 aa6d99659b fix(inspect): preserve screenshots for widths sharing a viewport class 2026-10-24 15:33:54 +05:30
14 changed files with 160 additions and 29 deletions

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
{
"name": "taste-skill",
"description": "tasteskill v2: art direction for websites, product pages, dashboards and app screens, with a library of researched directions, real pictures from open collections and a real-browser inspector.",
"version": "2.1.4",
"version": "2.1.5",
"source": "./",
"skills": ["./skills/taste-skill"],
"author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taste-skill",
"version": "2.1.4",
"version": "2.1.5",
"description": "tasteskill v2: art direction for websites, product pages, dashboards and app screens, checked in a real browser.",
"author": {
"name": "leonxlnx",
+5 -3
View File
@@ -17,7 +17,9 @@ jobs:
run: |
npm install --no-save --no-package-lock yaml@2.6.1
node tests/frontmatter.mjs
- name: Text files are clean UTF-8 (no double encoding)
run: node tests/text-encoding.mjs
- name: Scripts parse
run: for f in skills/taste-skill/scripts/*.mjs skills/taste-skill/scripts/lib/*.mjs skills/taste-skill/assets/*.js; do node --check "$f" || exit 1; done
- name: Browser profile cleanup regression
run: node --test tests/profile-cleanup.mjs
run: for f in skills/taste-skill/scripts/*.mjs skills/taste-skill/scripts/lib/*.mjs skills/taste-skill/assets/*.js skills/taste-skill/assets/scenes/*.js; do node --check "$f" || exit 1; done
- name: Browser profile cleanup and inspector file names
run: node --test tests/profile-cleanup.mjs tests/inspection-files.mjs
+13
View File
@@ -4,6 +4,19 @@ All notable changes to taste-skill live here. The repo follows SemVer-ish discip
---
## 2.1.5 - 2026-10-25
Fixes from the community. Thanks to everyone who reported and sent them.
- **Safe text in charts and split headings** ([#160](https://github.com/Leonxlnx/taste-skill/issues/160), [#169](https://github.com/Leonxlnx/taste-skill/pull/169), thanks @jcastilla89 and @Pablolakotek): `charts.js` and the `Stage.split` fallback escape the text they insert, so a chart label or heading that comes from user data can no longer inject markup.
- **No lost screenshots when two widths share a class** ([#162](https://github.com/Leonxlnx/taste-skill/issues/162), [#165](https://github.com/Leonxlnx/taste-skill/pull/165), thanks @nosawavey-spec and @ketpatil77): `--widths 1280,1024,768,390` keeps both tablet renders (`tablet-1024-*`, `tablet-768-*`).
- **Keyboard and accessible names** ([#170](https://github.com/Leonxlnx/taste-skill/pull/170), thanks @MrBinnacle): every control works from the keyboard and has an accessible name; a control explains itself by its form, not by a sentence beside it.
- **The italic faces load** ([#161](https://github.com/Leonxlnx/taste-skill/issues/161), with the fix from [#166](https://github.com/Leonxlnx/taste-skill/pull/166), thanks @nosawavey-spec and @ketpatil77): Boska, Erode and Sentient request their italic weight, so the browser no longer fakes it.
- **`edge-text` no longer fails ellipsised lines** ([#156](https://github.com/Leonxlnx/taste-skill/issues/156), thanks @shoctroper): text is measured where the reader sees it, clipped by `overflow`.
- **Inspect the production build** ([#171](https://github.com/Leonxlnx/taste-skill/issues/171), thanks @lilozzy): the Gate step says to run the last check on the built page, and the inspector warns (`dev-server`) when it is looking at a Vite, webpack or Next dev server.
- **The redraw rule names its third reason** ([#163](https://github.com/Leonxlnx/taste-skill/issues/163), thanks @Tsury): a section that cannot hold the real content.
- CI now also checks that every text file is clean UTF-8 (a double-encoded save is caught), runs the inspector file-name tests, and parses the scene scripts.
## 2.1.4 - 2026-10-20
- **Fix: one inspector run could delete the browser profile of another run that was still going** ([#157](https://github.com/Leonxlnx/taste-skill/issues/157), fixed in [#158](https://github.com/Leonxlnx/taste-skill/pull/158) by @t4sh). On macOS and Linux Chrome keeps no `lockfile`, so the sweep added in 2.1.0 judged a profile unused by its age alone, and a second run started during a long one could pull that run's profile away. #158 removed the sweep and added a regression test.
+16 -3
View File
@@ -141,8 +141,9 @@ The command prints a **direction sheet**. It is the art direction for this build
- **Adopt it.** Do not blend it with the layout you would have made anyway. If it feels unfamiliar,
good: the familiar layout is the one this skill exists to prevent.
- Redraw one slot (`--redraw hero | type | palette | photos | scenes | section:<role> | direction`) only for a
reason you can state: it contradicts a stated brand constraint, or needs an asset that does not
exist. Never redraw to get something more comfortable. Record every redraw in your report.
reason you can state: it contradicts a stated brand constraint, needs an asset that does not exist,
or is a section that cannot hold the real content (next point). Never redraw to get something more
comfortable. Record every redraw in your report.
- When a dealt section cannot hold the real content (it compares three plans and there is one price;
it wants five quotations and there are none), do not bend the content to fit. List the others with
`--alternatives <role>`, pick the one whose assumptions match the content, and take it with
@@ -296,7 +297,9 @@ not finished. Order of work:
wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the
inspector measures each of them.
9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is
its own composition.
its own composition. Every control, the live thing included, works from the keyboard alone in
reading order and has an accessible name: its visible label, or `aria-label` when it shows only an
icon.
Read `references/type.md`, `references/colour.md` and `references/layout.md` when you need the detail
behind a decision; the laws below are the short form.
@@ -322,6 +325,11 @@ designer in one sentence. Re-run until there are no `FAIL`s. Do not argue with t
hide things from it. The inspector serves local files over a throwaway local server; if the page must
also open straight from the file system, open `index.html` that way once before you finish.
Inspect what the visitor receives. With a build step, run the last gate on the production build
(`vite build && vite preview`, `next build && next start`, or the built `index.html`), not only on the
dev server: minifiers and bundlers change what ships (one dropped a `backdrop-filter` that the dev
server showed). The inspector warns when it is looking at a dev server.
### 8. Look
Passing the gate means nothing banned was measured. It does not mean the page is good. Open the
@@ -458,6 +466,11 @@ Short form. Numbers are from the study unless marked as house style.
square, 23% pill; the 9–16px "soft" radius is the weak-site default). Most strong heroes have no
button at all, or one.
- Navigation: wordmark, three to five plain links, one action. No bar chrome.
- A control explains how to use it by its form, its label and its place. When it seems to need a
sentence on how to operate it, redesign the control instead of adding the sentence. What a field
accepts is different. First remove the limit where you can: a date picker, or a parser that takes
any common format. A limit that remains, such as password rules or file type and size, stays visible
beside the field and is tied to it with `aria-describedby`.
- The footer is a designed close, not a sitemap: on most strong sites the name returns at full width.
## Special cases
+6 -4
View File
@@ -37,6 +37,8 @@
const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; };
// numbers follow the page's language (<html lang>), so separators match the rest of the screen
const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o);
// every text value that reaches innerHTML goes through esc: a label from a database is text, never markup
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const fmt = {
int: (v) => nf({ maximumFractionDigits: 0 }).format(v),
dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v),
@@ -299,7 +301,7 @@
const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g);
const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g));
const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s);
const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${labels[i]}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : format(sr.values[i])}</b> ${series.length > 1 ? sr.name : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `<div style="opacity:.7">${esc(labels[i])}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : esc(format(sr.values[i]))}</b> ${series.length > 1 ? esc(sr.name) : ''}</div>`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; };
hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); });
const last = series[0].values.filter((v) => v != null).at(-1);
s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`);
@@ -381,7 +383,7 @@
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s);
svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s);
const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums';
t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${label}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${sub}</div>` : ''}`;
t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${esc(label)}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${esc(sub)}</div>` : ''}`;
wrap.appendChild(t); el.appendChild(wrap);
}
// A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each
@@ -418,7 +420,7 @@
const over = r.spent > r.planned;
const li = document.createElement('li');
const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px';
top.innerHTML = `<span>${r.label}</span><span><b style="font-weight:500">${format(r.spent)}</b> <span style="color:var(--muted,#5f6368)">of ${format(r.planned)} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${note(r)}</span></span>`;
top.innerHTML = `<span>${esc(r.label)}</span><span><b style="font-weight:500">${esc(format(r.spent))}</b> <span style="color:var(--muted,#5f6368)">of ${esc(format(r.planned))} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${esc(note(r))}</span></span>`;
// scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan
const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100;
const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%';
@@ -440,7 +442,7 @@
for (const r of rows) {
const drift = r.value - r.target;
const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px';
li.innerHTML = `<span>${r.label}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${format(r.value)}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}</span></span>`;
li.innerHTML = `<span>${esc(r.label)}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${esc(format(r.value))}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${esc(format(Math.abs(drift)))} vs ${esc(format(r.target))}</span></span>`;
li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`);
list.appendChild(li);
}
+2 -1
View File
@@ -103,7 +103,8 @@
// fallback: words in inline-block spans, the full text kept for screen readers
if (!el.dataset.split) {
el.setAttribute('aria-label', el.textContent.trim());
el.innerHTML = el.textContent.trim().split(/\s+/).map((w) => `<span aria-hidden="true" style="display:inline-block">${w}</span>`).join(' ');
const words = el.textContent.trim().split(/\s+/).map((w) => { const span = document.createElement('span'); span.setAttribute('aria-hidden', 'true'); span.style.display = 'inline-block'; span.textContent = w; return span; });
el.replaceChildren(...words.flatMap((span, i) => (i ? [' ', span] : [span])));
el.dataset.split = 'words';
}
return [...el.children];
+9 -9
View File
@@ -1486,7 +1486,7 @@
],
"italic": false,
"settings": "weight 500 · tracking -0.025em · leading 0.95 · sentence case, or tight capitals at 32–40px for a single statement",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap\" rel=\"stylesheet\">"
},
"text": {
"family": "Erode",
@@ -1498,7 +1498,7 @@
],
"italic": true,
"settings": "400 for body, 600 for emphasis, italic for titles of works · leading 1.5 · tracking 0",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap\" rel=\"stylesheet\">"
},
"standsInFor": [
"Noe Text",
@@ -1512,7 +1512,7 @@
"aww-colonia-zacamil"
],
"links": [
"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap"
"https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap"
],
"scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
@@ -1534,7 +1534,7 @@
],
"italic": true,
"settings": "weight 400 · tracking -0.02em · leading 0.95 · roman capitals for the main words with lowercase italic for the connecting words at the same size, or sentence case throughout; centred lines work",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap\" rel=\"stylesheet\">"
},
"text": {
"family": "Boska",
@@ -1546,7 +1546,7 @@
],
"italic": true,
"settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0.005em",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap\" rel=\"stylesheet\">"
},
"standsInFor": [
"Louize Display",
@@ -1559,7 +1559,7 @@
"opl-sarah-matt-wedding"
],
"links": [
"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap"
"https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap"
],
"scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
@@ -1676,7 +1676,7 @@
],
"italic": false,
"settings": "weight 300 · tracking -0.015em · leading 1.0 · sentence case; about 8vw for the headline, 40px for statements",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap\" rel=\"stylesheet\">"
},
"text": {
"family": "Sentient",
@@ -1688,7 +1688,7 @@
],
"italic": true,
"settings": "400 for body, 500 for emphasis · leading 1.55 · tracking 0",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap\" rel=\"stylesheet\">"
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap\" rel=\"stylesheet\">"
},
"standsInFor": [
"Season Mix",
@@ -1702,7 +1702,7 @@
"opl-le-petit-bleu"
],
"links": [
"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap"
"https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap"
],
"scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
+10 -5
View File
@@ -12,12 +12,14 @@
//
// Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done).
// Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
// Widths sharing a viewport class receive width-qualified screenshot filenames.
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs';
import { resolve, join, relative, dirname, extname, sep } from 'node:path';
import { createServer } from 'node:http';
import { Browser, sleep } from './lib/cdp.mjs';
import { probe, frame, layout, app, colourShare } from './lib/probe.mjs';
import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.mjs';
import { viewportClass as name, screenshotLabel, isInspectionScreenshot } from './lib/inspection-files.mjs';
const args = process.argv.slice(2);
const flag = (name, def) => { const i = args.indexOf('--' + name); if (i === -1) return def; const v = args[i + 1]; args.splice(i, 2); return v; };
@@ -61,11 +63,10 @@ if (!/^https?:\/\//.test(target)) {
url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
}
const HEIGHT = { 1440: 900, 1280: 800, 1024: 768, 768: 1024, 390: 844, 375: 812 };
const name = (w) => (w >= 1100 ? 'desktop' : w >= 600 ? 'tablet' : 'mobile');
mkdirSync(outDir, { recursive: true });
// Screenshots from an earlier run would sit beside the new ones when the page got shorter.
for (const f of readdirSync(outDir)) if (/^(desktop|tablet|mobile)-(fold|page-\d+|film-\d+)\.jpg$/.test(f)) unlinkSync(join(outDir, f));
for (const f of readdirSync(outDir)) if (isInspectionScreenshot(f)) unlinkSync(join(outDir, f));
const browser = await Browser.launch();
const helper = await browser.newPage({ width: 1600, height: 1000 });
const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] };
@@ -117,14 +118,17 @@ try {
for (const w of widths) {
const h = HEIGHT[w] || Math.round(w * 0.625);
const label = name(w);
const imageLabel = screenshotLabel(w, widths);
const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 });
await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {});
const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 });
await sleep(600); // about 1.2s after the load event: is the title there to read yet?
const early = await page.eval(TITLE_SEEN).catch(() => null);
// a dev server is not what ships: bundlers and minifiers change the CSS a visitor receives
const devServer = await page.eval(`(() => { const s = [...document.scripts].map((x) => x.src || '').join(' '); if (/@vite\\/client|webpack-dev-server|\\/_next\\/static\\/development\\/|react-refresh|webpack-hmr/.test(s)) return true; try { return !!(window.__NEXT_DATA__ && window.__NEXT_DATA__.buildId === 'development'); } catch { return false; } })()`).catch(() => false);
await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
await sleep(800); // let the load choreography finish before judging the first screen
const fold = join(outDir, `${label}-fold.jpg`);
const fold = join(outDir, `${imageLabel}-fold.jpg`);
await page.screenshot({ path: fold, quality: 82 });
const metrics = await page.eval(probe);
@@ -179,7 +183,7 @@ try {
lastY = y;
}
for (let i = 0; i < shots.length; i += 24) {
const p = join(outDir, `${label}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`);
const p = join(outDir, `${imageLabel}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`);
await sheet(shots.slice(i, i + 24), 6, 320, 200, p);
filmSheets.push(p);
}
@@ -191,11 +195,12 @@ try {
const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8;
const sheets = [];
for (let i = 0; i < frames.length; i += perSheet) {
const p = join(outDir, `${label}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`);
const p = join(outDir, `${imageLabel}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`);
await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p);
sheets.push(p);
}
let findings = [...judge(m, { width: w, allow }), ...sceneFindings];
if (devServer && !allow.includes('dev-server')) findings.push({ level: 'WARN', id: 'dev-server', width: w, message: 'This page is served by a development server (Vite, webpack or Next in dev mode).', fix: 'Run the last gate on the production build too (vite build && vite preview, next build && next start, or the built index.html): minifiers and bundlers change the CSS a visitor receives.' });
if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) {
const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim();
const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family));
@@ -0,0 +1,9 @@
export const viewportClass = (width) => (width >= 1100 ? 'desktop' : width >= 600 ? 'tablet' : 'mobile');
export function screenshotLabel(width, widths) {
const label = viewportClass(width);
const sameClass = new Set(widths.filter((candidate) => viewportClass(candidate) === label));
return sameClass.size > 1 ? `${label}-${width}` : label;
}
export const isInspectionScreenshot = (file) => /^(desktop|tablet|mobile)(?:-\d+)?-(fold|page-\d+|film-\d+)\.jpg$/.test(file);
+21 -1
View File
@@ -566,6 +566,22 @@ export function layout() {
const layerOf = (el) => { for (let p = el; p && p !== B; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed') return 'fixed'; if (pos === 'sticky') return 'sticky'; } return null; };
const sel = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60);
const boxes = [];
// A range is layout geometry: an ellipsised or scrolled line reaches past what the reader sees. Clip
// each text box to the window its element's overflow allows (its own box and every clipping ancestor).
const clipMemo = new Map();
const clipBox = (el) => {
if (!el || el === B || el === document.documentElement) return null;
if (clipMemo.has(el)) return clipMemo.get(el);
const cs = getComputedStyle(el);
let box = cs.position === 'fixed' ? null : clipBox(el.parentElement);
if (cs.overflowX !== 'visible' || cs.overflowY !== 'visible') {
const r = el.getBoundingClientRect();
const own = { l: cs.overflowX !== 'visible' ? r.left : -Infinity, r: cs.overflowX !== 'visible' ? r.right : Infinity, t: cs.overflowY !== 'visible' ? r.top : -Infinity, b: cs.overflowY !== 'visible' ? r.bottom : Infinity };
box = box ? { l: Math.max(box.l, own.l), r: Math.min(box.r, own.r), t: Math.max(box.t, own.t), b: Math.min(box.b, own.b) } : own;
}
clipMemo.set(el, box);
return box;
};
// text, one box per text node (its line boxes merged)
const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange();
for (let n = walker.nextNode(); n && boxes.length < 6000; n = walker.nextNode()) {
@@ -573,8 +589,12 @@ export function layout() {
const el = n.parentElement; if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|OPTION)$/.test(el.tagName) || el.closest('svg')) continue;
rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue;
if (!shown(el)) continue;
const c = clipBox(el);
const l = c ? Math.max(r.left, c.l) : r.left, rr = c ? Math.min(r.right, c.r) : r.right;
const tp = c ? Math.max(r.top, c.t) : r.top, bt = c ? Math.min(r.bottom, c.b) : r.bottom;
if (rr - l < 2 || bt - tp < 2) continue;
const cs = getComputedStyle(el);
boxes.push({ kind: 'text', el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName });
boxes.push({ kind: 'text', el, l, r: rr, t: tp + SY, b: bt + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName });
}
// pictures, drawings and controls
for (const el of B.querySelectorAll('img, video, canvas, picture, iframe, svg, input, select, textarea, button, [role="button"], a, div, section, figure, span, li')) {
+1 -1
View File
@@ -240,4 +240,4 @@ export function judgeApp(A, m, { width = A.viewport.w, allow = [] } = {}) {
return out;
}
export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy'];
export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy', 'dev-server'];
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { screenshotLabel, isInspectionScreenshot } from '../skills/taste-skill/scripts/lib/inspection-files.mjs';
test('default widths retain their existing screenshot names', () => {
const widths = [1440, 768, 390];
assert.deepEqual(widths.map((width) => screenshotLabel(width, widths)), ['desktop', 'tablet', 'mobile']);
});
test('distinct widths in one viewport class cannot overwrite fold, page, or film images', () => {
const widths = [1280, 1024, 768, 390];
for (const suffix of ['fold', 'page-01', 'film-01']) {
const files = widths.map((width) => `${screenshotLabel(width, widths)}-${suffix}.jpg`);
assert.equal(new Set(files).size, widths.length);
assert.ok(files.includes(`tablet-1024-${suffix}.jpg`));
assert.ok(files.includes(`tablet-768-${suffix}.jpg`));
}
});
test('collisions are disambiguated in each viewport class', () => {
const widths = [1600, 1280, 900, 768, 500, 390];
assert.deepEqual(widths.map((width) => screenshotLabel(width, widths)), [
'desktop-1600', 'desktop-1280', 'tablet-900', 'tablet-768', 'mobile-500', 'mobile-390',
]);
});
test('cleanup recognizes legacy and width-qualified inspection images only', () => {
for (const file of ['desktop-fold.jpg', 'tablet-1024-fold.jpg', 'tablet-768-page-01.jpg', 'desktop-1280-film-02.jpg']) {
assert.equal(isInspectionScreenshot(file), true, file);
}
for (const file of ['notes.jpg', 'tablet-1024-original.jpg', 'mobile-fold.png', 'desktop-fold.jpg.bak']) {
assert.equal(isInspectionScreenshot(file), false, file);
}
});
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
// Text files stay UTF-8. An editor that reads UTF-8 as Latin-1 and saves it back turns every middle dot
// into an A-circumflex plus a dot and every dash into three stray characters across a whole file (it
// happened in a pull request to library/type.json); the JSON still parses, so nothing else catches it.
// node tests/text-encoding.mjs
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname, relative, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const TEXT = new Set(['.md', '.json', '.js', '.mjs', '.cjs', '.css', '.html', '.yml', '.yaml', '.txt', '.sh', '.svg']);
const SKIP = new Set(['.git', 'node_modules']);
// UTF-8 bytes of common characters read as Latin-1 / Windows-1252, and the replacement character
const MOJIBAKE = /\u00C3[\u0080-\u00BF]|\u00C2[\u0080-\u00BF]|\u00E2\u20AC|\uFFFD/;
const bad = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
if (SKIP.has(name)) continue;
const p = join(dir, name); const st = statSync(p);
if (st.isDirectory()) { walk(p); continue; }
if (!TEXT.has(extname(name).toLowerCase()) || st.size > 4e6) continue;
const lines = readFileSync(p, 'utf8').split('\n');
lines.forEach((l, i) => { const m = l.match(MOJIBAKE); if (m) bad.push(`${relative(ROOT, p).split('\\').join('/')}:${i + 1}: "${l.slice(Math.max(0, m.index - 20), m.index + 20).trim()}"`); });
}
};
walk(ROOT);
if (bad.length) {
console.error(`not ok - ${bad.length} line(s) look double-encoded (UTF-8 saved as Latin-1). Re-save the file as UTF-8:`);
for (const b of bad.slice(0, 20)) console.error(' ' + b);
process.exit(1);
}
console.log('ok - every text file reads as clean UTF-8');