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:
hamed
2026-08-02 14:27:23 +03:30
co-authored by Claude Opus 5
parent 444ebc897a
commit 1c4f2a2451
2 changed files with 333 additions and 93 deletions
+133 -78
View File
@@ -1,165 +1,220 @@
---
name: redesign-page
description: بازطراحی UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرین‌شات گرفتن از صفحه، نگاشت URL به فایل سورس، آدیت انحراف‌ها از دیزاین‌سیستم، و بازنویسی صفحه با کامپوننت‌ها و توکن‌های موجود. استفاده کن وقتی کاربر یک URL از /admin می‌دهد و می‌گوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «redesign this page»، «این قسمت را درست کن»، یا «screenshot این صفحه».
description: نقد و بازطراحی حرفه‌ای UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرین‌شات در چهار نما (روشن، تیره، فشرده، موبایل)، پروبِ دسترسی‌پذیری روی DOM زنده، نگاشت URL به فایل سورس، آدیت انحراف از دیزاین‌سیستم، و بازنویسی با کامپوننت‌ها و توکن‌های موجود. استفاده کن وقتی کاربر یک URL از /admin می‌دهد و می‌گوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «این صفحه را نقد کن»، «redesign this page»، «UI/UX review»، «این قسمت را درست کن»، یا «screenshot این صفحه».
---
# بازطراحی صفحه پنل ادمین ClinicPro
# نقد و بازطراحی صفحهٔ پنل ادمین ClinicPro
نقش: متخصص ارشد UI/UX. هدف **بهبود تجربهٔ کاربری در چهارچوب تم فعلی** است، نه ساختن
هویت بصری جدید. هر تغییری که با دیزاین‌سیستم فعلی ناسازگار باشد، رد است.
پنل ادمین یک SPA کلاینت‌ساید است (React 19 + Webpack Encore، سرو شده از `/admin/*`).
یعنی `curl` و فلگ `--screenshot` کروم به درد نمی‌خورند: هر دو روی فرم لاگین می‌نشینند،
چون توکن JWT در `localStorage['clinicpro-auth']` است.
درایور این skill آن کار را انجام می‌دهد: با API لاگین می‌کند، `localStorage` را seed
می‌کند، بعد ناوبری و اسکرین‌شات می‌گیرد — با CDP روی `WebSocket` نیتیو Node 22،
**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
چون توکن JWT در `localStorage['clinicpro-auth']` است. درایور این skill آن کار را
می‌کند: با API لاگین می‌کند، `localStorage` را seed می‌کند، تم/تراکم را می‌نشاند، بعد
ناوبری و اسکرین‌شات می‌گیرد — با CDP روی `WebSocket` نیتیو Node 22، **بدون هیچ وابستگی
npm** (نه playwright، نه puppeteer).
مسیرها نسبت به `clinicpro/` هستند.
## پیش‌نیازها
هیچ نصبی لازم نیست. فقط این دو:
هیچ نصبی لازم نیست:
```bash
ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
```
کروم در مسیر دیگری است؟ `CHROME_BIN` را ست کن.
کروم جای دیگری است؟ `CHROME_BIN` را ست کن.
## گردش کار
### ۱. اسکرین‌شات صفحه فعلی
### ۰. اول دیزاین‌سیستم را بخوان — قبل از هر چیز
**منبع حقیقتِ توکن‌ها `assets/admin/styles.css` است**، نه `docs/admin-ui/ui-design-spec.md`
(آن سند قدیمی است و پالت بنفشش با کد شیپ‌شده نمی‌خواند).
```bash
node .claude/skills/redesign-page/driver.mjs shot \
"https://clinic-pro.ddev.site/admin/appointments" --out /tmp/before.png
node .claude/skills/redesign-page/driver.mjs ds # توکن‌ها + کلاس‌ها + کامپوننت‌ها
node .claude/skills/redesign-page/driver.mjs ds tokens # فقط توکن‌ها
node .claude/skills/redesign-page/driver.mjs ds components # فقط کامپوننت‌های مشترک با Props
```
**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** بدون دیدنِ صفحه، بازطراحی
یعنی حدس زدن.
خروجی واقعی: `TOKENS (57)` و `SHARED COMPONENTS (26)`. قانون ترتیب:
**اول کامپوننت موجود، بعد توسعه/عمومی‌کردنش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
فلگ‌ها: `--w 1440 --h 900` (سایز ویوپورت)، `--wait 4000` (میلی‌ثانیه صبر برای رندر)،
`--full` (کل صفحه، نه فقط ویوپورت).
موبایل هم ببین — این پنل RTL و پرجدول است و بیشتر مشکلات ریسپانسیو آنجاست:
### ۱. چهار نمای اجباری
```bash
node .claude/skills/redesign-page/driver.mjs shot \
"https://clinic-pro.ddev.site/admin/appointments" --w 390 --h 844 --out /tmp/mobile.png
node .claude/skills/redesign-page/driver.mjs variants \
"https://clinic-pro.ddev.site/admin/resources" --dir /tmp/clinicpro-review
```
### ۲. نگاشت URL به سورس + آدیت
چهار فایل می‌سازد: `-light` · `-dark` · `-compact` · `-mobile`. **هر چهار را با ابزار
Read باز کن و نگاه کن.** قضاوت با یک اسکرین‌شات یعنی صفحه‌ای که در سه نمای دیگر خراب است.
تم تیره و تراکم فشرده در این پنل تنظیمات واقعی کاربرند، نه فرض.
تک‌نما:
```bash
node .claude/skills/redesign-page/driver.mjs inspect \
"https://clinic-pro.ddev.site/admin/clinics/41e325c4-e825-4067-8438-5d828ecaee09"
node .claude/skills/redesign-page/driver.mjs shot "<url>" --out /tmp/x.png \
--theme dark --density compact --w 390 --h 844 --full --wait 6000
```
فلگ‌ها: `--w/--h` ویوپورت · `--wait` میلی‌ثانیه · `--full` کل صفحه ·
`--theme light|dark` · `--density comfortable|compact` · `--context clinic|personal` ·
`--no-probe`.
هر `shot` یک **پروب رانتایم** هم می‌زند که فقط روی DOM رندرشده دیدنی است:
```
RUNTIME
⚠ 2 form field(s) with no label
```
چه چیزهایی می‌گیرد: سرریز افقی، دکمهٔ آیکونیِ بی‌نام (بدون `aria-label`/`title`
فیلد بدون لیبل، و کنترل کوتاه‌تر از ۳۲px (هدف لمسی ۴۴px است).
### ۲. نگاشت URL به سورس + آدیت ایستا
```bash
node .claude/skills/redesign-page/driver.mjs inspect "https://clinic-pro.ddev.site/admin/resources"
```
خروجی واقعی:
```
route clinics/:uuid
component ClinicDetailPage
file assets/admin/pages/ClinicDetailPage.tsx
components ConfirmDialog, Modal, PageHeader, SearchableSelect, NotificationMobileCard
lines 1035
route resources
component ResourcesPage
file assets/admin/pages/ResourcesPage.tsx
components PageHeader, DataTable, ResourceBlocksModal, ConfirmDialog, SearchableSelect, …
lines 278
test assets/admin/pages/ResourcesPage.test.tsx
AUDIT
assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared <Modal>
AUDIT clean
```
روی هر فایل دلخواه هم مستقیم:
روی هر فایل مستقیم:
```bash
node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/AppointmentsPage.tsx
node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/ClinicDetailPage.tsx
# AUDIT
# assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared <Modal>
```
### ۳. قبل از نوشتن کد، دیزاین‌سیستم را بخوان
آدیت این‌ها را می‌گیرد: `<select>` نیتیو، `.btn` بدون واریانت، هگز هاردکد، overlay دستی،
`<label>` داخل `.field`، تاریخ میلادی، `type="date"`، دکمهٔ آیکونی بدون `aria-label`،
توکن تعریف‌نشده، و `.seg` بدون کلاس `on/active`.
**منبع حقیقتِ توکن‌ها `assets/admin/styles.css` است** — نه `docs/admin-ui/ui-design-spec.md`
(آن سند قدیمی و پالت بنفشش با کد شیپ‌شده نمی‌خواند).
### ۳. گزارش — همیشه با این ۹ بخش
```bash
sed -n '/^:root/,/^}/p' assets/admin/styles.css | head -60 # توکن‌ها
ls assets/admin/components/ui/ # کامپوننت‌های آماده
```
۱. تحلیل صفحه چه کاری برای چه کاربری؛ جریان اصلی
۲. مشکلات UI سلسله‌مراتب بصری، فاصله، تایپوگرافی، رنگ، انحراف از DS
۳. مشکلات UX جریان کار، تعداد کلیک، حالت‌های Loading/Empty/Error، ریسپانسیو، دسترسی‌پذیری
۴. پیشنهادهای بهبود برای هر مشکل، یک راه‌حل مشخص و قابل اجرا
۵. ساختار جدید صفحه چیدمان پیشنهادی، در چهارچوب همین تم
۶. کامپوننت‌های قابل استفادهٔ مجدد از components/ui که همین حالا جواب می‌دهند
۷. کامپوننت‌های نیازمند بهبود کدام Props/API باید عمومی‌تر شود و چرا
۸. کامپوننت‌های جدید فقط در صورت ضرورت، با دلیل نبودِ جایگزین
۹. دلیل هر تغییر چرا این تغییر تجربه را بهتر می‌کند
```
قانون: **اول کامپوننت موجود، بعد توسعه‌اش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
هر یافته باید به `file:line` وصل باشد یا به یکی از اسکرین‌شات‌ها. یافتهٔ بی‌ارجاع، حدس است.
### ۴. بازنویسی، سپس مقایسه
بعد از ادیت، دوباره اسکرین‌شات بگیر و با `before.png` مقایسه کن:
```bash
yarn dev # یا: yarn watch
node .claude/skills/redesign-page/driver.mjs shot "<همان url>" --out /tmp/after.png
ddev exec yarn dev
node .claude/skills/redesign-page/driver.mjs variants "<همان url>" --dir /tmp/clinicpro-review-after
```
before/after را کنار هم بگذار. اگر تفاوتی دیده نمی‌شود، باندل قدیمی است.
### ۵. تست + تایپ‌چک (بدون این، تسک تمام نیست)
```bash
npx tsc --noEmit -p tsconfig.json
npx vitest run assets/admin/pages/<YourPage>.test.tsx
ddev exec npx tsc --noEmit --project tsconfig.json
npx vitest run # روی هاست، نه داخل ddev
```
توجه: سوییت کامل همین الان **۲۱ تست از پیش شکسته** دارد (`api.test.ts`، `LoginPage`،
`PatientDetailPage`، …) که ربطی به کار تو ندارند. قبل از شروع یک‌بار `npx vitest run`
بگیر و عدد پایه را یادداشت کن، وگرنه خطاهای موجود را به گردن تغییر خودت می‌اندازی.
خط پایه در ۲۰۲۶-۰۸-۰۲: **۱۰۰ فایل، ۶۶۰ تست، همه سبز.** هر شکستی مالِ توست.
(نسخهٔ قبلی این سند از «۲۱ تست از پیش شکسته» می‌گفت — دیگر درست نیست.)
## چک‌لیست بازطراحی
درایور موارد گرپ‌شدنی را می‌گیرد؛ این‌ها را باید خودت با چشم ببینی:
درایور موارد گرپ‌شدنی و DOMی را می‌گیرد؛ این‌ها را باید خودت با چشم ببینی:
- **`.field` در مقابل `.field-block`** — `.field` یک باکس افقی بوردردار است که لیبل
*داخلش* می‌نشیند. اگر `<label>` داخل `.field` بگذاری، لیبل کنار اینپوت می‌چسبد؛ و اگر
`SearchableSelect` داخلش بگذاری، دو باکس تودرتو می‌شود. برای «لیبل بالای فیلد» از
`.field-block` استفاده کن.
- **`className="btn"` بدون واریانت** بی‌رنگ و بدون بوردر رندر می‌شود عملاً نامرئی.
همیشه `btn primary` / `btn ghost` / `btn soft` / `btn danger`.
- **دکمه‌های فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
- **توکن مرده** — مثلاً `var(--error)` وجود ندارد (`--danger` درست است). درایور این را می‌گیرد.
- **سلسله‌مراتب** — عنوان صفحه در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
- **`.field` در مقابل `.field-block`** — `.field` خودش باکسِ بوردردار اینپوت است. لیبل
داخلش یعنی لیبل چسبیده به اینپوت؛ `SearchableSelect` داخلش یعنی دو باکس تودرتو.
«لیبل بالای فیلد» → `.field-block`.
- **`.card` پدینگ ندارد** — `card-pad` آن را می‌دهد.
- **`className="btn"` بدون واریانت** بی‌رنگ و بی‌بوردر رندر می‌شود، عملاً نامرئی.
همیشه `btn primary` / `btn secondary` / `btn ghost` / `btn danger`.
- **دکمهٔ فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
- **`.seg`** فقط `button` و `a` را می‌شناسد و کلاس فعالش `on` است (`active` هم alias شد).
تبِ فعالِ بی‌کلاس یعنی هیچ نشانه‌ای ندارد.
- **سلسله‌مراتب** — عنوان در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
- **صفحهٔ زیرمجموعه** حتماً `backTo` یا `<BackButton fallback=…>` دارد.
- **وضعیت لیست در URL** — جستجو/فیلتر/صفحه با `useUrlState`، نه `useState`؛ وگرنه
«بازگشت» نما را می‌پراند.
- **RTL/جلالی** — رشته‌های جدید فارسی، تاریخ‌ها جلالی، اعداد با `formatNumber`/`formatRial`.
- **دارک‌مود** — چون توکن استفاده می‌کنی خودکار درست است؛ هگز هاردکد آن را می‌شکند.
- **دارک‌مود** — با توکن خودکار درست است؛ یک هگز هاردکد آن را می‌شکند.
- **سه حالت داده** — Loading / Empty / Error هر سه باید طراحی داشته باشند، نه فقط حالت پر.
## Gotchas
- **ریدایرکت خاموش نقش‌ها.** `RoleRoute` کاربری که نقشش اجازه ندارد را بی‌صدا به
`/admin/dashboard` می‌برد. یعنی یک اسکرین‌شات کاملاً سالم از **صفحهٔ اشتباه** می‌گیری.
درایور مسیر نهایی را با مسیر درخواستی مقایسه می‌کند و هشدار می‌دهد:
- **کاربر پیش‌فرض درایور با دیتابیس فعلی هماهنگ است، ولی دیتابیس عوض می‌شود.**
پیش‌فرض `0912000201` / `QaTest@1234` (پزشکِ مالک کلینیک) است. اگر لاگین `ERR_AUTH_005`
داد، دیتابیس دوباره seed شده: `ddev exec php bin/console app:seed-scenarios --reset -n`.
در اجرای ۲۰۲۶-۰۸-۰۲ کاربر `0912000301` (مالک غیرپزشک) **رمز نداشت** و لاگینش رد شد؛
از `0912000201` یا `0912000101` استفاده کن.
- **ریدایرکت خاموش نقش‌ها.** `RoleRoute` کاربرِ بی‌مجوز را بی‌صدا به `/admin/dashboard`
می‌برد — یعنی یک اسکرین‌شات کاملاً سالم از **صفحهٔ اشتباه**. درایور مقایسه می‌کند:
```
⚠ WRONG PAGE: asked for /admin/clinics/…, landed on /admin/dashboard
```
کاربر پیش‌فرض (`09390039833`) نقش **doctor** دارد. صفحات ادمین/کلینیک با آن باز نمی‌شوند.
برای آن‌ها `CLINICPRO_USER` / `CLINICPRO_PASS` را ست کن.
- **محیط کاری، نه نقش.** کاربری که هم مطب شخصی دارد هم کلینیک، پیش‌فرض روی مطب می‌نشیند و
صفحهٔ منابع/سرویس‌های کلینیک **خالی** می‌آید. این باگ نیست: `--context clinic` بده.
- **کاربران تستی ممکن است seed نشده باشند.** `TEST_USERS.md` ادمین `09100000001` با رمز
`Test@1234` را مستند می‌کند، ولی روی این دیتابیس وجود نداشت و لاگین `ERR_AUTH_005` داد.
ساختنشان: `ddev exec php create_test_users.php` (دیتابیس را می‌نویسد — اول بپرس).
- **مودال نصب PWA جلوی صفحه را می‌گیرد.** درایور `pwa-dismissed=1` را seed می‌کند. با
کروم خام، این مودال وسط تصویر است.
- **مودال نصب PWA جلوی صفحه را می‌گیرد.** درایور `localStorage['pwa-dismissed']='1'` را
seed می‌کند. اگر با کروم خام اسکرین‌شات بگیری، این مودال وسط تصویر است.
- **تم فقط با صفت `data-theme` نمی‌ماند** — بعد از hydrate از `localStorage['clinicpro-ui']`
دوباره خوانده می‌شود. درایور هر دو را می‌نویسد و بعد از رندر یک بار دیگر صفت را می‌گذارد.
- **موبایل بدون `Emulation.setDeviceMetricsOverride` فقط «پنجرهٔ باریک» است** — مدیا
کوئری‌های `pointer: coarse` خاموش می‌مانند و ارتفاع لمسی ۴۴px دیده نمی‌شود. درایور برای
عرض ≤۴۸۰ خودش این را روشن می‌کند.
- **کپچا (altcha) لوکال اجباری نیست.** `POST /api/v1/user/login` بدون فیلد `altcha` هم
توکن می‌دهد؛ درایور به همین تکیه می‌کند. اگر روی محیطی که کپچا را اجبار می‌کند اجرا شود، می‌شکند.
توکن می‌دهد؛ درایور به همین تکیه می‌کند و روی محیطی که کپچا را اجبار کند می‌شکند.
- **سرت ddev را Node رد می‌کند** (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). درایور فقط برای
هاست‌های `*.ddev.site` / `localhost` تأیید TLS را خاموش می‌کند، نه برای هر مبدأ.
`*.ddev.site` / `localhost` تأیید TLS را خاموش می‌کند، نه برای هر مبدأ.
- **صفحهٔ نوبت‌ها خودش اسکرول می‌شود** به ساعت جاری، پس ویوپورت وسط تایم‌لاین می‌افتد.
برای دیدن هدر از `--full` استفاده کن.
- **صفحهٔ نوبت‌ها خودش تا ساعت جاری اسکرول می‌کند**، پس ویوپورت وسط تایم‌لاین می‌افتد.
برای دیدن هدر `--full` بده.
- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` می‌دهد** — از قبل وجود دارد و جلوی
کامپایل JS/TS را نمی‌گیرد. خطاهای TypeScript همچنان در خروجی `tsc` می‌آیند.
- **منوی تنظیمات دو مصرف‌کننده دارد** — `settingsMenu.ts` منبع واحد است؛ سایدبار دسکتاپ و
فهرست موبایل هر دو از آن می‌خوانند. در موبایل کل منو **بالای** محتوا می‌نشیند، پس صفحهٔ
تنظیماتی در نمای ۳۹۰px یعنی ۱۵ آیتم منو قبل از رسیدن به محتوا. در نقد موبایل حتماً ببینش.
- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` می‌دهد** — از قبل هست و جلوی کامپایل
JS/TS را نمی‌گیرد؛ خطاهای TypeScript همچنان در خروجی `tsc` می‌آیند.
## Troubleshooting
| علامت | علت / راه‌حل |
|---|---|
| `login failed for 0912000201: … ERR_AUTH_005` | دیتابیس دوباره seed شده. `ddev exec php bin/console app:seed-scenarios --reset -n` یا `CLINICPRO_USER`/`CLINICPRO_PASS` را ست کن. |
| `Chrome did not expose CDP on :9333` | نمونهٔ کروم قبلی زنده مانده. `CDP_PORT=9444` بده یا پروسه را بکش. |
| `login failed: … ERR_AUTH_005` | کاربر seed نشده یا رمز فرق دارد. `TEST_USERS.md` را ببین. |
| `redirected to /login` | توکن رد شد؛ معمولاً یعنی JWT منقضی شده — دوباره اجرا کن. |
| `⚠ redirected to /login` | توکن رد شد؛ معمولاً JWT منقضی شده — دوباره اجرا کن. |
| `WRONG PAGE` | نقشِ کاربر اجازه ندارد، یا محیط اشتباه است (`--context clinic`). |
| `⚠ page text is only N chars` | صفحه خالی رندر شده. `--wait 8000` بده یا کنسول را چک کن. |
| اسکرین‌شات تغییرات را نشان نمی‌دهد | باندل قدیمی است. `yarn dev` بزن (یا `yarn watch` روشن باشد). |
| اسکرین‌شات تغییرات را نشان نمی‌دهد | باندل قدیمی است. `ddev exec yarn dev` (یا `yarn watch` روشن). |
| صفحهٔ کلینیک خالی است ولی خطا ندارد | محیط روی مطب شخصی است. `--context clinic`. |
+200 -15
View File
@@ -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);
}