เพิ่มกลุ่ม API docx template สำหรับ wordaddin (WIP): - POST /api/template/extract ดึงค่า Content Control ทั้งหมดจาก .docx - POST /api/template/render แทนค่า JSON ลง template คืน .docx ใหม่ - GET /api/template/test ตรวจว่า service มีชีวิต logic แชร์ที่ app/api/template/lib-tmpl.ts (pizzip+xpath) ไม่แตะ DB เพิ่มหน้า Swagger UI ที่ GET /api/swagger — spec inline จาก lib/openapi.ts ครอบ API ทุกกลุ่ม (template + N10), asset self-host ที่ public/note/swagger-ui/ จาก postinstall scripts/copy-swagger-ui.mjs (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 เหมือน patch-pdf-worker, Docker build รัน script นี้ด้วยผ่าน web.Dockerfile) middleware เปิดกว้างให้ api/template, api/swagger, note/swagger-ui จาก code review: จำกัดไฟล์แนบ 100MB ทั้ง extract/render ก่อนอ่าน เข้าหน่วยความจำกัน DoS, คืน 400 แทน 500 เมื่อ JSON ผิด format, sync คำอธิบาย BadRequest ใน openapi spec ให้ตรง behavior ใหม่ Co-Authored-By: Claude Code <noreply@anthropic.com>
53 KiB
CLAUDE.md
ภาพรวมโปรเจกต์
Judge's Note — ระบบ PDF Annotation สำหรับ e-Court (โปรเจกต์ Next.js แยกต่างหาก ไม่เกี่ยวกับ dms-main ที่เป็น Nuxt)
- ฟีเจอร์: highlight ข้อความ, วาดเส้น freehand, sticky note comment, bookmark
- อ่านแผนฉบับเต็มก่อนเริ่มงานใด ๆ:
docs/PLAN.md(สถาปัตยกรรม, schema, พฤติกรรม feature, ลำดับ implement, edge cases) — ฟีเจอร์ต่อยอด (F1–F19 + N1–N7) อ่านเพิ่ม:docs/PLAN-FEAT.md
Tech Stack
- Next.js 14 (App Router) + TypeScript strict — serve ใต้ basePath
/docedit+ route ทั้งหมดของแอปใต้app/note/**(URL ปลายทางยังเป็น/docedit/note/…เหมือนเดิม — basePath/doceditเป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin,next.config.mjs) - pdfjs-dist v4 (
^4.10.38) — built-in TextLayer + worker จากpublic/note/pdf.worker.min.mjs(postinstallscripts/patch-pdf-worker.mjsคัดลอกจาก node_modules + patch fake-space ไทย — รายละเอียด invariant #6) - Drizzle ORM + PostgreSQL (Docker container เดียว:
judes-note-db, imagepostgres:17-alpine, พอร์ต 54322) — ไม่ใช้ Supabase + realtime sync ด้วย SSE (Server-Sent Events) ผ่านapp/api/note/annotations/stream+ pub/sub in-process (lib/annotate-events.ts) - Tailwind CSS + design system Genesis (
docs/DESIGN.md, 2026-09-23) — palette tokens ในtailwind.config.ts(primaryเดิม +primary-hover/canvas/line/ink/ink-soft/ink-faint/success/warning/error+ ระดับข้อความsuccess-ink/warning-ink/error-ink≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (slate-*/amber-*/emerald-*/red-*ฯลฯ) อีก; ฟอนต์ display = General Sans (self-host จาก Fontshare ผ่านnext/font/local—app/fonts/, variable weight 200–700, ใช้ intranet ได้), body = DM Sans (next/font/google), ไทย fallback Sarabun ตามลำดับใน stack (Latin ก่อนไทย —fontFamily/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (shadow-card-hover/shadow-btn-glow— static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popovershadow-lgและ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class.focus-ring/.genesis-inputใน globals.css (+ สำเนาFOCUS_RING_CLSใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) - mock login e-Court — หน้า
/note/login(dropdownMOCK_USERSuser1–user5 + รหัสผ่านกลางDemo1234) ตั้ง cookieoacCaseตาม format จริงของระบบศาล → identity (userId =u_userid, userName =u_userlogin— ต่างกันต่อคน เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่านlib/session.ts+ บังคับทุกหน้าด้วยmiddleware.ts(auth จริงเทียบระบบ e-Court + roles ยัง out of scope)
Invariants ที่ต้องรักษา (ห้ามละเมิด)
- Non-Destructive: ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน
public/อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์ใหม่ที่public/note/uploads/เท่านั้น ห้ามแตะไฟล์เดิม) - พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า —
rects,paths,position,strokeWidth(% ของ width),yOffset(% ของ height) — แปลง px⇄% ผ่านutils/coordinates.tsเท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วยmergeRectsByLineเหลือ 1 rect ต่อบรรทัด - ลำดับ layer ตาม PLAN.md §1: canvas (
pointer-events: noneเสมอ) → highlight-layer (noneเสมอ,mix-blend-mode: multiply) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน - เขียนผ่าน Server Actions เท่านั้น (
app/actions/annotations.ts) และหลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่านlib/annotate-events.ts+revalidatePathหน้า viewer และ summary ของ fileId นั้น (helperrevalidateFilePages— สั่ง browser ทิ้ง Router Cache ฝั่ง client,force-dynamicปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / อ่าน + sync ผ่าน SSE hook (hooks/useAnnotationRealtime.tsเปิดEventSourceไป/api/note/annotations/stream?fileId=) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ยกเว้นregisterExternalFile(N8) ที่เขียน rowfilesระหว่าง render ของหน้า/noteและ/note/annotate(entry point ทั้งสองของสัญญาลิงก์?fileUrl=) แล้วredirectออกทันที (หน้าforce-dynamic+ รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/revalidatePath) - Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ (DTO types) — ห้ามส่ง Date object ตรง ๆ
- pdfjs-dist ต้อง
dynamic import()ภายใน useEffect เท่านั้น — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้งGlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")(postinstallscripts/patch-pdf-worker.mjsคัดลอกไฟล์ไปpublic/note/แล้ว patch จุดเดียว: ถอดif (scaledDim)รอบtextChunk.prevTransform = getCurrentTextTransform()ให้อัปเดตทุก glyph — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จนaddFakeSpacesแทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ fail ดัง ๆ โดยตั้งใจ กัน patch หลุดเงียบ — test กัน regression:tests/scripts/patch-pdf-worker.test.ts+ fixturetests/fixtures/thai_letterspacing.pdf— Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดูdocker/web.Dockerfile) — ห้ามใช้new URL(..., import.meta.url) - InkLayer ใช้ SVG แบบไม่มี viewBox — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
- JSONB
dataเป็น strict discriminated union (AnnotationDataแยกตาม enumtype) — ห้าม loosen type เป็นany/generic object - Optimistic update ใช้
useOptimistic(React 18) ภายในstartTransition— React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ในhooks/useAnnotationRealtime.ts(duplicate ชั่วขณะกับ realtime event หายเอง) - Page Virtualization: render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน
- DB connection:
DATABASE_URLชี้ที่ plain PostgreSQL containerjudes-note-db(postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note) +prepare: falseใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) - URL ฝั่ง browser ที่สร้างเองต้องผ่าน
withBasePath()(utils/base-path.ts— เติม basePath/docedit+ app path/noteจาก envNEXT_PUBLIC_BASE_PATH/NEXT_PUBLIC_APP_PATHประกาศที่next.config.mjsที่เดียว) ครอบคลุม:fetch()/EventSource/plain<a href>/workerSrc/img.src/file.pdfUrlตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน<Link>/next/navigation(router.push/replace/redirect) Next เติม basePath/doceditให้เองเท่านั้น path ที่เขียนถึง route จึงต้องมี/noteนำหน้าเสมอ (เช่นhref="/note/login",redirect("/note/…"),revalidatePath("/note/…")) ยกเว้น API — route API อยู่ใต้app/api/note/**(URL/docedit/api/note/…): path ที่ขึ้นต้น/api/เขียนครบเป็น/api/note/…แล้วwithBasePathเติมแค่ basePath ไม่มี/noteซ้อน;middleware.tsจับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น/note/…ส่วน API ขึ้นต้น/api/note/…— รายการยกเว้นใน matcher: ของแอปนำหน้าnote/เช่นnote/uploads, API เป็นapi/note, ส่วนfonts/faviconอยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง);pdf_urlใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่านwithBasePath/ ฝั่ง disk ผ่านresolvePublicPdfPath(lib/server-pdf.tsroot ที่public/note/— statics ของแอปอยู่ใต้public/note/**ทั้งหมดยกเว้นฟอนต์ export)
โครงสร้างไฟล์ (ตามแผน)
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon)
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId — เรียกระหว่าง render หน้า /note และ /note/annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน)
lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม)
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok)
utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route)
utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้)
utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure)
utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12)
utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed)
utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05)
utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3)
utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route)
hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id
hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2)
app/actions/annotations.ts ← Server Actions CRUD + validation + publish event
app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie)
app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/note/uploads/ + ช่องกลุ่ม typeId ทางเลือก normalize เหมือน N8 (N4)
app/api/note/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login)
app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…)
app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component)
app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line)
app/note/login/page.tsx ← หน้า login (มี session แล้ว redirect /note)
app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน)
components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)
components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + redaction
components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18)
components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป)
components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4)
components/EcourtLinkForm.tsx ← การ์ดจำลองลิงก์ e-Court บนหน้ารายการ (N8 — กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" แล้ว router.push ไป /note?fileUrl=&fileId=&typeId= ให้หน้า /note ลงทะเบียน+redirect เอง — ใช้ทดสอบก่อนมีลิงก์จริงจาก e-Court)
components/groups/GroupExplorer.tsx ← N9 client root ของหน้า /groups/[typeId] (redesign 2026-09-24) — state ตัวกรองก้อนเดียว `FacetFilterState` (first=fileId/`kinds: Set<"bm"|"ann">` ชนิดรายการ + `types: Set<AnnotationType>` ชนิดของ Annotation — **ทั้งคู่ Set ว่าง = แสดงทั้งหมด (default), ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** คำขอผู้ใช้ 2026-09-24/ผู้บันทึก) + กรองใน memory ด้วย useMemo + facet count/static users derive เอง + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `<details>` แบบหน้าสรุป, desktop aside sticky 15rem scroll ภายใน) — render `FacetFilterPanel` สองที่แชร์ state เดียว (`firstGroup` = ไฟล์) + re-export `GroupListItem` = `ExplorerItem`
components/explorer/FacetFilterPanel.tsx ← แผงตัวกรอง facet **กลางใช้ร่วม 2 หน้า** — ย้ายมาจาก FilterControls ของหน้ากลุ่ม 2026-09-24 ให้หน้าสรุปใช้ร่วม (มิติแรกส่งเข้าทาง prop `firstGroup`: groups = "ไฟล์", สรุป = "หน้า" พร้อม `optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ; อีก 3 กลุ่ม ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก เหมือนกันทุกหน้า) — **tree menu 2 ระดับสไตล์ Quasar docs** (q-tree--dense no-connectors — design รอบสอง 2026-09-24 ตามคำขอผู้ใช้, ธีม Genesis พื้นสว่าง) — **ชนิดรายการ (Bookmark/Annotation) เป็น checkbox คู่ที่ไม่เลือก = แสดงทั้งหมด (default) ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** (direct selection ตามคำขอผู้ใช้ 2026-09-24 รอบสอง — แบบเดิมเปิดครบทั้งคู่ต้องปิดเอง = สับสน; เคยย้ายไป segmented บนหัว content แล้วย้ายกลับตามคำขอผู้ใช้) = parent `<details open>` หุบ/กางอิสระ + chevron หมุน 90°, leaf = `<button>` dense ไอคอน+ชื่อ+badge pill — **เลือก button แทน label+input เพราะโดนกฎ coarse 44px ของ `main.listing-page`/`main.summary-page` พอดี** (desktop dense ~28px / จอสัมผัส 44px อัตโนมัติ ไม่ต้องแต้ globals.css), active ผ่าน variant `aria-pressed:` (แถบซ้าย `border-l-primary` + `bg-primary/[0.08]` + ตัวน้ำเงิน; border โปร่งคงไว้ทุกแถวกัน layout shift), multi-select (ชนิดรายการ/ชนิดของ Annotation) มี**กล่อง checkbox visual 14px หน้าไอคอน** (ปกติกรอบเทาโปร่งใน, เลือก = `border-primary bg-primary` + ✓ ขาว; **ติ๊กชนิดของ Annotation แล้วเลือก Annotation ในชนิดรายการให้อัตโนมัติ ส่วน**เลิกเลือก Annotation แล้วล้างชนิดที่ติ๊กค้างไว้** เริ่มใหม่ตาม default ตอนกลับมาติ๊ก — คำขอผู้ใช้ 2026-09-24** — เป็น visual ของ button เดิมไม่ใช่ native input, กฎ coarse 44px ยังครบ; design review 2026-09-24 ตาม convention ของ faceted filter ต่างจาก nav highlight ของ Quasar), ชนิด annotation reuse `AnnotationTypeIcon` ผ่าน `TYPE_ICON_DATA` (data ตัวแทนค่าว่าง), แสดงเฉพาะชนิดที่มี ≥1 รายการ + **ไม่ได้ติ๊ก Annotation (ชนิดรายการ) = กลุ่มชนิด disabled + หุบ หรี่ทั้งกลุ่ม** (`<details open>` ผูก prop เฉพาะ transition — การหุบ/กางด้วยมือไม่ถูกเขียนทับ; คำขอผู้ใช้ 2026-09-24 ติ๊ก Annotation แล้วกลุ่มกลับมาใช้ได้+กางอัตโนมัติ), ผู้บันทึกใช้ spacer คง indentation, count เป็น static facet ไม่หดตามตัวกรองอื่น — export `ExplorerItem` (type รายการรวม bm/ann — `GroupListItem` ของ groups re-export จากที่นี่) + `FacetFilterState`/`createDefaultFacets()` (ห้ามแชร์ object default เดียว — Set ถูก mutate ข้ามหน้าได้)
components/groups/GroupList.tsx ← คอลัมน์ content — list เดียวรวมทั้งกลุ่ม (ไฟล์ที่มาต้น meta line truncate เฉพาะ span ของตัวเองกันชื่อยาวกินบรรทัด, badge "จัดการแล้ว"/โน้ต/"ใน: parent" ตามหน้าสรุป; **แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม `<Link>` ข้อความ "View"** ท้ายแถว `h-8 px-3 rounded-md` ขอบ line hover เป็น primary (คำขอผู้ใช้ 2026-09-24: ไม่กดทั้งแถว — ทำให้พ้นข้อจำกัด interactive ซ้อน `<a>` และกฎ coarse ที่ display:inline-flex ชนะ utility flex ก็ไม่กระทบแถว) + `title`/`aria-label` "View — ไปที่หน้า N ใน …"/"ดู…ที่หน้า N ใน …" บอกปลายทางรายแถว) + header "พบ N รายการ" + ปุ่ม "เปิดเอกสารนี้" (เฉพาะกรองไฟล์เดียว — ทางเข้าไฟล์ที่ไม่มีรายการ) + "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state 3 แบบ (กลุ่มว่าง/กรองจนไม่เหลือ/ไฟล์เดียวไม่มีรายการ)
components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/"))
components/AppNav.tsx ← nav sticky 56px แบบ Genesis (backdrop-blur + border-b) ใช้ที่หน้า /note — โลโก้ซ้าย + ชื่อผู้ใช้/LogoutButton ขวา (แทน SessionBar เดิม 2026-09-23; หน้า /note/groups/[typeId] เลิกใช้ตาม redesign 2026-09-24 — ไม่มี navbar เหลือ back link)
components/DotGrid.tsx ← กริดจุดอันตรกิริยาตาม pointer พื้นหลังหน้า login — decorative ชิ้นเดียวที่ Genesis อนุญาต (อัปเดตตำแหน่งผ่าน CSS variable บน style ไม่ setState)
ขอบเขต — ห้ามทำเกิน (Out of Scope)
ขอบเขตด้านล่างเป็นของเฟสแรก — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาตเฉพาะตาม task F1–F19 ใน
docs/PLAN-FEAT.mdและทำผ่าน/implement-featเท่านั้น ห้ามทำเกินสเปกของแผนนั้น — ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON), F16 (export CSV) และ N7 (ส่งออก Bookmark JSON) ที่ถูกถอดออกจากโค้ดแล้ว — F16/N7 ถอด 2026-09-25 ตามคำขอผู้ใช้ แทนด้วย N10 API (ระบบภายนอกดึงข้อมูลผ่าน/api/note/external/documents): แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15),export CSV (F16)(ถอดแล้ว — แทนด้วย N10), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของdocs/PLAN-FEAT.md): โน้ตคำอธิบายnote?ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับparent_id+ โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผนdocs/PLAN-IMAGE.md), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4 — เพิ่มช่องกลุ่ม 2026-09-23 ตามคำขอผู้ใช้), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ +bookmarks.user_name(N6 — แผนdocs/PLAN-BOOKMARK.md),รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน(ถอดออกจากโค้ดแล้ว 2026-09-25 ตามคำขอผู้ใช้ — แทนด้วย N10 API: route + ปุ่มหน้าสรุป + ปุ่มใน sidebar ถอดครบ), เปิดเอกสารจากลิงก์ e-Courtdocs/PLAN-BOOKMARK-PDF.md— 2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON แทนรายงาน PDF ตามคำขอผู้ใช้: flat arrayBookmarkDTOเรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม)?fileUrl=&fileId=&typeId=— ลงทะเบียน metadata-only (dedupeexternal_id, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (/samples/…) เพื่อทดสอบ grouping ใน dev (N8 — แผนdocs/PLAN-ECOURT.md, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตามtype_idจากลิงก์ e-Court — หน้ารายการ/จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า/groups/[typeId]แสดงเอกสารที่รู้จักของกลุ่ม (หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"ถอดออก 2026-09-24 ตามคำขอผู้ใช้) +การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์(2026-09-23 ตามคำขอผู้ใช้ — ปิด gap 5.3.10 ครึ่งหลัง bookmark รวมทั้งกลุ่ม; redesign 2026-09-24 ตามคำขอผู้ใช้: ถอด navbar + ตัวกรองฝั่งซ้าย (ไฟล์/ชนิดรายการ/ชนิด annotation/ผู้บันทึก) + list เดียวรวมทั้งกลุ่มไม่แยกไฟล์ ผ่านcomponents/groups/); อ่านtype_idอย่างเดียว — เขียนสองที่: N8registerExternalFile+ ช่องกลุ่มทางเลือกของฟอร์มอัปโหลด N4uploadFile(2026-09-23 ตามคำขอผู้ใช้ — insert ตอนสร้าง row ใหม่เท่านั้น normalize เหมือน N8: trim → ว่าง = NULL, cap 200) ไม่มี action/SSE ใหม่) (N9 — แผนdocs/PLAN-DOCSET.md), API ดึงข้อมูลให้ระบบภายนอกด้วย API Key เดียวใน envEXTERNAL_API_KEY— endpoint เดียวPOST /api/note/external/documents(URL เต็ม/docedit/api/note/external/documents) (JSON body{fileId และ/หรือ typeId, filter:{type: bookmarks|annotations, userId, annotationTypes}}— เดิม 2 GET query-param รวมเป็นเส้นเดียวตามคำขอผู้ใช้ 2026-09-24 + รับ fileId/typeId พร้อมกันเป็น AND เหลือเอกสารเดียวตามคำขอผู้ใช้ 2026-09-25) ให้ backend e-Court เรียก server-to-server ผ่าน headerx-api-keyคืน{documents:[{field เอกสาร + annotations(พร้อม url deep link กลับหน้า /annotate/[id] + replies) + bookmarks(พร้อม url) ระดับเดียวกัน}]}รูปเดียวเสมอ — ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath, ไม่มี CORS, REDACTION รวมในผลลัพธ์ (key เชื่อถือระดับ backend e-Court เท่านั้น) (N10 — แผนdocs/PLAN-API.md)
- ไม่มี auth จริง/roles (login เป็น mock หน้า
/note/loginตั้ง cookieoacCase— ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตารางannotation_versions - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ ของตัวเอง ในหน้าเดียวกัน — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6)
- ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (
userIdตรงกัน) เท่านั้น - หน้า
/noteอ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่public/note/uploads/เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วยnpm run seed-samples) - ลายเซ็นจากรูป/upload ยังเป็น out of scope (D7 — stamp เลือก preset หรือพิมพ์คำเอง ได้แล้ว 2026-09-28: แถว "กำหนดเอง…" ใน dropdown ของ tool เปิดหน้า popup แก้ข้อความ/สี/ขนาด พร้อม preview ตรายางตามสัดส่วนหน้าจริง + แก้คำตรายางที่วางแล้วผ่านปุ่ม "แก้ไขข้อความ" ในแผง selection — StampEditor WYSIWYG (textarea) · จัดบรรทัดแบบถอยหลัง (
stampLayout/wrapStampLabel/stampBoxHeightที่utils/geometry.ts— คำสั้น 1 บรรทัดฟอนต์เต็ม 0.4w เหมือนเดิมเป๊ะ, คำยาวพอดี ๆ ย่อฟอนต์ให้อยู่บรรทัดเดียวก่อนลงได้จนถึง floor 10% ของความกว้างกรอบ ("ตรวจสอบแล้ว" ย่อ ~25% พอ = บรรทัดเดียว — คำขอผู้ใช้ 2026-09-28 รอบสอง), ยาวเกิน floor ค่อย wrap ≤3 บรรทัด กรอบสูงตามจำนวนบรรทัด; ทุกมิติเป็นสัดส่วนของความกว้างกรอบ จำนวนบรรทัดจึงไม่ขึ้นกับ scale ขยาย width ไม่ช่วยเรื่องบรรทัดตก; ใช้ร่วม render/preview/bbox/export ผ่าน measure ของแต่ละฝั่ง); · มุมเอียงStampData.rotation?(องศา −180..180 หมุนรอบกลางกรอบ, 2026-09-28) — ช่อง "มุมเอียง" ในแผง selection (patchstampRotation, 0° = ถอด field) + จุดจับหมุนวงกลมเหนือขอบบนกรอบ ลากวนรอบกลางกรอบได้ (mode "rotate" — atan2 delta + snap ทวีคูณ 15°), render/กรอบ selection+handles หมุนด้วย CSS transform origin กลางกรอบ (origin เป็นพิกัดใน element ผ่านrotOn()ไม่ใช่พิกัด page),annotationBBoxคืน AABB ของกรอบหมุน (stampRect/rotatedRectAABB/rotatePointที่ utils/geometry.ts — anchor ของ handle scale เป็นมุมกรอบหลังหมุนบนจอ และ k คำนวณใน frame ของกรอบ), StampEditor แสดงตรง (พิมพ์ง่าย), export ใช้ pdf-librotate: degrees(−θ)+ anchor มุมล่างซ้ายหลังหมุน (pdf-lib หมุนรอบ anchor ไม่ใช่ center); เกณฑ์ "ไม่ติดลบ" ของ STAMP ทั้ง clamp client และ validator ใช้ AABB ตัวเดียวกัน (position = มุมก่อนหมุน ติดลบได้เมื่อกรอบจริงยังในหน้า —vPointFinite) และตอนตั้งมุมใหม่ต้องclampStampIntoPageเลื่อนกลับเข้าหน้าทันที (code review 2026-09-28); ลายเซ็นใช้ tool DRAW วาด)
Claude Tooling (ใน .claude/)
agents/plan-reviewer— ตรวจโค้ดกับ invariants ด้านบน + verify criteria ของ task (report-only ไม่แก้โค้ด) — เรียกหลัง implement แต่ละ taskskills/implement-task—/implement-task <N>implement task N จาก PLAN.md §7 พร้อม verify ตามเกณฑ์skills/implement-feat—/implement-feat <F-number หรือ feature id>implement task F1–F19 จาก PLAN-FEAT.md พร้อม verify + sync-db + sync invariant/rules ตามตาราง §6 ของแผนนั้นskills/sync-db—/sync-dbdrizzle push + seed ไฟล์ตัวอย่างrules/*.md— กติกาเชิงปฏิบัติแยกตามพื้นที่ (pdf-viewer,coordinates,data-layer,realtime) โหลดอัตโนมัติเมื่อแตะไฟล์ที่ match — แก้ invariant ต้องแก้ที่ CLAUDE.md/PLAN.md ก่อน แล้วค่อย sync มา rules