@suphark/ui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +20 -0
  3. package/package.json +5 -1
  4. package/scripts/audit.mjs +218 -0
  5. package/src/components/app-shell/app-brand.tsx +1 -0
  6. package/src/components/app-shell/app-nav.tsx +2 -4
  7. package/src/components/app-shell/app-shell.tsx +6 -7
  8. package/src/components/app-shell/command-palette.tsx +256 -0
  9. package/src/components/app-shell/index.ts +11 -0
  10. package/src/components/app-shell/organization-switcher.tsx +150 -0
  11. package/src/components/app-shell/user-menu.tsx +8 -3
  12. package/src/components/data-table/data-table.tsx +34 -26
  13. package/src/components/data-table/types.ts +4 -0
  14. package/src/components/design-system/sections/showcase-data-display.tsx +2 -2
  15. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
  16. package/src/components/design-system/sections/showcase-overlays-navigation.tsx +2 -2
  17. package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
  18. package/src/components/shared/action-form.tsx +12 -3
  19. package/src/components/shared/button/floating-save-button.tsx +1 -1
  20. package/src/components/shared/calendar-month-grid.tsx +182 -0
  21. package/src/components/shared/form/form-field-color-select.tsx +1 -1
  22. package/src/components/shared/form/form-field-combobox.tsx +1 -1
  23. package/src/components/shared/form/form-field-date.tsx +1 -1
  24. package/src/components/shared/form/form-field-input.tsx +6 -6
  25. package/src/components/shared/form/form-field-radio-cards.tsx +4 -0
  26. package/src/components/shared/form/labeled-field.tsx +21 -0
  27. package/src/components/shared/form-dialog.tsx +12 -3
  28. package/src/components/shared/form-message.tsx +26 -6
  29. package/src/components/shared/icon-picker.tsx +192 -0
  30. package/src/components/shared/notice.tsx +1 -0
  31. package/src/components/shared/progress-bar.tsx +83 -0
  32. package/src/components/shared/selection-button.tsx +29 -0
  33. package/src/components/shared/status-badge.tsx +1 -0
  34. package/src/components/shared/theme/theme-menu-items.tsx +31 -14
  35. package/src/components/shared/theme/theme-toggle.tsx +3 -3
  36. package/src/components/shared/ui-text.tsx +41 -0
  37. package/src/index.ts +22 -1
  38. package/src/lib/status-colors.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.7.0] — 2026-09-20
6
+
7
+ component ระดับแอปที่ Pi Auth และ Pi Done เขียนซ้ำกัน — สเปกจาก Pi Auth ออกแบบให้ทั้งสองแอปใช้ร่วม (ไม่พึ่ง i18n/auth ของแอป ทุกข้อความรับเป็น prop หรือ `UiText`)
8
+
9
+ ### Added
10
+
11
+ - **`CommandPalette`** (`@suphark/ui/next`) — ค้นหาแบบ ⌘K/Ctrl+K ทั้งแอป: `pages` (หน้าในเมนูที่แอปกรองสิทธิ์แล้ว — กรองฝั่ง client ตาม label/group/href/keywords) + `search?(query) => Promise<CommandPaletteSection[]>` (server action ของแอป เรียกเมื่อ ≥ `minQuery` ตัว, debounce `debounceMs` 150, เก็บ query คู่ผล ผลล่าสุดเท่านั้นที่แสดง), `compact` (ปุ่มไอคอนใน header) หรือปุ่มเต็มกว้าง + `Kbd` (⌘K/CtrlK ตาม platform หลัง mount) สำหรับ `sidebarHeader`, `shortcut` (ค่าเริ่มต้น k), `text` (fallback `UiText.palette`), `onNavigate` (ค่าเริ่มต้น router.push + ปิด); `Command shouldFilter={false}`, `CommandEmpty` แสดง "กำลังค้นหา…" ขณะ pending; types `CommandPaletteItem`, `CommandPaletteSection`, `CommandPaletteText`
12
+ - **`OrganizationSwitcher`** (`@suphark/ui/next`) — ตัวสลับองค์กร/tenant แบบ Clerk บน `HeaderCrumb` + `DropdownMenu`: `organizations[{ id, name, slug?, logoUrl? }]`, `activeId`, `onSwitch(id)` (server action — component ทำ useTransition + `router.refresh()` ปิดได้ด้วย `refresh={false}`), `placement="header"` (HeaderCrumb ใน layout top) / `"sidebar"` (ปุ่มเต็มกว้าง + SwitcherChevron ใน `sidebarHeader`), `fallbackLabel`/`heading` (ค่าเริ่มต้น `UiText.switcher`), `hint(org)` (ค่าเริ่มต้น `/slug`), `footer` (slot ท้ายเมนู เช่น สร้างองค์กร); องค์กรเดียว = ไม่มีเมนู, active มี ✓ และกดไม่ทำอะไร, pending ปิดปุ่ม, โลโก้ `<img size-5>` หรือ Building2; types `OrganizationOption`, `OrganizationSwitcherProps`
13
+ - `UiText.palette` / `UiText.switcher` (optional, มีค่าเริ่มต้นไทย/อังกฤษ)
14
+ - **`IconPicker`** (barrel, generic `T extends string`) — เลือกไอคอนจากชุดที่แอปส่ง `icons[{ name, icon: LucideIcon, labelTh, labelEn, keywords? }]` (แพ็กเกจไม่ bundle ชุดไอคอน): ค้นหา name/labelTh/labelEn/keywords ผ่าน `useDeferredValue`, preview ไอคอน+ชื่อด้านบน, กริด `SelectionButton` (aria-pressed, title/aria-label ตาม `language` th/en), `accentColor` (inline style สี dynamic ของผู้ใช้), `heightClassName`/`columnsClassName`, `text` ทับข้อความ — สเปกจาก DueBell ให้ Pi Done (metadata icon) ใช้ร่วม
15
+ - **`SelectionButton`** (barrel) — ปุ่ม toggle `aria-pressed` + สีเน้นเมื่อเลือก (Button outline) — chip ประเภท/ตัวกรอง, ช่องใน IconPicker (ยกจาก DueBell)
16
+ - **`CalendarMonthGrid<T>`** (barrel) — ตารางเดือนเลือกวันที่แอปคุมเนื้อหาต่อวัน: `month "YYYY-MM"`, `selectedDay`, `onSelectDay(day, date)`, `itemsByDay`, `renderDay(ctx)` (คืน null = ✓ ของแพ็กเกจเมื่อ complete), `dayState(items, ctx) → { complete?, hasItems? }` → data-attribute `data-selected/today/has-items/complete/weekend` ให้ CSS ของแอปแต่ง, (data-attribute เป็น boolean มี/ไม่มี — CSS ใช้ `[data-selected]`), `weekStartsOn 0|1`, `weekdayLabels` (export `WEEKDAYS_TH`/`WEEKDAYS_EN`), `today` (ไม่ส่ง = browser หลัง mount ผ่าน `useMounted` — SSR ได้ไม่ mismatch), `dayLabel` (aria-label "วันที่ N มี M รายการ"), `legend`, `dayClassName`; ช่องว่างก่อนวันที่ 1 aspect-square; ปุ่มวัน `aria-pressed` — สเปกจาก DueBell + Pi Done
17
+ - **`ProgressBar`** (barrel) — แถบความคืบหน้าตั้ง `tone` (default/success/warning/destructive), `size` sm/md/lg, `label`, `showValue`/`format`, `trackClassName`/`indicatorClassName`/`indicatorStyle` (สี dynamic) จาก props — `Progress` ของ shadcn ต่อท้าย Track+Indicator ให้เสมอ ส่ง children เข้าไปจะได้ 2 แถบ (รายงานจาก DueBell) จึงประกอบจาก `ProgressPrimitive.Root` โดยตรง
18
+ - showcase: หน้า `icon-picker`, `selection-button`, `calendar-month-grid`, `progress-bar`; tests 6 กรณีเพิ่ม (รวม 100)
19
+ - **error รายช่องในฟอร์ม server action** (ขอจาก Pi Mail): `FormState` แบบ `ok: false` รับ `errors?: Record<string, string>`; `ActionForm` และ `FormDialog` รับ `children` เป็นฟังก์ชัน `({ state, pending, errors, fieldError }) => ReactNode` (type `FormRenderContext`) → `<LabeledField error={fieldError("email")}>` ไม่ต้องมี hook ของแอป; `FormMessage` ยังแสดง `error` รวมเหมือนเดิม
20
+ - `UserMenu`: prop `menuLabel` + `UiText.userMenu`/`signOut` (ขอจาก Pi PDF — แอปอังกฤษได้ label ไทย); `ThemeMenuItems`: prop `onThemeChange(theme)` สำหรับบันทึกธีมเป็น preference ของบัญชี + ข้อความจาก `UiText.theme`
21
+ - `LabeledField`: prop `htmlFor` — ส่งมาแล้ว label ไม่ครอบ control (ผูกด้วย id) ให้ tooling ที่ไม่คำนวณ nested label เห็น accessible name (ข้อสังเกตจาก Pi Files)
22
+ - `DataTable`: `showToolbar` / `showPagination` (ค่าเริ่มต้น true) ปิดแถบค้นหา/ตัวกรอง/View และ pagination สำหรับตารางตั้งค่าเล็ก ๆ (ขอจาก Pi Files)
23
+
24
+ ### Fixed
25
+
26
+ - **`AppShell` (layout sidebar) มี `<main>` ซ้อน 2 ชั้น** (รายงานจาก DueBell): `SidebarInset` ของ shadcn เป็น `<main>` อยู่แล้ว → ใส่ `id={APP_MAIN_ID}`/`tabIndex` ที่ SidebarInset และเลิกสร้าง `<main>` ชั้นใน (เป็น `<div>`); layout top ยังมี `<main>` ตัวเดียวเหมือนเดิม
27
+ - showcase: หน้า `command-palette`, `organization-switcher`; `/shell` (sidebarHeader + ยุบเป็นไอคอน) และ `/shell-top` (crumb องค์กร) ใช้สองตัวนี้จริงแทน mock เดิม; tests 5 กรณี (คีย์ลัด toggle + กรอง keywords, search debounce/ผลล่าสุด/push+ปิด, org เดียวไม่มีเมนู, active ไม่เรียก onSwitch + refresh, fallback label)
28
+
29
+ ## [0.6.1] — 2026-09-20
30
+
31
+ ### Added
32
+
33
+ - **`npx suphark-ui-audit`** (`bin` ของแพ็กเกจ, `scripts/audit.mjs`) — ตรวจโค้ดแอปปลายทางตามกติกา design system ในคำสั่งเดียว: error = `window-dialog` (window.confirm/alert/prompt), `radix-state` (`data-[state=…]` ที่ Base UI ไม่มี), `arbitrary-text` (`text-[Npx]`), `base-ui-props` (สคริปต์เดิม); warning = `raw-palette` (bg-red-500 …), `important` (utility ลงท้าย `!` — เฉพาะ `--important` สำหรับโค้ดแพ็กเกจ); `--strict` ให้ warning เป็น error; ยกเว้น `// ui-audit-ok: เหตุผล` / `/* ui-audit-ok-file: เหตุผล */`; ข้าม `components/ui/**`, tests, generated; README + `docs/agent-rules.md` มีวิธีใช้ — repo นี้รัน `pnpm audit:ui` ใน `verify` และ CI (0 error 0 warning)
34
+ - `FormFieldRadioCards`: prop `aria-label` ส่งต่อไป RadioGroup เมื่อไม่แสดง `label` (ข้อสังเกตจาก Pi Auth หลังย้ายมาใช้ — ของเดิมมี aria-label)
35
+
36
+ ### Changed
37
+
38
+ - ล้าง raw palette ในแพ็กเกจเป็น semantic token: labs badge ใน `AppNav` → `text-warning-muted-foreground`, `FloatingSaveButton` สำเร็จ → `bg-success`, `FormMessage` สำเร็จ → `text-success`, ดอกจัน/ข้อความ error ใน `FormField*` → `text-destructive`, ไอคอน `ThemeToggle` → `text-warning`/`text-primary`/`text-muted-foreground`, ตัวอย่างใน design-system; ที่ยังใช้ palette โดยตั้งใจและจดไว้ (`ui-audit-ok-file`): `Notice`/`StatusBadge` (โทน 50/900 คู่ dark — รอตัดสินใจ token ระดับ tone), `lib/status-colors.ts` (ชุดสีให้ผู้ใช้เลือก)
39
+
5
40
  ## [0.6.0] — 2026-09-20
6
41
 
7
42
  component สำเร็จรูปสำหรับฟอร์ม server action ที่ Pi Auth ประกอบเองอยู่ (DueBell/Mail/Files ใช้ได้เหมือนกัน) — ไม่เปลี่ยน API เดิม; **รวม 0.5.2 ที่ยังไม่ได้ publish**
package/README.md CHANGED
@@ -182,6 +182,26 @@ webview/automation บางตัวปิด `window.confirm` ทิ้ง (
182
182
 
183
183
  `createKanbanCollisionDetection({ columnItems, resolveColumnId })` (จาก `@suphark/ui/kanban`) ใช้เป็น `collisionDetection` ของ `DndContext` เมื่อมีหลายคอลัมน์ (SortableContext ต่อคอลัมน์) — ตัวอย่างบอร์ดเต็มที่ ui.suphark.com/components/kanban-board; wrapper ของการ์ด**ห้าม** `overflow-hidden` (dnd-kit ใส่ transform ที่ node ที่ `setNodeRef` ถ้าถูกครอบด้วย overflow-hidden การ์ดที่ขยับหลบจะถูก clip)
184
184
 
185
+ ### ตรวจกติกาในแอป — `npx suphark-ui-audit`
186
+
187
+ แพ็กเกจมี `bin` ให้รันในแอปปลายทาง (ตรวจ `src` ค่าเริ่มต้น; ข้าม `components/ui/**`, tests, generated):
188
+
189
+ ```bash
190
+ npx suphark-ui-audit # หรือใส่ใน package.json: "audit:ui": "suphark-ui-audit src"
191
+ npx suphark-ui-audit --strict # warning นับเป็น error (ใช้ใน CI เมื่อล้างหมดแล้ว)
192
+ ```
193
+
194
+ | กฎ | ระดับ | เจอ → ใช้แทน |
195
+ | --- | --- | --- |
196
+ | `window-dialog` | error | `window.confirm/alert/prompt(` → `ConfirmDialog` / `useConfirm()` / `useDialogs()` |
197
+ | `radix-state` | error | `data-[state=open\|checked\|active…]` → `data-open` / `data-checked` / `data-active` / `data-popup-open` (Base UI ไม่มี data-state) |
198
+ | `arbitrary-text` | error | `text-[Npx]` → `text-xs … text-3xl` |
199
+ | `base-ui-props` | error | `default*` ที่ค่าเปลี่ยนได้ / `onSelect` บนเมนู (ดู `scripts/audit-base-ui-props.mjs`) |
200
+ | `raw-palette` | warning | `bg-red-500`, `text-slate-600` … → `bg-destructive`, `text-muted-foreground`, `bg-success`, `bg-warning`, `text-primary` |
201
+ | `important` | warning (เฉพาะ `--important`) | utility ลงท้าย `!` ในโค้ดแพ็กเกจ |
202
+
203
+ ยกเว้นรายบรรทัด `// ui-audit-ok: <เหตุผล>` (บรรทัดเดียวกันหรือบรรทัดก่อน) — ยกเว้นทั้งไฟล์ `/* ui-audit-ok-file: <เหตุผล> */`
204
+
185
205
  ### สิทธิ์ (ActionButton/LinkButton `permission` prop)
186
206
 
187
207
  แพ็กเกจไม่รู้จักระบบสิทธิ์ของแอป — ครอบด้วย `UiPermissionProvider` แล้วส่งฟังก์ชัน `check(requirement, options)` ของแอปเข้ามา ถ้าไม่ครอบ = อนุญาตทุกอย่าง
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -22,6 +22,9 @@
22
22
  "sideEffects": [
23
23
  "*.css"
24
24
  ],
25
+ "bin": {
26
+ "suphark-ui-audit": "scripts/audit.mjs"
27
+ },
25
28
  "files": [
26
29
  "src",
27
30
  "!src/**/__tests__",
@@ -116,6 +119,7 @@
116
119
  },
117
120
  "scripts": {
118
121
  "typecheck": "tsc --noEmit",
122
+ "audit": "node scripts/audit.mjs src --important",
119
123
  "test": "vitest run",
120
124
  "test:watch": "vitest",
121
125
  "lint:base-ui": "node scripts/audit-base-ui-props.mjs",
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * suphark-ui-audit — ตรวจโค้ดแอปปลายทางตามกติกา design system (docs/agent-rules.md) ในคำสั่งเดียว
4
+ *
5
+ * npx suphark-ui-audit # ตรวจ src (ค่าเริ่มต้น)
6
+ * npx suphark-ui-audit src apps/web # หลายโฟลเดอร์
7
+ * npx suphark-ui-audit --strict # warning นับเป็น error ด้วย (ใช้ใน CI เมื่อล้างหมดแล้ว)
8
+ * npx suphark-ui-audit --no-palette # ปิดกฎ raw palette ชั่วคราว
9
+ * npx suphark-ui-audit --important # เปิดกฎ utility ลงท้าย ! (ค่าเริ่มต้นตรวจเฉพาะโค้ดของแพ็กเกจเอง)
10
+ *
11
+ * กฎ (error = พังบน Base UI / ผิดกติกาชัดเจน, warning = ควรแก้แต่ไม่บล็อก):
12
+ * window-dialog error window.confirm/alert/prompt → ConfirmDialog / useConfirm() / useDialogs()
13
+ * radix-state error data-[state=open|checked|active|…] ไม่มีบน Base UI → data-open / data-checked / data-active / data-popup-open
14
+ * arbitrary-text error text-[Npx] → ใช้ scale (text-xs … text-3xl)
15
+ * important warning utility ที่ลงท้าย ! (size-3.5!) — เฉพาะโค้ดแพ็กเกจ (--important): Tailwind ของแอปที่ scan ผ่าน @source อาจไม่ generate
16
+ * raw-palette warning bg-red-500 / text-slate-600 … → semantic token (bg-destructive, text-muted-foreground, bg-success …)
17
+ * base-ui-props error default* ที่ค่าเปลี่ยนได้ / onSelect บน Base UI (สคริปต์ audit-base-ui-props)
18
+ *
19
+ * ยกเว้นเป็นบรรทัด: ใส่ `// ui-audit-ok: <เหตุผล>` ท้ายบรรทัดหรือบรรทัดก่อนหน้า
20
+ * ยกเว้นทั้งไฟล์: `/* ui-audit-ok-file: <เหตุผล> *\/` ที่หัวไฟล์
21
+ * ข้ามอัตโนมัติ: node_modules, .next, dist, build, generated, __tests__, *.test.*, components/ui/** (ไฟล์ shadcn)
22
+ */
23
+ import { spawnSync } from "node:child_process";
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const args = process.argv.slice(2);
29
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
30
+ const roots = args.filter((a) => !a.startsWith("--"));
31
+ if (roots.length === 0) roots.push("src");
32
+ const STRICT = flags.has("--strict");
33
+ const PALETTE = !flags.has("--no-palette");
34
+ const SKIP_BASE_UI = flags.has("--no-base-ui");
35
+ const IMPORTANT = flags.has("--important");
36
+
37
+ const SKIP_DIRS = new Set([
38
+ "node_modules",
39
+ ".next",
40
+ "dist",
41
+ "build",
42
+ "generated",
43
+ "__tests__",
44
+ ".git",
45
+ ]);
46
+ const LINE_MARKER = "ui-audit-ok:";
47
+ const FILE_MARKER = "ui-audit-ok-file:";
48
+
49
+ const PALETTE_COLORS =
50
+ "red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone";
51
+ const RULES = [
52
+ {
53
+ id: "window-dialog",
54
+ level: "error",
55
+ regex: /\bwindow\.(confirm|alert|prompt)\s*\(/g,
56
+ message:
57
+ "ใช้ ConfirmDialog / useConfirm() / useDialogs() ของ @suphark/ui แทน window dialog (webview บางตัวปิดทิ้ง)",
58
+ },
59
+ {
60
+ id: "radix-state",
61
+ level: "error",
62
+ regex:
63
+ /data-\[state=(?:open|closed|checked|unchecked|active|inactive|on|off|visible|hidden|delayed-open|instant-open)\]/g,
64
+ message:
65
+ "Base UI ไม่ใส่ data-state — ใช้ data-open / data-closed / data-checked / data-active / data-popup-open",
66
+ },
67
+ {
68
+ id: "arbitrary-text",
69
+ level: "error",
70
+ regex: /\btext-\[\d+(?:px|rem|em)\]/g,
71
+ message: "ห้าม text-[Npx] — ใช้ typography scale (text-xs … text-3xl) ตาม docs/design-system.md",
72
+ },
73
+ {
74
+ id: "important",
75
+ level: "warning",
76
+ regex: /(?<=["'`\s])[a-z][\w:/[\]().%=,-]*!(?=["'`\s])/g,
77
+ message:
78
+ "utility ที่ลงท้าย ! — แอปที่ scan แพ็กเกจผ่าน @source อาจไม่ generate class นี้ ปรับ specificity แทน",
79
+ },
80
+ {
81
+ id: "raw-palette",
82
+ level: "warning",
83
+ regex: new RegExp(
84
+ `(?<![\\w-])(?:[a-z-]+:)*(?:bg|text|border|ring|fill|stroke|from|to|via|outline|decoration|divide|shadow|accent|caret|placeholder)-(?:${PALETTE_COLORS})-(?:50|100|200|300|400|500|600|700|800|900|950)(?:/\\d+)?(?![\\w-])`,
85
+ "g",
86
+ ),
87
+ message:
88
+ "raw palette → semantic token (bg-muted, text-muted-foreground, bg-destructive, bg-success, bg-warning, text-primary …)",
89
+ },
90
+ ];
91
+
92
+ /** ปิดส่วนที่ไม่ใช่โค้ด (comment, <code>/<pre>, template ตัวอย่างในเอกสาร) ด้วยช่องว่างเพื่อคง offset */
93
+ function mask(content) {
94
+ return content
95
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
96
+ .replace(/(^|[^:\\])\/\/.*$/gm, (m, p1) => p1 + " ".repeat(m.length - p1.length))
97
+ .replace(/<code[\s\S]*?<\/code>/g, (m) => m.replace(/[^\n]/g, " "))
98
+ .replace(/<pre[\s\S]*?<\/pre>/g, (m) => m.replace(/[^\n]/g, " "));
99
+ }
100
+
101
+ function walk(dir, out) {
102
+ let entries;
103
+ try {
104
+ entries = fs.readdirSync(dir, { withFileTypes: true });
105
+ } catch {
106
+ return out;
107
+ }
108
+ for (const e of entries) {
109
+ const full = path.join(dir, e.name);
110
+ if (e.isDirectory()) {
111
+ if (SKIP_DIRS.has(e.name)) continue;
112
+ walk(full, out);
113
+ } else if (/\.(tsx?|jsx?|mdx?)$/.test(e.name) && !/\.test\.[jt]sx?$/.test(e.name)) {
114
+ // ไฟล์ shadcn ของแอป (สำเนา CLI) ไม่ตรวจ — ควรใช้ของแพ็กเกจแทนอยู่แล้ว
115
+ if (/[\\/]components[\\/]ui[\\/]/.test(full)) continue;
116
+ out.push(full);
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ const files = roots.flatMap((r) => walk(path.resolve(process.cwd(), r), []));
123
+ const findings = [];
124
+ let skippedFiles = 0;
125
+
126
+ for (const file of files) {
127
+ const raw = fs.readFileSync(file, "utf8");
128
+ if (raw.includes(FILE_MARKER)) {
129
+ skippedFiles++;
130
+ continue;
131
+ }
132
+ const lines = raw.split("\n");
133
+ const masked = mask(raw).split("\n");
134
+ for (const rule of RULES) {
135
+ if (rule.id === "raw-palette" && !PALETTE) continue;
136
+ if (rule.id === "important" && !IMPORTANT) continue;
137
+ for (let i = 0; i < masked.length; i++) {
138
+ const line = masked[i] ?? "";
139
+ rule.regex.lastIndex = 0;
140
+ let m = rule.regex.exec(line);
141
+ if (!m) continue;
142
+ const rawLine = lines[i] ?? "";
143
+ const prev = lines[i - 1] ?? "";
144
+ if (
145
+ rawLine.includes(LINE_MARKER) ||
146
+ (prev.trim().startsWith("//") && prev.includes(LINE_MARKER))
147
+ )
148
+ continue;
149
+ const hits = [];
150
+ while (m) {
151
+ hits.push(m[0]);
152
+ m = rule.regex.exec(line);
153
+ }
154
+ findings.push({
155
+ file: path.relative(process.cwd(), file),
156
+ line: i + 1,
157
+ rule: rule.id,
158
+ level: STRICT ? "error" : rule.level,
159
+ hits: [...new Set(hits)],
160
+ message: rule.message,
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ // base-ui props audit ของแพ็กเกจ (รันเป็นโปรเซสลูกเพื่อคง UI_DIR ชี้ที่ shadcn ของแพ็กเกจ)
167
+ let baseUiExit = 0;
168
+ let baseUiOutput = "";
169
+ if (!SKIP_BASE_UI) {
170
+ const here = path.dirname(fileURLToPath(import.meta.url));
171
+ const uiDir = path.resolve(here, "..", "src", "components", "ui");
172
+ const r = spawnSync(process.execPath, [path.join(here, "audit-base-ui-props.mjs"), ...roots], {
173
+ encoding: "utf8",
174
+ env: { ...process.env, UI_DIR: uiDir },
175
+ });
176
+ baseUiExit = r.status ?? 1;
177
+ baseUiOutput = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
178
+ }
179
+
180
+ // ---------- รายงาน ----------
181
+ const byRule = new Map();
182
+ for (const f of findings) byRule.set(f.rule, (byRule.get(f.rule) ?? 0) + 1);
183
+ const errors = findings.filter((f) => f.level === "error");
184
+ const warnings = findings.filter((f) => f.level === "warning");
185
+
186
+ findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file)));
187
+ let lastFile = "";
188
+ for (const f of findings) {
189
+ if (f.file !== lastFile) {
190
+ console.log(`\n${f.file}`);
191
+ lastFile = f.file;
192
+ }
193
+ const tag = f.level === "error" ? "✗" : "△";
194
+ console.log(` ${tag} :${f.line} ${f.rule} ${f.hits.join(", ")}`);
195
+ }
196
+ if (baseUiOutput && baseUiExit !== 0) console.log(`\n${baseUiOutput}`);
197
+
198
+ console.log(
199
+ `\nsuphark-ui-audit — ตรวจ ${files.length} ไฟล์ใน ${roots.join(", ")}${skippedFiles ? ` (ข้าม ${skippedFiles} ไฟล์ที่ทำเครื่องหมาย ${FILE_MARKER})` : ""}`,
200
+ );
201
+ for (const rule of RULES) {
202
+ const n = byRule.get(rule.id) ?? 0;
203
+ if (rule.id === "raw-palette" && !PALETTE) continue;
204
+ if (rule.id === "important" && !IMPORTANT) continue;
205
+ console.log(
206
+ ` ${n === 0 ? "✓" : rule.level === "error" || STRICT ? "✗" : "△"} ${rule.id.padEnd(15)} ${n}${n ? ` — ${rule.message}` : ""}`,
207
+ );
208
+ }
209
+ console.log(
210
+ ` ${baseUiExit === 0 ? "✓" : "✗"} base-ui-props ${baseUiExit === 0 ? "ผ่าน" : "พบปัญหา (ดูข้างบน)"}`,
211
+ );
212
+ console.log(`\n${errors.length} error, ${warnings.length} warning${STRICT ? " (--strict)" : ""}`);
213
+ if (errors.length || warnings.length) {
214
+ console.log(
215
+ `ยกเว้นรายบรรทัด: // ${LINE_MARKER} <เหตุผล> ยกเว้นทั้งไฟล์: /* ${FILE_MARKER} <เหตุผล> */`,
216
+ );
217
+ }
218
+ process.exit(errors.length > 0 || baseUiExit !== 0 ? 1 : 0);
@@ -65,6 +65,7 @@ export function AppBrand({
65
65
  <SidebarMenuItem className={cn("flex items-center", className)}>
66
66
  <SidebarMenuButton
67
67
  size="lg"
68
+ // ui-audit-ok: class เดียวกับ size=lg ของ sidebar.tsx (shadcn) จึงถูก generate แน่นอน
68
69
  className="min-w-0 flex-1 group-data-[collapsible=icon]:p-0!"
69
70
  render={<Link href={href} onClick={closeMobile} />}
70
71
  >
@@ -49,12 +49,10 @@ function MenuTitle({ title, isLabs }: { title: string; isLabs?: boolean }) {
49
49
  <TooltipTrigger
50
50
  render={<span className="flex w-full items-center gap-1.5 overflow-hidden" />}
51
51
  >
52
- <span
53
- className={cn("truncate", isLabs && "font-medium text-amber-600 dark:text-amber-500")}
54
- >
52
+ <span className={cn("truncate", isLabs && "font-medium text-warning-muted-foreground")}>
55
53
  {title}
56
54
  </span>
57
- {isLabs && <FlaskConical className="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-500" />}
55
+ {isLabs && <FlaskConical className="h-3 w-3 shrink-0 text-warning-muted-foreground" />}
58
56
  </TooltipTrigger>
59
57
  <TooltipContent side="right" align="center" className="max-w-xs break-words">
60
58
  {title}
@@ -114,7 +114,7 @@ export function AppShell(props: AppShellProps) {
114
114
  return <SidebarShell {...props} />;
115
115
  }
116
116
 
117
- /** id ของ <main> ที่ AppShell สร้าง — ใช้อ้างจาก skip link / anchor */
117
+ /** id ของ <main> ของ AppShell (sidebar: SidebarInset, top: main ในเนื้อหา) — ใช้อ้างจาก skip link / anchor */
118
118
  export const APP_MAIN_ID = "app-main";
119
119
 
120
120
  /** ลิงก์ "ข้ามไปเนื้อหา" สำหรับคีย์บอร์ด/screen reader — มองไม่เห็นจนกด Tab ตัวแรก */
@@ -180,7 +180,8 @@ function SidebarShell({
180
180
  collapsible={collapsible}
181
181
  />
182
182
 
183
- <SidebarInset>
183
+ {/* SidebarInset ของ shadcn เป็น <main> อยู่แล้ว → ใส่ id/tabIndex ให้ skip link ที่นี่ ไม่สร้าง <main> ซ้อน */}
184
+ <SidebarInset id={APP_MAIN_ID} tabIndex={-1} className="outline-none">
184
185
  {showHeader && (
185
186
  <div className="sticky top-0 z-40 shrink-0 bg-background print:static">
186
187
  {headerOpts.banner}
@@ -216,17 +217,15 @@ function SidebarShell({
216
217
  </header>
217
218
  </div>
218
219
  )}
219
- <main
220
- id={APP_MAIN_ID}
221
- tabIndex={-1}
220
+ <div
222
221
  className={cn(
223
- "flex flex-1 flex-col gap-4 p-4 outline-none md:gap-6 md:p-6 print:overflow-visible",
222
+ "flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 print:overflow-visible",
224
223
  contentMaxWidth && cn("mx-auto w-full", contentMaxWidth),
225
224
  contentClassName,
226
225
  )}
227
226
  >
228
227
  {children}
229
- </main>
228
+ </div>
230
229
  </SidebarInset>
231
230
  </SidebarProvider>
232
231
  );
@@ -0,0 +1,256 @@
1
+ "use client";
2
+
3
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
+ import { Button } from "@suphark/ui/components/ui/button";
5
+ import {
6
+ Command,
7
+ CommandDialog,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ CommandShortcut,
14
+ } from "@suphark/ui/components/ui/command";
15
+ import { Kbd } from "@suphark/ui/components/ui/kbd";
16
+ import { useMounted } from "@suphark/ui/hooks/use-mounted";
17
+ import { cn } from "@suphark/ui/lib/utils";
18
+ import { Search } from "lucide-react";
19
+ import { useRouter } from "next/navigation";
20
+ import { type ReactNode, useEffect, useMemo, useState, useTransition } from "react";
21
+
22
+ export interface CommandPaletteItem {
23
+ href: string;
24
+ label: string;
25
+ /** ชื่อกลุ่ม (แสดงเป็น shortcut ท้ายรายการ และใช้ค้นหา) */
26
+ group?: string;
27
+ icon?: ReactNode;
28
+ /** คำค้นเพิ่มเติมนอกจาก label/group/href */
29
+ keywords?: string[];
30
+ }
31
+
32
+ export interface CommandPaletteSection {
33
+ key: string;
34
+ heading: string;
35
+ items: Array<{ id: string; href: string; label: string; description?: string; icon?: ReactNode }>;
36
+ }
37
+
38
+ export interface CommandPaletteText {
39
+ placeholder: string;
40
+ title: string;
41
+ description: string;
42
+ empty: string;
43
+ searching: string;
44
+ /** หัวกลุ่ม "หน้า" */
45
+ pages: string;
46
+ }
47
+
48
+ const DEFAULT_TEXT: CommandPaletteText = {
49
+ placeholder: "ค้นหาหรือสั่งงาน…",
50
+ title: "ค้นหา",
51
+ description: "พิมพ์เพื่อค้นหาหน้าและข้อมูล",
52
+ empty: "ไม่พบผลลัพธ์",
53
+ searching: "กำลังค้นหา…",
54
+ pages: "หน้า",
55
+ };
56
+
57
+ /** ⌘ บน Mac, Ctrl ที่อื่น — อ่านฝั่ง client หลัง mount เท่านั้น (กัน hydration mismatch) */
58
+ function useModKeyLabel(mounted: boolean): string {
59
+ return useMemo(() => {
60
+ if (!mounted) return "Ctrl";
61
+ const p = `${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`;
62
+ return /mac|iphone|ipad/i.test(p) ? "⌘" : "Ctrl";
63
+ }, [mounted]);
64
+ }
65
+
66
+ /**
67
+ * ค้นหาแบบ ⌘K / Ctrl+K ทั้งแอป: หน้าในเมนู (แอปกรองสิทธิ์มาแล้ว) กรองฝั่ง client + ส่วนจาก server action
68
+ * (`search`) เมื่อพิมพ์ ≥ `minQuery` ตัว (debounce; ผลของ query ล่าสุดเท่านั้นที่แสดง)
69
+ * ปุ่มเรียก: `compact` = ไอคอนใน header, ค่าเริ่มต้น = ปุ่มเต็มกว้าง + Kbd ใน `sidebarHeader` ของ AppShell
70
+ * ข้อความรับเป็น prop `text` (ค่าเริ่มต้นจาก `UiText.palette` หรือไทย) — ไม่พึ่ง i18n ของแอป
71
+ * ยกจาก Pi Auth (features/console/command-palette) ให้ Pi Done/DueBell ใช้ร่วม
72
+ */
73
+ export function CommandPalette({
74
+ pages,
75
+ search,
76
+ compact = false,
77
+ shortcut = "k",
78
+ minQuery = 2,
79
+ debounceMs = 150,
80
+ text: textProp,
81
+ onNavigate,
82
+ className,
83
+ }: {
84
+ pages: CommandPaletteItem[];
85
+ /** server action ของแอป — คืน section ตามลำดับที่ต้องการแสดง */
86
+ search?: (query: string) => Promise<CommandPaletteSection[]>;
87
+ compact?: boolean;
88
+ /** ตัวอักษรคีย์ลัดร่วมกับ ⌘/Ctrl (ค่าเริ่มต้น "k") */
89
+ shortcut?: string;
90
+ minQuery?: number;
91
+ debounceMs?: number;
92
+ text?: Partial<CommandPaletteText>;
93
+ /** ค่าเริ่มต้น router.push + ปิดกล่อง — ครอบเพื่อ prefetch/analytics แล้วเรียกต่อเองได้ */
94
+ onNavigate?: (href: string) => void;
95
+ className?: string;
96
+ }) {
97
+ const ui = useUiText();
98
+ const text: CommandPaletteText = { ...DEFAULT_TEXT, ...ui.palette, ...textProp };
99
+ const router = useRouter();
100
+ const mounted = useMounted();
101
+ const mod = useModKeyLabel(mounted);
102
+ const [open, setOpen] = useState(false);
103
+ const [query, setQuery] = useState("");
104
+ const [result, setResult] = useState<{ query: string; sections: CommandPaletteSection[] } | null>(
105
+ null,
106
+ );
107
+ const [pending, start] = useTransition();
108
+
109
+ // คีย์ลัดจากที่ไหนก็ได้ในแอป
110
+ useEffect(() => {
111
+ const key = shortcut.toLowerCase();
112
+ const onKey = (e: KeyboardEvent) => {
113
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) {
114
+ e.preventDefault();
115
+ setOpen((o) => !o);
116
+ }
117
+ };
118
+ window.addEventListener("keydown", onKey);
119
+ return () => window.removeEventListener("keydown", onKey);
120
+ }, [shortcut]);
121
+
122
+ const q = query.trim();
123
+ const shouldSearch = Boolean(search) && q.length >= minQuery;
124
+
125
+ // ค้นหาฝั่ง server หลังหยุดพิมพ์ — เก็บ query คู่กับผล ผลที่ไม่ตรง query ปัจจุบันไม่แสดง
126
+ useEffect(() => {
127
+ if (!search || !shouldSearch) return;
128
+ const handle = setTimeout(() => {
129
+ start(async () => {
130
+ try {
131
+ const sections = await search(q);
132
+ setResult({ query: q, sections });
133
+ } catch {
134
+ setResult({ query: q, sections: [] });
135
+ }
136
+ });
137
+ }, debounceMs);
138
+ return () => clearTimeout(handle);
139
+ }, [q, search, shouldSearch, debounceMs]);
140
+
141
+ const sections = shouldSearch && result?.query === q ? result.sections : [];
142
+ const ql = q.toLowerCase();
143
+ const visiblePages = ql
144
+ ? pages.filter((p) =>
145
+ `${p.label} ${p.group ?? ""} ${p.href} ${(p.keywords ?? []).join(" ")}`
146
+ .toLowerCase()
147
+ .includes(ql),
148
+ )
149
+ : pages;
150
+ const nothing = visiblePages.length === 0 && sections.every((s) => s.items.length === 0);
151
+
152
+ function close() {
153
+ setOpen(false);
154
+ setQuery("");
155
+ }
156
+ function go(href: string) {
157
+ close();
158
+ if (onNavigate) onNavigate(href);
159
+ else router.push(href);
160
+ }
161
+
162
+ const shortcutLabel = `${mod}${shortcut.toUpperCase()}`;
163
+
164
+ return (
165
+ <>
166
+ {compact ? (
167
+ <Button
168
+ type="button"
169
+ variant="ghost"
170
+ size="icon"
171
+ aria-label={text.placeholder}
172
+ title={`${text.placeholder} (${shortcutLabel})`}
173
+ className={cn("text-muted-foreground", className)}
174
+ onClick={() => setOpen(true)}
175
+ >
176
+ <Search />
177
+ </Button>
178
+ ) : (
179
+ <Button
180
+ type="button"
181
+ variant="outline"
182
+ size="sm"
183
+ className={cn(
184
+ "h-8 w-full justify-start gap-2 bg-background px-2 font-normal text-muted-foreground",
185
+ className,
186
+ )}
187
+ onClick={() => setOpen(true)}
188
+ >
189
+ <Search />
190
+ <span className="flex-1 truncate text-left">{text.placeholder}</span>
191
+ <Kbd className="hidden sm:inline-flex">{shortcutLabel}</Kbd>
192
+ </Button>
193
+ )}
194
+ <CommandDialog
195
+ open={open}
196
+ onOpenChange={(o) => (o ? setOpen(true) : close())}
197
+ title={text.title}
198
+ description={text.description}
199
+ >
200
+ <Command shouldFilter={false}>
201
+ <CommandInput
202
+ autoFocus
203
+ placeholder={text.placeholder}
204
+ value={query}
205
+ onValueChange={setQuery}
206
+ />
207
+ <CommandList>
208
+ {nothing && (
209
+ <CommandEmpty>{pending && shouldSearch ? text.searching : text.empty}</CommandEmpty>
210
+ )}
211
+ {visiblePages.length > 0 && (
212
+ <CommandGroup heading={text.pages}>
213
+ {visiblePages.map((p) => (
214
+ <CommandItem key={p.href} value={p.href} onSelect={() => go(p.href)}>
215
+ <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
216
+ {p.icon}
217
+ </span>
218
+ <span className="flex-1 truncate">{p.label}</span>
219
+ {p.group && <CommandShortcut>{p.group}</CommandShortcut>}
220
+ </CommandItem>
221
+ ))}
222
+ </CommandGroup>
223
+ )}
224
+ {sections.map(
225
+ (s) =>
226
+ s.items.length > 0 && (
227
+ <CommandGroup key={s.key} heading={s.heading}>
228
+ {s.items.map((it) => (
229
+ <CommandItem
230
+ key={it.id}
231
+ value={`${s.key}:${it.id}`}
232
+ onSelect={() => go(it.href)}
233
+ >
234
+ <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
235
+ {it.icon}
236
+ </span>
237
+ <span className="flex-1 truncate">
238
+ {it.label}
239
+ {it.description && (
240
+ <span className="text-muted-foreground"> · {it.description}</span>
241
+ )}
242
+ </span>
243
+ </CommandItem>
244
+ ))}
245
+ </CommandGroup>
246
+ ),
247
+ )}
248
+ {pending && shouldSearch && !nothing && (
249
+ <div className="px-3 py-2 text-muted-foreground text-xs">{text.searching}</div>
250
+ )}
251
+ </CommandList>
252
+ </Command>
253
+ </CommandDialog>
254
+ </>
255
+ );
256
+ }