18 KiB
Re-skin + Layout Modernization ของ Web Portal (design system จาก admincourt.go.th)
Context
Web portal (/web — Nuxt 4 + Tailwind v3) ปัจจุบันใช้ default palette ตรงๆ ใน markup (~400 จุด: slate/sky/red/emerald/amber) กระจายใน 12 ไฟล์ .vue โดยไม่มี design token layer (tailwind.config.ts มีแค่ darkMode: 'class'', ไม่มีฟอนต์ไทย — ใช้ system font) และ layout เป็นแบบเก่า: header ไม่ sticky, container แคบ, ตารางทุกหน้ากินพื้นที่ overflow บนมือถือ, ไม่มีรูปแบบ page header ที่ consistent
งานนี้ทำ 2 อย่างพร้อมกัน:
- Re-skin ตาม scheme ของ admincourt.go.th: primary
#0000EE, accent gold#8F7226, surface ขาว, elevated ครีม#FBF7EC, radius 8px, ปุ่ม pill, shadow นุ่ม, ฟอนต์ sukhumvit — ผ่านการสร้าง token layer ที่เหมาะสม (ไม่ใช่ find-replace สี) - Layout modernization + responsive: sticky header, รูปแบบ page header มาตรฐาน, ตารางเปลี่ยนเป็น card list บนจอเล็ก (responsive table pattern สมัยใหม่), ฟอร์มจัด width และ action row ตามแนวปฏิบัติปัจจุบัน
ข้อตกลงกับผู้ใช้ (fixed):
- เก็บ dark mode ต่อ — derive dark palette จาก scheme (ครีม → warm near-black,
#0000EE→ indigo สว่าง) ใช้ CSS variables +.darkต่อจาก useTheme/FOUC guard เดิม,useTheme.spec.tsต้องเขียว - ฟอนต์ผ่าน Google Fonts CDN — Sarabun (ใกล้ Sukhumvit ที่สุด) น้ำหนัก 400/500/600/700 + stack
'Sukhumvit Set', 'Sarabun', ... - Scope =
/webเท่านั้น ห้ามแตะ/addin,/backend - (เพิ่มจาก feedback) Layout ต้องเป็น modern standard + responsive เต็มรูปแบบ
ขั้นตอนที่ 0 — บันทึกแผนลง repo
เขียนแผนฉบับนี้ไว้ที่ docs/plans/web-portal-redesign.md (ทำเป็น action แรกหลังอนุมัติแผน) เพื่อให้ session ถัดไปหยิบไป implement ต่อได้ — สอดคล้องกับธรรมเนียม docs/plans/officejs-implementation-plan.md ของ repo
ส่วนที่ 1 — Token layer (re-skin)
สถาปัตยกรรม
- CSS custom properties (
:root+.dark) เป็น source of truth ของสี, expose ผ่านtheme.extend.colorsด้วยrgb(var(--c-x) / <alpha-value>)—bg-primary/10ฯลฯ ใช้ได้ และdark:variants ~209 จุดหายเกือบหมด (สีพลิกผ่านตัวแปร)- ⚠️ ตัวแปรต้องเป็น RGB triplet เว้นวรรค (
0 0 238) — hex จะ break slash-opacity แบบเงียบ
- ⚠️ ตัวแปรต้องเป็น RGB triplet เว้นวรรค (
@layer components+@applyสำหรับ pattern ที่ repeat — ไม่สร้าง Vue component (ปุ่มเป็น mix ของ<button>/<NuxtLink>/ternary class, tests เป็น pure logic)- ⚠️ Tailwind v3 ห้าม
@applycustom class ในอีก custom class — compose ใน markup (class="btn btn-primary")
- ⚠️ Tailwind v3 ห้าม
- ห้าม set root font-size 18px — spacing เป็น rem จะเพี้ยนทั้ง scale; 18px เป็น typographic token (
text-body-lg) เฉพาะ prose
การ map scheme → ระบบจริง
| Scheme | การใช้ |
|---|---|
Primary #0000EE |
ปุ่ม primary, link, focus ring (light ใช้ #2977FF — #6CB5F9 บนขาว contrast ~2:1, สลับตามโหมด) |
Accent #8F7226 |
ใช้น้อย: badge "web portal", badge เตือน (แทน amber), accent bar หน้าหลัก |
Elevated #FBF7EC |
card, table, navbar (3 ชั้น: หน้าขาว → header/card ครีม → input ขาว inset) |
| Success/active | เขียว #53A336 (action-green ของ scheme) — state ที่ user scan หา ต้องเป็นเขียว |
| Error | แดง #DB3700 + -strong สำหรับ text บน tint (contrast AA) |
| Typography | home h1 = text-display 36px/700 · page h1 = text-page-title 28px/700 · section h2 = text-section-title 20px/700 · prose = text-body-lg 18px/1.5 · table/badge/ปุ่ม = 14/12px — 50px/155px เว้นตามกฎ "don't copy verbatim" |
| Radius | rounded-card 8px · rounded-pill 9999px · override borderRadius.DEFAULT → 8px |
| Shadow | shadow-soft = 0 4px 4px 0 rgba(0,0,0,.25) + shadow-soft-lg (hover) |
| Spacing | Tailwind default scale = 4/8/12/16/20/24/32/40/60 อยู่แล้ว — ห้ามค่านอก scale |
ขั้นตอน
1. tailwind.css — เพิ่ม vars หลัง 3 directives:
:root {
color-scheme: light;
--c-primary: 0 0 238; --c-primary-hover: 0 0 190; --c-on-primary: 255 255 255;
--c-accent: 143 114 38;
--c-success: 83 163 54; --c-success-strong: 41 109 24;
--c-error: 219 55 0; --c-error-strong: 184 43 0;
--c-focus: 41 119 255;
--c-surface: 255 255 255; --c-elevated: 251 247 236; --c-surface-muted: 243 237 220;
--c-ink: 0 0 0; --c-muted: 78 78 78; --c-line: 204 206 209;
}
.dark {
color-scheme: dark;
--c-primary: 143 143 255; --c-primary-hover: 170 170 255; --c-on-primary: 23 20 15;
--c-accent: 210 179 106;
--c-success: 148 205 128; --c-success-strong: 154 214 134;
--c-error: 255 128 96; --c-error-strong: 255 157 126;
--c-focus: 108 181 249;
--c-surface: 23 20 15; --c-elevated: 36 31 22; --c-surface-muted: 44 38 27;
--c-ink: 247 243 233; --c-muted: 174 166 148; --c-line: 62 56 43;
}
2. tailwind.config.ts — theme.extend: colors ทั้ง 15 ตัวแบบ rgb(var(--c-x) / <alpha-value>) · fontFamily.sans = ['Sukhumvit Set', 'Sarabun', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'] · fontSize: display/page-title/section-title/body-lg · borderRadius: DEFAULT 0.5rem, card 0.5rem, pill 9999px · boxShadow: soft, soft-lg — คง darkMode: 'class' + comment เดิม
3. @layer base + @layer components ใน tailwind.css:
- Base:
html { @apply font-sans antialiased },body { @apply bg-surface text-ink } - Components:
.btn(+.btn-primary.btn-secondary.btn-outline-danger.btn-outline-success.btn-sm.btn-xs) ·.input.label·.card.table-card.table-head.empty-state·.badge(+success/neutral/accent) ·.spinner.link.nav-link·.alert(+error/success/info).panel-info .btn-xsdefine หลัง.btnในไฟล์เดียวกัน → source order ชนะ padding
4. nuxt.config.ts — เพิ่มใน app.head (FOUC script byte-identical):
htmlAttrs: { lang: 'th' },
link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Sarabun:wght@400;500;600;700&display=swap' },
],
ส่วนที่ 2 — Layout modernization + responsive
Global shell — app.vue
- Sticky header (modern standard):
sticky top-0 z-20 border-b border-line bg-elevated/95 backdrop-blur(blur ผ่าน alpha var ได้อัตโนมัติ) - Container กว้างขึ้น: header และ main ใช้
mx-auto max-w-6xlเดียวกัน (ตารางหายใจได้, ฟอร์มคุม width เองในระดับหน้า) - Responsive padding:
px-4 py-3 sm:px-6(header),px-4 py-6 sm:px-6 sm:py-8(main) - Navbar บนมือถือ: คงกลไก flex-wrap (brand row + controls row ตอนจอแคบ) ปรับ
gap-x-4 gap-y-2, brandmin-w-0 truncate, badge "web portal"hidden sm:inline-flex - Skip link (a11y มาตรฐานปัจจุบัน):
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-pill focus:bg-primary focus:px-4 focus:py-2 focus:text-on-primary">ข้ามไปยังเนื้อหา</a>+<main id="main">
Page header pattern (ทุกหน้าใช้เหมือนกัน)
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="text-page-title">…</h1>
<p class="mt-1 text-sm text-muted">…</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">…actions…</div>
</div>
มือถือ: title กับ actions stack กัน, actions ไม่บีบเต็มความกว้าง · Desktop: actions ชิดขวา
Responsive table pattern (Tailwind UI "stacked on mobile" — ทุกตาราง)
Desktop เป็นตารางปกติ (hidden md:block table-card) · ต่ำกว่า md เป็น card list (md:hidden space-y-3):
<ul class="space-y-3 md:hidden">
<li v-for="t in templates" :key="t.id" class="card space-y-3 p-4">
<!-- ชื่อ/หัวข้อ (link font-semibold) + badge, description (line-clamp-2),
meta text-xs text-muted, action row: flex flex-wrap gap-2 (btn-xs) -->
</li>
</ul>
<div class="table-card hidden md:block"><table>…เดิม…</table></div>
ใช้กับ: templates index · template documents · document fields · version history (card มี download + expand toggle — ใช้ expandedVersionId state เดิม, expand แล้วโชว์ dl เดิมใน bg-surface-muted rounded-card p-3) · detected-fields ใน new · field-definitions editor ใน edit (card = key mono + inputs stack, v-model เดิม render สองที่ — Vue รองรับ)
Form pattern
- ฟอร์มคุม width:
max-w-xl(new/edit metadata) ·max-w-2xl(create document) - Action row มาตรฐานปัจจุบัน:
mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end(primary ขวา, มือถือ primary อยู่ล่างเต็มความกว้างw-full sm:w-autoบนปุ่ม) - Login card ใน AuthGate:
mx-auto max-w-mdกึ่งกลาง
Home
- Grid:
grid gap-4 sm:grid-cols-2 lg:grid-cols-3(เพิ่ม lg จากเดิมที่มีแค่ sm:2) - Card: footer ใช้
mt-auto border-t border-line pt-3ให้สูงเท่ากันทุกใบ - หัวหน้า:
text-display+ introtext-body-lg text-muted+ accent barh-1 w-10 rounded-pill bg-accent(decorative accent จุดเดียวของหน้า)
Breakpoints ที่ใช้ (Tailwind defaults)
sm 640 (grid 2 คอลัมน์, form row เป็นแนวนอน) · md 768 (table↔card, page header แนวนอน) · lg 1024 (grid 3 คอลัมน์)
ส่วนที่ 3 — แก้ 12 ไฟล์ .vue (token + layout พร้อมกัน)
กฎร่วม: เปลี่ยน palette → token/component class, ลบคู่ dark: ที่ตัวแปรรับแล้ว, ห้ามแต่ structure/v-if/handler/aria-*/functional utility (truncate, break-all, line-clamp-2, whitespace-nowrap)
- app.vue — shell/page header ตามส่วนที่ 2; nav →
nav-link; toggle/logout →btn btn-secondary btn-sm; login →btn btn-primary btn-sm; span "web portal" →badge badge-accent - AlertBox.vue — computed คืน
'alert-error'|'alert-success'|'alert-info', templateclass="alert"+:class; props/emoji/roleคงเดิม - AuthGate.vue —
spinner; login card →card mx-auto max-w-md p-8 text-center+text-section-title+ prosetext-body-lg text-muted; ปุ่มw-full sm:w-auto - index.vue — หัวหน้า + grid + card ตามส่วนที่ 2; link ในการ์ด →
link - auth/callback.vue —
spinner; retry →btn btn-primary, back →btn btn-secondary(จัดflex flex-col-reverse gap-2 sm:flex-row) - templates/index.vue — page header pattern (ปุ่มสร้าง →
btn btn-primary); ตาราง dual-render ตาม pattern; badge →badge badge-success/badge badge-neutral; ternary activate/deactivate →btn btn-xs btn-outline-danger/btn-outline-success; checkbox เพิ่มaccent-primary - templates/new.vue — ฟอร์ม
max-w-xl+ action row pattern;label/input; file input →input+file:mr-3 file:rounded-pill file:border-0 file:bg-surface-muted file:px-3 file:py-1(ลบdark:file:*); detected-fields dual-render; ดาวแดง →text-error-strong - templates/[id]/edit.vue — page header + back
btn btn-secondary btn-sm; section h2 →text-section-title; cell editor →class="input px-2 py-1 …"; field-definitions dual-render (มือถือ card ต่อ field, v-model เดิม); "บันทึกแล้วเมื่อ" →text-success-strong; metadata formmax-w-xl - templates/[id]/documents.vue — page header + ตาราง dual-render ตาม pattern
- documents/create/[templateId].vue — ฟอร์ม
max-w-2xl+ action row; result card →card p-6+ downloadbtn btn-primary; panel "ขั้นตอนต่อไป" →panel-info; invalid →:class="issues[f.key] ? 'border-error' : ''"; validation msg →text-xs text-error-strong - documents/[documentId].vue — page header pattern (ปุ่ม re-generate อยู่ actions ฝั่งขวา); ตาราง dual-render ทั้ง fields และ version history (expand ใน card ใช้ state เดิม); badge amber ทั้งสอง →
badge badge-accent; hover แถว →hover:bg-surface-muted/60; expanded →bg-surface-muted - (ครบ 12: app.vue, AlertBox, AuthGate, index, callback, templates index/new/edit/documents, documents create/detail)
ห้ามแตะ
- useTheme.ts + FOUC script — byte-identical (STORAGE_KEY
'theme-preference'sync) - logic ทั้งหมด (useAuth/useApi, page scripts, window.confirm, navigation),
test/setup.ts, test 3 ไฟล์ (verified: ไม่มี class assertion นอกจากclassList.contains('dark'), vitest ไม่ import tailwind.css) devServer.port: 5173,runtimeConfig,/addin,/backend- ไม่ต้องแก้ spec/CLAUDE.md — re-skin + layout ล้วน, requirement ไม่เปลี่ยน
Verification
- Baseline
npm testในweb/ก่อนเริ่ม (3 files เขียว) - หลัง token + config + fonts:
npm run dev→ http://localhost:5173 — ยืนยัน Sarabun โหลด, ไม่มี unstyled flash (แก้ tailwind.config.ts แล้วต้อง restart dev server) - เดินทุก route ทั้ง 2 โหมด (toggle + hard reload ยืนยัน FOUC):
/,/templates,/templates/new,/templates/{id}/edit,/templates/{id}/documents,/documents/create/{id}(รวม?from=+ กรอกผิดดู invalid border),/documents/{id}(expand version),/auth/callback(error path) - Responsive check: DevTools device mode ที่ 375px และ 768px — header wrap ถูก, ตารางกลายเป็น card ต่ำกว่า md, ฟอร์ม/ปุ่ม stack ถูก, ไม่มี horizontal scroll ของหน้า (ยกเว้นใน table-card บน desktop)
npm testอีกครั้ง — เขียวnpm run build— fail ทันทีถ้า@applyclass ที่ไม่มี- Audit grep (expect 0):
grep -rnE '(slate|sky|emerald|red|amber)-[0-9]' web/appและgrep -rn 'dark:' web/app - เช็คตา: contrast ปุ่ม primary / text บน tint / dark mode ของ on-primary, sticky header ทับเนื้อหาถูกต้อง (z-20, anchor ไม่โดนบัง —
scroll-marginให้ section ถ้าจำเป็น)
Risks / gotchas
- RGB triplet เท่านั้น ใน vars — hex break
/opacityเงียบ (footgun อันดับ 1) - ห้าม
@applycustom class ซ้อน — compose ใน markup - Dual-render ตาราง: markup ซ้ำสองชุดต่อตาราง — ต้อง sync v-for/v-model/state ให้ตรง (Vue รองรับ binding ซ้ำ, แต่ review ตอนแก้ต้องระวังลืมฝั่งใดฝั่งหนึ่ง)
file:pseudo-button บน WebKit มี quirk — ตรวจตอน dev- Dark ครีม→เทาอุ่น: saturation เกิน ~15% จะเลี่ยน — ค่าที่เลือก desaturate แล้ว; shadow มองไม่เห็นบนพื้นดำ (border token เป็นตัวแบ่ง elevation ใน dark)
- ตารางครีมที่ text-sm อาจแน่น — fallback บรรทัดเดียว:
table-card→bg-surface - ลำดับสำคัญ: tokens/config → component classes → .vue edits