eCourt-Doc-Edit/docs/plans/web-portal-redesign.md
Suphonchai Phoonsawat 7b20f12ede Phase 9: version message (FR-13)
- ใส่คำอธิบายแบบ commit message ได้ทุก flow ที่สร้าง version (Save & Version / Start Versioning / generate จากเว็บ) + แสดงใน version history ทุกหน้า
- backend: DocumentVersion.Message (varchar 2048) + migration AddVersionMessage + BadRequest ตรวจความยาวก่อนแตะ MinIO (กัน 500 + orphan object)
- add-in: ConfirmVersionDialog เพิ่ม textarea (Start Versioning ใช้ dialog ด้วย) + แสดง message ใน VersionHistoryPanel
- web: ฟอร์มสร้างเอกสาร + หน้า detail แสดง message (desktop truncate + tooltip, mobile เต็มบรรทัด)
- sync เอกสารทั้งหมด: spec v5 (FR-13/AC-16) + plan Phase 9 + acceptance-review 1–16 + README + CLAUDE.md + web/README
- tests: backend 52 / addin 50 / web 36 — เขียวทั้งชุด

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 22:05:49 +07:00

20 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 อย่างพร้อมกัน:

  1. Re-skin ตาม scheme ของ admincourt.go.th: primary #0000EE, accent gold #8F7226, surface ขาว, elevated ครีม #FBF7EC, radius 8px, ปุ่ม pill, shadow นุ่ม, ฟอนต์ sukhumvit — ผ่านการสร้าง token layer ที่เหมาะสม (ไม่ใช่ find-replace สี)
  2. Layout modernization + responsive: sticky header, รูปแบบ page header มาตรฐาน, ตารางเปลี่ยนเป็น card list บนจอเล็ก (responsive table pattern สมัยใหม่), ฟอร์มจัด width และ action row ตามแนวปฏิบัติปัจจุบัน

ข้อตกลงกับผู้ใช้ (fixed):

  1. เก็บ dark mode ต่อ — derive dark palette จาก scheme (ครีม → warm near-black, #0000EE → indigo สว่าง) ใช้ CSS variables + .dark ต่อจาก useTheme/FOUC guard เดิม, useTheme.spec.ts ต้องเขียว
  2. ฟอนต์ผ่าน Google Fonts CDN — Sarabun (ใกล้ Sukhumvit ที่สุด) น้ำหนัก 400/500/600/700 + stack 'Sukhumvit Set', 'Sarabun', ...
  3. Scope = /web เท่านั้น ห้ามแตะ /addin, /backend
  4. (เพิ่มจาก 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 แบบเงียบ
  • @layer components + @apply สำหรับ pattern ที่ repeat — ไม่สร้าง Vue component (ปุ่มเป็น mix ของ <button>/<NuxtLink>/ternary class, tests เป็น pure logic)
    • ⚠️ Tailwind v3 ห้าม @apply custom class ในอีก custom class — compose ใน markup (class="btn btn-primary")
  • ห้าม 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-xs define หลัง .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, brand min-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 + intro text-body-lg text-muted + accent bar h-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)

  1. 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
  2. AlertBox.vue — computed คืน 'alert-error'|'alert-success'|'alert-info', template class="alert" + :class; props/emoji/role คงเดิม
  3. AuthGate.vue — spinner; login card → card mx-auto max-w-md p-8 text-center + text-section-title + prose text-body-lg text-muted; ปุ่ม w-full sm:w-auto
  4. index.vue — หัวหน้า + grid + card ตามส่วนที่ 2; link ในการ์ด → link
  5. auth/callback.vue — spinner; retry → btn btn-primary, back → btn btn-secondary (จัด flex flex-col-reverse gap-2 sm:flex-row)
  6. 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
  7. 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
  8. 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 form max-w-xl
  9. templates/[id]/documents.vue — page header + ตาราง dual-render ตาม pattern
  10. documents/create/[templateId].vue — ฟอร์ม max-w-2xl + action row; result card → card p-6 + download btn btn-primary; panel "ขั้นตอนต่อไป" → panel-info; invalid → :class="issues[f.key] ? 'border-error' : ''"; validation msg → text-xs text-error-strong
  11. 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. (ครบ 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

  1. Baseline npm test ใน web/ ก่อนเริ่ม (3 files เขียว)
  2. หลัง token + config + fonts: npm run dev → http://localhost:5173 — ยืนยัน Sarabun โหลด, ไม่มี unstyled flash (แก้ tailwind.config.ts แล้วต้อง restart dev server)
  3. เดินทุก 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)
  4. Responsive check: DevTools device mode ที่ 375px และ 768px — header wrap ถูก, ตารางกลายเป็น card ต่ำกว่า md, ฟอร์ม/ปุ่ม stack ถูก, ไม่มี horizontal scroll ของหน้า (ยกเว้นใน table-card บน desktop)
  5. npm test อีกครั้ง — เขียว
  6. npm run build — fail ทันทีถ้า @apply class ที่ไม่มี
  7. Audit grep (expect 0): grep -rnE '(slate|sky|emerald|red|amber)-[0-9]' web/app และ grep -rn 'dark:' web/app
  8. เช็คตา: 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)
  • ห้าม @apply custom 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

หมายเหตุจากการทำจริง (2026-08-20)

  • ✅ implement ครบทั้ง 12 ไฟล์ + verification ผ่านทั้งหมด — web npm test 36/36 เขียวทั้งก่อน/หลัง · npm run build ผ่าน · smoke test production server ยืนยัน fonts link, lang="th", sticky header + token classes, FOUC script ครบ · audit grep palette เดิม (slate/sky/red/emerald/amber-*) = 0 เหลือ · dark: variants (เดิม 209 จุด) = 0 เหลือ
  • 🐛 บั๊กแฝงที่เจอระหว่างทำ (สำคัญ): @nuxtjs/tailwindcss resolve default cssPath แบบ relative ต่อ rootDir → บน Nuxt 4 (srcDir = app/) หา app/assets/css/tailwind.css ไม่เจอและ fallback ไปไฟล์ default ของ tailwindcss package เงียบๆ (build ผ่าน แต่ token/component classes หายทั้งหมด) — ก่อนหน้านี้ไม่มีใครเจอเพราะไฟล์เดิมมีเนื้อหา 3 directives เหมือน fallback เป๊ะ · แก้ด้วย tailwindcss: { cssPath: '~/assets/css/tailwind.css' } ใน nuxt.config.ts (บันทึกเป็นกติกา #14 ใน CLAUDE.md แล้ว)
  • smoke test เครื่องมือ: PORT=5174 node .output/server/index.mjs + curl ตรวจ HTML — ใช้ซ้ำได้เมื่อแก้ shell/global