chore(skill): make redesign-page a real UI/UX review harness
The skill's driver could not log in any more: its default credentials were a user the scenario seeder wiped, so every command died on ERR_AUTH_005 before taking a single screenshot. Defaults now point at a user the seeder actually creates, and the failure message says how to rebuild the users. A page was also being judged on one screenshot. Dark mode and compact density are real settings in this panel and mobile is where an RTL, table-heavy admin breaks, so `variants` now captures all four and the theme is written to the ui store rather than only stamped on the element — the attribute alone is overwritten at hydrate. Narrow shots enable device metrics, without which pointer:coarse media queries never fire and the 44px touch targets stay invisible. Every shot now probes the live DOM for the things no grep can see: horizontal overflow, nameless icon buttons, unlabelled fields, controls under 32px. The static audit gained Gregorian dates, native date inputs, icon buttons with no aria-label, and .seg without an on/active class. `ds` prints the tokens and the shared components with their props, so a redesign starts from what exists instead of inventing a second Modal. Also corrected a stale claim: the suite has no pre-broken tests — it is 100 files / 660 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,27 +4,39 @@
|
||||
* SPA from its URL, with no npm dependencies (Node 22's global WebSocket speaks
|
||||
* CDP directly, so there is no playwright/puppeteer install to babysit).
|
||||
*
|
||||
* node .claude/skills/redesign-page/driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
|
||||
* node .claude/skills/redesign-page/driver.mjs inspect <url>
|
||||
* node .claude/skills/redesign-page/driver.mjs audit <file.tsx>
|
||||
* driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
|
||||
* [--theme dark] [--density compact] [--context clinic]
|
||||
* driver.mjs variants <url> [--dir /tmp/review] ← the four shots a review needs
|
||||
* driver.mjs inspect <url>
|
||||
* driver.mjs audit <file.tsx>
|
||||
* driver.mjs ds [components|tokens]
|
||||
*
|
||||
* `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
|
||||
* navigates and captures. Needed because the admin is a client-side
|
||||
* SPA: Chrome's plain `--screenshot` flag lands on the login form.
|
||||
* `inspect` maps a URL to the route entry in App.tsx, the page source file, and
|
||||
* the design-system components it already imports.
|
||||
* `audit` greps one source file for the anti-patterns this project keeps
|
||||
* regrowing (native <select>, hardcoded hex, dead tokens, …).
|
||||
* `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
|
||||
* navigates and captures. Needed because the admin is a client-side
|
||||
* SPA: Chrome's plain `--screenshot` flag lands on the login form.
|
||||
* `variants` runs `shot` four times — light desktop, dark, compact, 390px mobile.
|
||||
* A redesign judged on one screenshot ships a page that breaks in the
|
||||
* other three; dark mode and compact density are real user settings
|
||||
* here, not hypotheticals.
|
||||
* `inspect` maps a URL to the route entry in App.tsx, the page source file, and
|
||||
* the design-system components it already imports.
|
||||
* `audit` greps one source file for the anti-patterns this project keeps
|
||||
* regrowing (native <select>, hardcoded hex, dead tokens, …) plus the
|
||||
* accessibility misses that never fail a build.
|
||||
* `ds` prints the design system — tokens from styles.css and the shared
|
||||
* components — so a redesign starts from what exists.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
|
||||
const USER = process.env.CLINICPRO_USER ?? '09390039833';
|
||||
const PASS = process.env.CLINICPRO_PASS ?? '09390039833';
|
||||
// دو کاربرِ سیدر که رمز دارند: 0912000101 پزشک مستقل، 0912000201 پزشکِ مالک کلینیک.
|
||||
// دومی هر دو محیط را دارد، پس بیشترین صفحه با آن باز میشود.
|
||||
const USER = process.env.CLINICPRO_USER ?? '0912000201';
|
||||
const PASS = process.env.CLINICPRO_PASS ?? 'QaTest@1234';
|
||||
const CHROME = process.env.CHROME_BIN
|
||||
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const PORT = Number(process.env.CDP_PORT ?? 9333);
|
||||
@@ -77,12 +89,48 @@ async function login() {
|
||||
body: JSON.stringify({ mobile_number: USER, password: PASS }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.access_token) throw new Error(`login failed: ${JSON.stringify(j).slice(0, 200)}`);
|
||||
if (!j.access_token) {
|
||||
throw new Error(
|
||||
`login failed for ${USER}: ${JSON.stringify(j).slice(0, 160)}\n`
|
||||
+ ' → seeded users are in TEST_USERS.md; rebuild them with\n'
|
||||
+ ' ddev exec php bin/console app:seed-scenarios --reset -n',
|
||||
);
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
/**
|
||||
* محیط کاری را عوض میکند. کاربری که هم مطب شخصی دارد هم کلینیک، پیشفرض روی مطب
|
||||
* مینشیند و صفحههای کلینیک خالی میآیند — که شبیه باگ است ولی نیست.
|
||||
*/
|
||||
async function switchContext(token, kind) {
|
||||
const r = await fetch(`${BASE}/api/v1/auth/contexts`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => null);
|
||||
|
||||
// اندپوینت فهرست محیطها عمومی نیست؛ uuid را از /api/v1/user/me میگیریم.
|
||||
const me = await (await fetch(`${BASE}/api/v1/user/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})).json().catch(() => ({}));
|
||||
|
||||
const contexts = me?.data?.available_contexts ?? me?.available_contexts ?? [];
|
||||
const want = contexts.find((c) => (c.type ?? c.scope) === kind);
|
||||
if (!want) {
|
||||
console.log(`⚠ no "${kind}" context for this user — staying where we are`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(`${BASE}/api/v1/auth/switch-context`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ db_uuid: want.db_uuid }),
|
||||
});
|
||||
console.log(`context → ${kind} (${want.name ?? want.db_uuid})`);
|
||||
}
|
||||
|
||||
async function shot(url, opts) {
|
||||
const { access_token, refresh_token } = await login();
|
||||
if (opts.context) await switchContext(access_token, opts.context);
|
||||
|
||||
const chrome = spawn(CHROME, [
|
||||
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
||||
@@ -105,6 +153,15 @@ async function shot(url, opts) {
|
||||
await S('Page.enable');
|
||||
await S('Runtime.enable');
|
||||
|
||||
// موبایل بدون این فلگ فقط «پنجرهٔ باریک» است: مدیا کوئریهای pointer: coarse
|
||||
// خاموش میمانند و ارتفاع لمسی ۴۴px که برای موبایل نوشته شده دیده نمیشود.
|
||||
if (opts.w <= 480) {
|
||||
await S('Emulation.setDeviceMetricsOverride', {
|
||||
width: opts.w, height: opts.h, deviceScaleFactor: 2, mobile: true,
|
||||
});
|
||||
await S('Emulation.setTouchEmulationEnabled', { enabled: true });
|
||||
}
|
||||
|
||||
// localStorage is origin-scoped, so the origin must be loaded before seeding.
|
||||
await S('Page.navigate', { url: `${BASE}/admin/login` });
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
@@ -115,20 +172,36 @@ async function shot(url, opts) {
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
// تم و تراکم را uiStore در همان localStorage نگه میدارد و روی <html> مینشاند؛
|
||||
// ستکردن مستقیم صفت، بعد از hydrate پس گرفته میشود، پس استور هم نوشته میشود.
|
||||
const ui = { state: { darkMode: opts.theme === 'dark', density: opts.density }, version: 0 };
|
||||
await S('Runtime.evaluate', {
|
||||
expression: `
|
||||
localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
|
||||
localStorage.setItem('clinicpro-ui', ${JSON.stringify(JSON.stringify(ui))});
|
||||
localStorage.setItem('pwa-dismissed', '1');
|
||||
document.documentElement.setAttribute('data-theme', ${JSON.stringify(opts.theme)});
|
||||
document.documentElement.setAttribute('data-density', ${JSON.stringify(opts.density)});
|
||||
`,
|
||||
});
|
||||
|
||||
await S('Page.navigate', { url });
|
||||
await new Promise((r) => setTimeout(r, opts.wait));
|
||||
|
||||
// تم بعد از hydrate ممکن است از استور دوباره خوانده شود؛ آخرین کلام با ما.
|
||||
await S('Runtime.evaluate', {
|
||||
expression: `
|
||||
document.documentElement.setAttribute('data-theme', ${JSON.stringify(opts.theme)});
|
||||
document.documentElement.setAttribute('data-density', ${JSON.stringify(opts.density)});
|
||||
`,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
const { data } = await S('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: opts.full,
|
||||
});
|
||||
mkdirSync(dirname(resolve(opts.out)), { recursive: true });
|
||||
writeFileSync(opts.out, Buffer.from(data, 'base64'));
|
||||
console.log(`✓ ${opts.out}`);
|
||||
|
||||
@@ -149,12 +222,70 @@ async function shot(url, opts) {
|
||||
console.log(' → set CLINICPRO_USER/CLINICPRO_PASS to a user with the right role.');
|
||||
}
|
||||
if (Number(len) < 40) console.log(`⚠ page text is only ${len} chars — may be blank`);
|
||||
|
||||
if (opts.probe) await probeRuntime(S);
|
||||
|
||||
ws.close();
|
||||
} finally {
|
||||
chrome.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* چیزهایی که فقط در DOMِ رندرشده دیده میشوند و هیچ گرپی رویشان نمیافتد:
|
||||
* سرریز افقی، دکمهٔ بینام، و فیلد بدون لیبل.
|
||||
*/
|
||||
async function probeRuntime(S) {
|
||||
const { result } = await S('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `(() => {
|
||||
const out = [];
|
||||
if (document.documentElement.scrollWidth > window.innerWidth + 2) {
|
||||
out.push('horizontal scroll: page is ' + document.documentElement.scrollWidth
|
||||
+ 'px wide in a ' + window.innerWidth + 'px viewport');
|
||||
}
|
||||
const nameless = [...document.querySelectorAll('button, a[role="button"]')]
|
||||
.filter(b => !(b.innerText || '').trim()
|
||||
&& !b.getAttribute('aria-label') && !b.getAttribute('title')).length;
|
||||
if (nameless) out.push(nameless + ' icon-only control(s) with no accessible name');
|
||||
const unlabelled = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')]
|
||||
.filter(i => !i.getAttribute('aria-label') && !i.getAttribute('aria-labelledby')
|
||||
&& !(i.id && document.querySelector('label[for="' + i.id + '"]'))
|
||||
&& !i.closest('label')).length;
|
||||
if (unlabelled) out.push(unlabelled + ' form field(s) with no label');
|
||||
const tiny = [...document.querySelectorAll('button, a')]
|
||||
.filter(b => { const r = b.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && r.height < 32; }).length;
|
||||
if (tiny) out.push(tiny + ' control(s) under 32px tall (44px is the touch target)');
|
||||
return out;
|
||||
})()`,
|
||||
});
|
||||
const findings = result.value ?? [];
|
||||
console.log(findings.length ? 'RUNTIME\n' + findings.map((f) => ' ⚠ ' + f).join('\n')
|
||||
: 'RUNTIME clean');
|
||||
}
|
||||
|
||||
/** چهار نمای اجباریِ هر بازطراحی: روشن، تیره، فشرده، موبایل. */
|
||||
async function variants(url, dir) {
|
||||
const slug = new URL(url).pathname.replace(/^\/admin\/?/, '').replace(/\W+/g, '-') || 'page';
|
||||
const runs = [
|
||||
{ name: 'light', w: 1440, h: 900, theme: 'light', density: 'comfortable' },
|
||||
{ name: 'dark', w: 1440, h: 900, theme: 'dark', density: 'comfortable' },
|
||||
{ name: 'compact', w: 1440, h: 900, theme: 'light', density: 'compact' },
|
||||
{ name: 'mobile', w: 390, h: 844, theme: 'light', density: 'comfortable' },
|
||||
];
|
||||
|
||||
for (const r of runs) {
|
||||
console.log(`\n── ${r.name} ${r.w}×${r.h} ${r.theme}/${r.density}`);
|
||||
await shot(url, {
|
||||
out: `${dir}/${slug}-${r.name}.png`,
|
||||
w: r.w, h: r.h, wait: 5000, full: true, probe: true,
|
||||
theme: r.theme, density: r.density, context: null,
|
||||
});
|
||||
}
|
||||
console.log(`\nنگاه کردن به هر چهار فایل اجباری است: ${dir}/${slug}-*.png`);
|
||||
}
|
||||
|
||||
// ── Static inspection ──────────────────────────────────────────────────────
|
||||
|
||||
/** URL path → the <Route> line in App.tsx → the page component file. */
|
||||
@@ -192,6 +323,8 @@ function inspect(url) {
|
||||
const ds = [...src.matchAll(/from\s+'\.\.\/components\/(ui\/)?([\w/]+)'/g)].map((m) => m[2]);
|
||||
console.log(`components ${[...new Set(ds)].join(', ') || '(none)'}`);
|
||||
console.log(`lines ${src.split('\n').length}`);
|
||||
const test = file.replace(/\.tsx$/, '.test.tsx');
|
||||
console.log(`test ${existsSync(`${REPO}/${test}`) ? test : '— none, write one'}`);
|
||||
auditSource(file, src);
|
||||
}
|
||||
}
|
||||
@@ -209,16 +342,56 @@ function auditSource(label, src) {
|
||||
push(/#[0-9a-fA-F]{6}\b/, 'hardcoded hex — use a var(--…) token');
|
||||
push(/className="overlay"/, 'hand-rolled overlay — use the shared <Modal>');
|
||||
push(/className="field"[\s\S]*?<label/, '<label> inside .field — .field is an inline box; use .field-block');
|
||||
push(/new Date\([^)]*\)\.toLocaleDateString\((?!'fa)/, 'Gregorian date — use formatDate() (Jalali)');
|
||||
push(/type="date"/, 'native date input — use PersianDateInput');
|
||||
// آیکون تنها داخل دکمه، بدون aria-label: در تست سبز است و برای screen reader بینام.
|
||||
src.split('\n').forEach((line, i) => {
|
||||
if (/<button(?![^>]*aria-label)/.test(line) && /Icon\b/.test(line) && !/>\s*[^\s<]/.test(line)) {
|
||||
findings.push(`${label}:${i + 1} icon-only <button> with no aria-label`);
|
||||
}
|
||||
});
|
||||
|
||||
// var(--x) references that styles.css never defines (e.g. the dead --error).
|
||||
for (const m of src.matchAll(/var\((--[\w-]+)/g)) {
|
||||
if (!tokens.includes(`${m[1]}:`)) findings.push(`${label} undefined token ${m[1]}`);
|
||||
}
|
||||
|
||||
// .seg فقط کلاس on/active را میشناسد؛ هر چیز دیگری یعنی تب فعال بینشانه.
|
||||
if (/className="seg"/.test(src) && !/'(on|active)'/.test(src)) {
|
||||
findings.push(`${label} .seg without an on/active class — the selected tab has no highlight`);
|
||||
}
|
||||
|
||||
console.log(findings.length ? '\nAUDIT\n' + [...new Set(findings)].map((f) => ' ' + f).join('\n')
|
||||
: '\nAUDIT clean');
|
||||
}
|
||||
|
||||
/** دیزاینسیستم موجود — قدم اول هر بازطراحی، پیش از نوشتن یک خط JSX. */
|
||||
function designSystem(what) {
|
||||
const css = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
|
||||
|
||||
if (what !== 'components') {
|
||||
const root = css.match(/^:root\s*\{([\s\S]*?)^\}/m)?.[1] ?? '';
|
||||
const vars = [...root.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((m) => ` ${m[1]}: ${m[2].trim()}`);
|
||||
console.log(`TOKENS (${vars.length}) — assets/admin/styles.css`);
|
||||
console.log(vars.join('\n'));
|
||||
|
||||
const classes = [...new Set([...css.matchAll(/^\.([\w-]+)[\s,{:]/gm)].map((m) => m[1]))];
|
||||
console.log(`\nCLASSES (${classes.length})\n ${classes.join(' · ')}`);
|
||||
}
|
||||
|
||||
if (what !== 'tokens') {
|
||||
const dir = `${REPO}/assets/admin/components/ui`;
|
||||
const ui = readdirSync(dir).filter((f) => f.endsWith('.tsx') && !f.endsWith('.test.tsx'));
|
||||
console.log(`\nSHARED COMPONENTS (${ui.length}) — assets/admin/components/ui/`);
|
||||
for (const f of ui) {
|
||||
const src = readFileSync(`${dir}/${f}`, 'utf8');
|
||||
const props = src.match(/interface Props\s*\{([\s\S]*?)\n\}/)?.[1] ?? '';
|
||||
const names = [...props.matchAll(/^\s*\/?\*?\s*(\w+)\??:/gm)].map((m) => m[1]);
|
||||
console.log(` ${f.replace('.tsx', '').padEnd(26)} ${names.slice(0, 8).join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const [cmd, arg, ...rest] = process.argv.slice(2);
|
||||
@@ -231,15 +404,27 @@ if (cmd === 'shot' && arg) {
|
||||
h: Number(flag('h', 900)),
|
||||
wait: Number(flag('wait', 4000)),
|
||||
full: rest.includes('--full'),
|
||||
probe: !rest.includes('--no-probe'),
|
||||
theme: flag('theme', 'light'),
|
||||
density: flag('density', 'comfortable'),
|
||||
context: flag('context', null),
|
||||
});
|
||||
} else if (cmd === 'variants' && arg) {
|
||||
await variants(arg, flag('dir', '/tmp/clinicpro-review'));
|
||||
} else if (cmd === 'inspect' && arg) {
|
||||
inspect(arg);
|
||||
} else if (cmd === 'audit' && arg) {
|
||||
auditSource(arg, readFileSync(resolve(REPO, arg), 'utf8'));
|
||||
} else if (cmd === 'ds') {
|
||||
designSystem(arg);
|
||||
} else {
|
||||
console.log(`usage:
|
||||
driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
|
||||
[--theme light|dark] [--density comfortable|compact]
|
||||
[--context clinic|personal] [--no-probe]
|
||||
driver.mjs variants <url> [--dir /tmp/clinicpro-review]
|
||||
driver.mjs inspect <url>
|
||||
driver.mjs audit <path/to/File.tsx>`);
|
||||
driver.mjs audit <path/to/File.tsx>
|
||||
driver.mjs ds [tokens|components]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user