Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12af471f62 | ||
|
|
af8a897996 | ||
|
|
f99d059146 | ||
|
|
fd25565e10 | ||
|
|
5fac2066df | ||
|
|
643565c72d | ||
|
|
114deccee7 | ||
|
|
1dcd3a2644 | ||
|
|
aa6d99659b | ||
|
|
6010f91a99 | ||
|
|
99c1877abe | ||
|
|
588d11adca | ||
|
|
b39320c06f |
No files matched your search
@@ -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.3",
|
||||
"version": "2.1.5",
|
||||
"source": "./",
|
||||
"skills": ["./skills/taste-skill"],
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taste-skill",
|
||||
"version": "2.1.3",
|
||||
"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",
|
||||
|
||||
@@ -17,5 +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
|
||||
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
|
||||
@@ -4,6 +4,24 @@ 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.
|
||||
- **Leftover profiles are cleaned up again, safely.** Every profile now carries `taste-skill-owner.json` with the machine and the process IDs of the run and its Chrome. A later run removes a profile left by a killed run only when that file names this machine and neither process is alive; a profile without the file is never touched. Profiles left by earlier versions are not removed automatically: delete the `ad-chrome-*` folders in your temp folder (or in `TASTE_SKILL_TMP`) by hand.
|
||||
|
||||
## 2.1.3 - 2026-10-19
|
||||
|
||||
One repo, one skill. Nothing in `design-taste-frontend` changed.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Drives an installed Chrome / Edge / Chromium in headless mode over Node's built-in WebSocket.
|
||||
// Requires Node 22+. No npm install.
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, readdirSync, statSync, openSync, closeSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -37,22 +37,28 @@ export function findChrome() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Profiles from runs that were killed before close(): removed once no Chrome holds them (a running
|
||||
// Chrome keeps the profile's lockfile open) and they are a few minutes old.
|
||||
function sweepProfiles(base) {
|
||||
try {
|
||||
const cutoff = Date.now() - 3 * 60 * 1000;
|
||||
for (const name of readdirSync(base)) {
|
||||
if (!name.startsWith('ad-chrome-')) continue;
|
||||
const p = join(base, name);
|
||||
try {
|
||||
if (statSync(p).mtimeMs > cutoff) continue;
|
||||
const lock = join(p, 'lockfile');
|
||||
if (existsSync(lock)) closeSync(openSync(lock, 'r+')); // throws while a Chrome holds it
|
||||
rmSync(p, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
// Each profile carries an owner file naming the machine and the two processes that use it (the run and
|
||||
// its Chrome). A later run removes a profile left by a killed run only when that file names this machine
|
||||
// and neither process is alive. A profile without one (made before 2.1.4, or by another tool) is never touched.
|
||||
const OWNER = 'taste-skill-owner.json';
|
||||
|
||||
function alive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
||||
}
|
||||
|
||||
export function sweepProfiles(base) {
|
||||
let names;
|
||||
try { names = readdirSync(base); } catch { return; }
|
||||
for (const name of names) {
|
||||
if (!name.startsWith('ad-chrome-')) continue;
|
||||
const p = join(base, name);
|
||||
try {
|
||||
const o = JSON.parse(readFileSync(join(p, OWNER), 'utf8'));
|
||||
if (o.host !== hostname() || !Number.isInteger(o.pid) || alive(o.pid) || alive(o.chrome)) continue;
|
||||
rmSync(p, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export class Browser {
|
||||
@@ -64,11 +70,15 @@ export class Browser {
|
||||
if (!exe) {
|
||||
throw new Error('No Chrome/Edge/Chromium found. Install one or set CHROME_PATH to its executable.');
|
||||
}
|
||||
// A throwaway profile per run. Its cache is capped (each run starts cold anyway), profiles left by
|
||||
// runs that were killed are swept once they are a few hours old, and the profile goes on exit.
|
||||
// A throwaway profile per run, its cache capped (each run starts cold anyway), removed on exit. It is
|
||||
// claimed with an owner file before Chrome starts, so another run's sweep can tell when it was abandoned.
|
||||
const base = process.env.TASTE_SKILL_TMP || tmpdir();
|
||||
sweepProfiles(base);
|
||||
const dir = mkdtempSync(join(base, 'ad-chrome-'));
|
||||
const claim = (chrome) => {
|
||||
try { writeFileSync(join(dir, OWNER), JSON.stringify({ host: hostname(), pid: process.pid, chrome })); } catch {}
|
||||
};
|
||||
claim();
|
||||
// On exit Chrome still holds the profile's files: stop it first, then remove the folder.
|
||||
let pid = 0;
|
||||
const drop = () => {
|
||||
@@ -88,6 +98,7 @@ export class Browser {
|
||||
];
|
||||
const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
pid = proc.pid;
|
||||
claim(pid);
|
||||
const wsUrl = await new Promise((resolve, reject) => {
|
||||
let buf = '';
|
||||
const t = setTimeout(() => reject(new Error('Chrome did not start within 20s')), 20000);
|
||||
|
||||
@@ -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);
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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'];
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { hostname, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const OWNER = 'taste-skill-owner.json';
|
||||
// A pid that is certainly gone: a child that has already exited.
|
||||
const deadPid = () => spawnSync(process.execPath, ['-e', '']).pid;
|
||||
const profile = (base, name, owner) => {
|
||||
const p = join(base, name);
|
||||
mkdirSync(p);
|
||||
writeFileSync(join(p, 'sentinel'), 'preserve');
|
||||
if (owner !== undefined) writeFileSync(join(p, OWNER), typeof owner === 'string' ? owner : JSON.stringify(owner));
|
||||
return p;
|
||||
};
|
||||
|
||||
// Run in a child so Browser.launch()'s exit/signal handlers cannot leak into the test runner.
|
||||
if (process.argv[2] === '--fixture') {
|
||||
const { Browser } = await import('../skills/taste-skill/scripts/lib/cdp.mjs');
|
||||
globalThis.WebSocket = class {
|
||||
constructor() { queueMicrotask(() => this.onopen?.()); }
|
||||
send(raw) {
|
||||
const { id } = JSON.parse(raw);
|
||||
queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ id, result: {} }) }));
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
const base = process.env.TASTE_SKILL_TMP;
|
||||
const profiles = ['ad-chrome-open-lock', 'ad-chrome-no-lock'].map(name => profile(base, name));
|
||||
const gone = deadPid();
|
||||
const abandoned = profile(base, 'ad-chrome-abandoned', { host: hostname(), pid: gone, chrome: gone });
|
||||
let lock;
|
||||
let browser;
|
||||
try {
|
||||
lock = openSync(join(profiles[0], 'lockfile'), 'w+');
|
||||
for (const p of [...profiles, abandoned]) {
|
||||
const old = new Date(Date.now() - 10 * 60 * 1000);
|
||||
utimesSync(p, old, old);
|
||||
}
|
||||
browser = await Browser.launch();
|
||||
for (const p of profiles) {
|
||||
assert.ok(existsSync(join(p, 'sentinel')), 'launch deleted another profile');
|
||||
}
|
||||
assert.equal(existsSync(abandoned), false, 'launch kept a profile whose owners are gone');
|
||||
const owner = JSON.parse(readFileSync(join(browser.dir, OWNER), 'utf8'));
|
||||
assert.deepEqual(owner, { host: hostname(), pid: process.pid, chrome: browser.proc.pid }, 'launch did not claim its profile');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (lock !== undefined) closeSync(lock);
|
||||
}
|
||||
assert.equal(existsSync(browser.dir), false, 'close left the owned profile behind');
|
||||
} else {
|
||||
test('launch preserves older foreign profiles and close removes its own', {
|
||||
skip: process.platform === 'win32' && 'POSIX fake browser fixture',
|
||||
}, () => {
|
||||
const base = mkdtempSync(join(tmpdir(), 'taste-profile-test-'));
|
||||
try {
|
||||
const fakeChrome = join(base, 'fake-chrome');
|
||||
// No real browser, network, or existing user profile is accessed.
|
||||
writeFileSync(fakeChrome, '#!/bin/sh\nprintf "DevTools listening on ws://fixture\\n" >&2\nexec tail -f /dev/null\n', { mode: 0o755 });
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--fixture'], {
|
||||
env: { ...process.env, CHROME_PATH: fakeChrome, TASTE_SKILL_TMP: base },
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error?.message);
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('sweep removes a profile only when its owner file says both processes are gone', async () => {
|
||||
const { sweepProfiles } = await import('../skills/taste-skill/scripts/lib/cdp.mjs');
|
||||
const base = mkdtempSync(join(tmpdir(), 'taste-sweep-test-'));
|
||||
try {
|
||||
const gone = deadPid();
|
||||
const here = hostname();
|
||||
const abandoned = profile(base, 'ad-chrome-abandoned', { host: here, pid: gone, chrome: gone });
|
||||
const kept = [
|
||||
profile(base, 'ad-chrome-run-alive', { host: here, pid: process.pid, chrome: gone }),
|
||||
profile(base, 'ad-chrome-chrome-alive', { host: here, pid: gone, chrome: process.pid }),
|
||||
profile(base, 'ad-chrome-other-machine', { host: `${here}-elsewhere`, pid: gone, chrome: gone }),
|
||||
profile(base, 'ad-chrome-no-owner'),
|
||||
profile(base, 'ad-chrome-torn-owner', '{"host":'),
|
||||
profile(base, 'ad-chrome-bad-pid', { host: here, pid: 'x', chrome: gone }),
|
||||
profile(base, 'not-a-profile', { host: here, pid: gone, chrome: gone }),
|
||||
];
|
||||
sweepProfiles(base);
|
||||
assert.equal(existsSync(abandoned), false, 'sweep kept a profile whose owners are gone');
|
||||
for (const p of kept) assert.ok(existsSync(join(p, 'sentinel')), `sweep removed ${p}`);
|
||||
sweepProfiles(join(base, 'missing')); // a missing base is not an error
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user