Files
clinicpro/.claude/skills/redesign-page/driver.mjs
T
hamedandClaude Opus 5 103d913cd0 refactor(admin): restructure the new-appointment modal around its real steps
The booking modal presented one flat scroll of fields whose order did not
match the order of the decisions behind them, and gave no reason when the
submit button stayed grey.

- Group the form into numbered steps (service+time, patient) so the order of
  decisions is visible. The optional visit-price collapse stays unnumbered —
  numbering an optional step reads as required.
- Show the first blocking condition above the footer instead of leaving a
  disabled button unexplained.
- Label the header chip's facts ("device:", "supervising doctor:") and add the
  appointment's Jalali date, which the modal never displayed at all.
- Replace the hand-rolled primary/ghost button pair with the design system's
  `.seg` + `.on`, and announce state via aria-pressed.
- Move autoFocus off the patient search in picker mode; the first decision is
  the section select above it.
- Give every input an id and its label an htmlFor.
- Surface a distinct error state for the slot query. A failed request used to
  fall through to "not enough free time", which sent users to another day for
  no reason.
- Raise the service remove button (18px), the duration pill and the time chips
  to at least the 32px hit target; mark service rows role=checkbox.
- Modal close button gets an accessible name; `.field` controls stretch to the
  full 40px box so the whole frame is clickable.

Runtime probe on the open modal goes from 4 unnamed icon controls, 1 unlabelled
field and 2 sub-32px controls to clean, across light/dark/compact/mobile.

The redesign-page probe now names the offending elements instead of only
counting them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:08:25 +03:30

476 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* ClinicPro admin page driver — screenshots and audits a page of the React admin
* 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).
*
* 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.
* `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, 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';
// دو کاربرِ سیدر که رمز دارند: 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);
// ddev serves a locally-signed cert that Node's fetch refuses. Only relax TLS for
// that local host — never for a real origin someone might point this at.
if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
// ── CDP plumbing ───────────────────────────────────────────────────────────
/** Chrome needs a moment before /json/version answers; poll instead of sleeping. */
async function waitForCdp(timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (r.ok) return (await r.json()).webSocketDebuggerUrl;
} catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Chrome did not expose CDP on :${PORT} within ${timeoutMs}ms`);
}
/** Minimal CDP client: send(method, params) → Promise<result>. */
function cdp(ws) {
let id = 0;
const pending = new Map();
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { resolve: res, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : res(msg.result);
}
});
return (method, params = {}, sessionId) =>
new Promise((res, reject) => {
const msgId = ++id;
pending.set(msgId, { resolve: res, reject });
ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
});
}
async function login() {
const r = await fetch(`${BASE}/api/v1/user/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number: USER, password: PASS }),
});
const j = await r.json();
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',
'--ignore-certificate-errors', // ddev serves a local CA cert
`--remote-debugging-port=${PORT}`,
`--user-data-dir=/tmp/clinicpro-shot-${process.pid}`,
`--window-size=${opts.w},${opts.h}`,
'about:blank',
], { stdio: 'ignore' });
try {
const ws = new WebSocket(await waitForCdp());
await new Promise((res) => ws.addEventListener('open', res, { once: true }));
const send = cdp(ws);
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
const S = (m, p) => send(m, p, sessionId);
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));
const auth = {
state: {
token: access_token, refreshToken: refresh_token, isAuthenticated: true,
},
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));
// مودال‌ها فقط با تعامل باز می‌شوند و بدون این، نقدشان ممکن نیست: --click یک
// متنِ دیدنی یا سلکتور می‌گیرد، اولین تطابق را می‌زند و منتظر رندر می‌ماند.
if (opts.click) {
const clicked = await S('Runtime.evaluate', {
returnByValue: true,
expression: `(() => {
const q = ${JSON.stringify(opts.click)};
let el = null;
try { el = document.querySelector(q); } catch {}
if (!el) {
// دکمه بر لینک مقدم است: نامِ یکسان معمولاً هم در سایدبار (a) هست هم
// روی خودِ صفحه (button)، و منظورِ نقد همیشه دومی است.
const hits = [...document.querySelectorAll('button,[role=button],a,td,.slot,.tl-slot')]
.filter((n) => (n.innerText || '').trim().includes(q) && n.offsetParent !== null);
el = hits.find((n) => n.closest('nav,.sidebar') === null) ?? hits[0];
}
if (!el) return 'not found: ' + q;
el.scrollIntoView({ block: 'center' });
el.click();
return 'clicked: ' + (el.innerText || el.className || el.tagName).slice(0, 60);
})()`,
});
console.log(' CLICK', clicked?.result?.value ?? '—');
await new Promise((r) => setTimeout(r, opts.clickWait ?? 1800));
}
// تم بعد از 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}`);
// The SPA redirects silently: RoleRoute bounces a user whose role lacks access
// straight to /dashboard, so you get a valid-looking screenshot of the WRONG
// page. Compare the landed path against the requested one and say so loudly.
const { result } = await S('Runtime.evaluate', {
expression: 'location.pathname + "|" + (document.body.innerText||"").trim().length',
returnByValue: true,
});
const [landed, len] = String(result.value).split('|');
const wanted = new URL(url).pathname;
if (landed.includes('/login')) {
console.log('⚠ redirected to /login — token rejected or expired');
} else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
console.log(`⚠ WRONG PAGE: asked for ${wanted}, landed on ${landed}`);
console.log(' → the test user\'s role probably lacks access (see RoleRoute in App.tsx).');
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 where = (el) => {
const tag = el.tagName.toLowerCase();
const cls = (typeof el.className === 'string' ? el.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 3);
const txt = (el.innerText || el.value || el.placeholder || '').trim().replace(/\s+/g, ' ').slice(0, 24);
const near = el.closest('[class]');
return tag + (el.id ? '#' + el.id : '') + (cls.length ? '.' + cls.join('.') : '')
+ (txt ? ' «' + txt + '»' : '')
+ (near && near !== el && typeof near.className === 'string'
? ' ← in .' + near.className.trim().split(/\s+/)[0] : '');
};
const list = (arr) => arr.map(where).join(' · ');
const nameless = [...document.querySelectorAll('button, a[role="button"]')]
.filter(b => !(b.innerText || '').trim()
&& !b.getAttribute('aria-label') && !b.getAttribute('title'));
if (nameless.length) out.push(nameless.length + ' icon-only control(s) with no accessible name\n ' + list(nameless));
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'));
if (unlabelled.length) out.push(unlabelled.length + ' form field(s) with no label\n ' + list(unlabelled));
const tiny = [...document.querySelectorAll('button, a')]
.filter(b => { const r = b.getBoundingClientRect();
return r.width > 0 && r.height > 0 && r.height < 32; });
if (tiny.length) out.push(tiny.length + ' control(s) under 32px tall (44px is the touch target)\n '
+ tiny.map(b => where(b) + ' [' + Math.round(b.getBoundingClientRect().height) + 'px]').join(' · '));
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, extra = {}) {
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, ...extra,
});
}
console.log(`\nنگاه کردن به هر چهار فایل اجباری است: ${dir}/${slug}-*.png`);
}
// ── Static inspection ──────────────────────────────────────────────────────
/** URL path → the <Route> line in App.tsx → the page component file. */
function inspect(url) {
const path = url.replace(/^https?:\/\/[^/]+/, '').replace(/^\/admin\/?/, '').split('?')[0];
const app = readFileSync(`${REPO}/assets/admin/App.tsx`, 'utf8');
const segs = path.split('/').filter(Boolean);
const routes = [...app.matchAll(/<Route\s+path="([^"]+)"[\s\S]*?element=\{([\s\S]*?)\}\s*\/>/g)]
.map(([, p, el]) => ({ p, comp: (el.match(/<(\w+)\s*\/>/g) ?? []).pop() ?? el.trim() }));
const score = (rp) => {
const rs = rp.split('/').filter(Boolean);
if (rs.length !== segs.length) return -1;
return rs.every((s, i) => s.startsWith(':') || s === segs[i]) ? rs.length : -1;
};
const hit = routes.map((r) => ({ ...r, s: score(r.p) })).filter((r) => r.s >= 0)
.sort((a, b) => b.s - a.s)[0];
if (!hit) {
console.log(`no route matched "${path}". Routes:\n` + routes.map((r) => ' ' + r.p).join('\n'));
return;
}
const comp = hit.comp.replace(/[<>/\s]/g, '');
const imp = app.match(new RegExp(`import\\s+${comp}\\s+from\\s+'([^']+)'`));
const file = imp ? `assets/admin/${imp[1].replace(/^\.\//, '')}.tsx` : '(inline element)';
console.log(`route ${hit.p}`);
console.log(`component ${comp}`);
console.log(`file ${file}`);
const abs = `${REPO}/${file}`;
if (existsSync(abs)) {
const src = readFileSync(abs, 'utf8');
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);
}
}
/** The anti-patterns this codebase keeps regrowing. Each one is a real past bug. */
function auditSource(label, src) {
const tokens = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
const findings = [];
const push = (re, msg) => {
src.split('\n').forEach((line, i) => { if (re.test(line)) findings.push(`${label}:${i + 1} ${msg}`); });
};
push(/<select\b/, 'native <select> — use SearchableSelect');
push(/className="btn"(?!\s*\+)/, '.btn with no variant — renders borderless/invisible');
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);
const flag = (n, d) => { const i = rest.indexOf(`--${n}`); return i >= 0 ? rest[i + 1] : d; };
if (cmd === 'shot' && arg) {
await shot(arg, {
out: flag('out', 'page.png'),
w: Number(flag('w', 1440)),
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),
click: flag('click', null),
clickWait: Number(flag('click-wait', 1800)),
});
} else if (cmd === 'variants' && arg) {
await variants(arg, flag('dir', '/tmp/clinicpro-review'), {
click: flag('click', null),
clickWait: Number(flag('click-wait', 1800)),
});
} 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 ds [tokens|components]`);
process.exit(1);
}