@tailwind base; @tailwind components; @tailwind utilities; /* ─── Genesis utilities (docs/DESIGN.md 2026-09-23) ──────────────────────── focus indicator = outline น้ำเงินทึบ 2px + offset 2px ของ design system — แยกเป็น class ร่วมเพื่อใช้ซ้ำทุกปุ่ม/ลิงก์; input ใช้ .genesis-input (ขอบน้ำเงิน + outline ตอน :focus ตาม spec Inputs) — ครอบด้วย @layer components ให้ Tailwind utilities ทับได้ (UX review M3 2026-09-23: halo alpha 0.12 ของ spec เดิม blend บนขาวแล้ว ~1.2:1 ต่ำกว่าเกณฑ์ 3:1 ของ WCAG 1.4.11 — alpha 0.25–0.35 ก็ยังไม่ผ่าน จึงเปลี่ยนเป็น outline ทึบเป็นตัวชี้วัด + offset คั่นไม่ให้จมกับพื้นปุ่ม primary) */ @layer components { .focus-ring { @apply focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary; } .genesis-input { @apply rounded-md border border-line bg-white px-3.5 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary; } } /* ─── TextLayer ของ pdfjs v4 ───────────────────────────────────────────────── คัดมาจาก node_modules/pdfjs-dist/web/pdf_viewer.css (ส่วนที่จำเป็น) ใช้กับ pdfjs.TextLayer ที่ render ลง div.textLayer ใน PDFViewer */ .textLayer { position: absolute; text-align: initial; inset: 0; overflow: clip; opacity: 1; line-height: 1; text-size-adjust: none; forced-color-adjust: none; transform-origin: 0 0; caret-color: CanvasText; /* ห้ามใส่ z-index ที่นี่ — บล็อกนี้อยู่หลัง @tailwind utilities ใน cascade จะ override z-[2] ของ Tailwind จน textLayer ไปอยู่ใต้ canvas (invariant #3) */ } .textLayer :is(span, br) { color: transparent; position: absolute; white-space: pre; cursor: text; transform-origin: 0% 0%; } .textLayer > :not(.markedContent), .textLayer .markedContent span:not(.markedContent) { z-index: 1; } .textLayer span.markedContent { top: 0; height: 0; } .textLayer span[role="img"] { user-select: none; cursor: default; } .textLayer ::selection { background: rgba(0 0 255 / 0.25); background: color-mix(in srgb, AccentColor, transparent 75%); } .textLayer br::selection { background: transparent; } .textLayer .endOfContent { display: block; position: absolute; inset: 100% 0 0; z-index: 0; cursor: default; user-select: none; } .textLayer.selecting .endOfContent { top: 0; } /* canvas วัด font metrics ที่ pdfjs.TextLayer สร้างแล้ว append ท้าย document.body — คัดมาจาก pdf_viewer.css ของ pdfjs-dist เช่นกัน (ตอนแรกคัดมาแค่ส่วน .textLayer กฎนี้จึงตกหล่น ทำให้ canvas ว่าง alpha:false แสดงเป็นก้อนดำ 300×150 ที่ล่างซ้าย ของ document และเพิ่ม scrollHeight เกินจริง 150px) */ .hiddenCanvasElement { position: absolute; top: 0; left: 0; width: 0; height: 0; background-size: 100% 100%; background-color: transparent; } /* ─── UX review 2026-09-09 ข้อ 4: ปุ่ม header เล็กกว่ามาตรฐาน touch 44×44 ───── ขยายเฉพาะอุปกรณ์จอสัมผัส — ครอบปุ่ม toggle/page nav/zoom/tool group (รวมปุ่ม dropdown ของ tool)/undo-redo/bookmark/export + ปุ่ม summary (Link) */ @media (pointer: coarse) { header .touch-44 > button, header .touch-44 > div > button, header .touch-44 > div > div > button, header .touch-44 > div > a { min-height: 2.75rem; min-width: 2.75rem; /* min 44px ทำให้ปุ่ม icon เดี่ยวกว้างเกิน content — ต้อง center icon ไม่งั้นชิดซ้าย+ลอยตาม baseline (ปุ่ม dropdown มี flex อยู่แล้ว ไม่กระทบ) */ display: inline-flex; align-items: center; justify-content: center; } /* UX review 2026-09-14 L2: ปุ่มใน dropdown panel (สี/ความหนา/preset) — swatch 16px เล็กเกินนิ้ว แต่ 44px ทำ panel บวม จึงใช้ 36px / C1: เมนู dropdown + popup bookmark ย้ายไป createPortal ที่ document.body แล้ว (ไม่มี .z-50 เหลือใน header) จึง target ผ่าน [data-tool-menu] ตรง ๆ / ไม่ใส่ display/justify — แถวเมนูเป็น text-left อยู่แล้ว */ [data-tool-menu] button:not(.combo-step), [data-tool-menu] label { min-height: 2.25rem; min-width: 2.25rem; } /* NumberCombo (ความหนา/ความทึบ/ขนาด): ▲▼ ซ้อนแนวตั้ง 2 ปุ่ม — ถ้าโดน min 36px คอลัมน์จะสูง 72px และ input (items-stretch) ถูกดึงบวมตามทั้งแถว — ปุ่ม ▲▼ จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถว input เองที่ 36px แทน (:first-child — เว้น dropdown ของ preset ที่เป็นลูก div ตัวที่สอง ไม่ให้ถูก บังคับความสูงจนตัดรายการ): แตะ input = เปิด preset dropdown (เปิดขึ้นบน — L2: แถวท้ายเมนูเปิดลงจะล้นเขต scroll ของเมนู) คือ touch path หลัก ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */ [data-tool-menu] .number-combo > div:first-child { height: 2.25rem; } [data-tool-menu] .combo-step { min-width: 1.25rem; } /* M3: ตัวควบคุมบนหน้ากระดาษเล็กกว่ามาตรฐาน touch มาก (ไม่ได้อยู่ใน header จึงหลุด จาก rule ด้านบน) — ของที่มองเห็นคงขนาดเดิม ขยายพื้นที่รับ touch แทน: handle/strip ของกรอบ selection เป็น div ขนาดจาก inline style (10px/8px) แก้ width/height ตรง ๆ ไม่ได้ จึงใช้ ::after แผ่รับ pointer รอบ ๆ (handle 10→hit 28px, strip 8→hit 16px — มุม render ทีหลังใน DOM จึงชนะบริเวณซ้อนกับ strip = ต้องการ) */ .sel-handle::after { content: ""; position: absolute; inset: -9px; } .sel-strip::after { content: ""; position: absolute; inset: -4px; } /* ปุ่ม bookmark/ลบในแผง selection (20×20) + swatch สีในแผง (16×16) — sel-panel-btn ใส่ centering แบบ .coarse-36 กันผู้ใช้ class ไม่ได้เอา flex มาเอง (ไอคอนลอยชิดบนในกล่อง 36px); ปุ่มที่ใช้อยู่มี flex แล้ว ผลลัพธ์ไม่เปลี่ยน */ .sel-panel-btn { min-height: 2.25rem; min-width: 2.25rem; display: inline-flex; align-items: center; justify-content: center; } .sel-panel .color-swatches button, .sel-panel .color-swatches label { min-height: 2rem; min-width: 2rem; } /* M6: ปุ่ม icon ใน sidebar (16/14px ติดกัน — จิ้มพลาดไปปุ่มข้าง ๆ ง่าย โดยเฉพาะ "ลบ" ชิด "ประวัติ") → อย่างน้อย 32px แบบไม่ต้องขยายตัว icon ที่มองเห็น */ /* UX audit 2026-09-16: ขยายเป็นปุ่มทุกตัวใน sidebar — รวมปุ่มข้อความเล็ก (ยกเลิก/บันทึก/ล้างทั้งหมด/ViewSwitch/แท็บ) ที่เดิมหลุดจาก rule; แยกเป็น 2 rule เพราะ specificity ของ selector นี้ชนะ Tailwind class เดี่ยว — ใส่ display/ align-items ใน rule กว้างจะขโมย items-start ของปุ่มแถวรายการ (ไอคอนลอยกลาง ข้อความ 2 บรรทัด) ตัวจัด center จึงคงไว้เฉพาะ button.shrink-0 (ไอคอนเดี่ยว) ส่วนแถวรายการเป็นปุ่มเต็มความกว้างสูงพออยู่แล้ว ไม่กระทบ */ aside[data-sidebar] button { min-height: 2rem; } aside[data-sidebar] button.shrink-0 { min-height: 2rem; min-width: 2rem; display: inline-flex; align-items: center; justify-content: center; } /* ─── ยกเว้น chevron หุบ/กางของต้นไม้ bookmark (bug report 2026-09-22) ────── คอลัมน์นี้เป็น "ตาราง" ของต้นไม้ ต้องกว้างเท่าไอคอน 14px เท่ากับ spacer (w-3.5) ของแถวลูกเสมอ — ถ้าปล่อย min-width 32px ดันไอคอน bookmark ของ หัวข้อแม่ไปชนแนวพอดีกับของหัวข้อลูก (indent 16px/ระดับโดนกิน) ลำดับ ชั้นมองไม่เห็น และ root มีลูก (chevron) กับไม่มีลูก (spacer) ก็เพี้ยน ไม่ตรงแนวกันเอง — จึงคงขนาดจริง 14px แล้วขยาย hit area ด้วย ::after แบบเดียวกับ sel-handle (จิ้มสบาย 32px เท่าเดิม โดยไม่จอง layout) */ aside[data-sidebar] button.bm-toggle { min-height: 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; position: relative; } aside[data-sidebar] button.bm-toggle::after { content: ""; position: absolute; /* ขยาย hit รอบไอคอน 14px เป็น 32px (±9px) โดยไม่กว้างขึ้นจริง */ inset: -0.5625rem; } /* M6: ตัด double-tap zoom ของ browser เมื่อแตะปุ่มรัว ๆ (ยัง pinch ได้ตามปกติ — handle ของกรอบ selection เป็น div + touch-none อยู่แล้ว ไม่โดน rule นี้) */ button, label { touch-action: manipulation; } /* M6: กัน long-press บน iOS เปิด magnifier/callout ขอคัดลอก — scope เฉพาะ layer ของ TEXTBOX/STAMP เท่านั้น (textLayer ต้อง select/copy ได้ตามปกติ) */ .no-callout, .no-callout * { -webkit-touch-callout: none; } /* M6: ปุ่มเล็กใน comment composer/popover/pin (~24px) → 36px ผ่าน class แทนการ แก้ขนาดจริง (desktop คงเดิม) */ .coarse-36 { min-height: 2.25rem; min-width: 2.25rem; display: inline-flex; align-items: center; justify-content: center; } /* M6: ปุ่มหน้าสรุป (30px) → 44px ตามมาตรฐาน touch */ main.summary-page a { min-height: 2.75rem; min-width: 2.75rem; display: inline-flex; align-items: center; } /* ปุ่มที่ไม่ใช่ (แถวตัวกรองในแผง explorer ของหน้าสรุป, ปุ่มล้างตัวกรอง) หลุดจากกฎข้างบน — ใส่แค่ min-height (ปุ่มเป็น flex w-full text-left อยู่แล้ว ไม่ต้อง display/justify แบบกฎ ; เดิมเป็นปุ่มหุบ/กางของ CollapsibleBlock design review 2026-09-17 H1 ก่อนหน้าถูกแทนด้วยตัวกรอง explorer 2026-09-24) */ main.summary-page button { min-height: 2.75rem; } /* UX review M5 2026-09-23: ช่องกรอกทุกชนิด ≥16px บนจอสัมผัส — iOS Safari ซูมทั้ง หน้าทุกครั้งที่แตะโฟกัสช่องที่ตัวอักษรเล็กกว่า 16px (ทีมตั้งกติกาเดียวกันไว้ หน้า login แล้ว — ขยายครบทุกช่องของแอปเฉพาะอุปกรณ์จอสัมผัส; !important จำเป็น เพราะช่องส่วนใหญ่กำหนดขนาดด้วย text-xs/text-[11px] ที่ specificity สูงกว่า) responsive audit 2026-09-24: ครอบ contentEditable ของ TEXTBOX editor ด้วย — fontSize default 2.5% ≈ 15px ทำ Safari ซูม viewport ตอนแตะเข้าโหมดแก้ไข (ตัวอักษรขยายเป็น 16px เฉพาะช่วงพิมพ์ ต่างจากผลลัพธ์ ~7% — trade-off มาตรฐาน ของ editor บน iOS เพราะ maximum-scale กัน focus-zoom ไม่ได้) */ input, textarea, select, [contenteditable]:not([contenteditable="false"]) { font-size: 1rem !important; } } /* UX review M7 2026-09-23: ปุ่มหลักของหน้ารายการ/กลุ่ม (Annotate h-8, ลิงก์กลุ่ม, ออกจากระบบ) สูง 32px ต่ำกว่ามาตรฐาน touch 44px ที่ viewer/หน้าสรุปได้แล้ว — หน้าที่ผู้ใช้ iPad แตะบ่อยที่สุดก่อนเปิดเอกสารต้องได้เป้าเท่ากัน (ครอบ a/button ของ
+ header ของ AppNav; select/summary เป็น native control/มี flex อยู่แล้ว ใส่แค่ min-height) ครอบด้วย @layer components ให้ Tailwind utilities (flex/items-*) ทับได้ — กฎ unlayered ชนะทุก layer จนบังคับ align-items:center ทับ items-start ของ แถวลิงก์ในการ์ดกลุ่ม กลายเป็นไอคอนลอยกลางแถว 2–3 บรรทัด (code review 2026-09-23) + justify-content:center คืนค่า default ของปุ่ม (text-align:center ของ UA หมดผลเมื่อเป็น flex) ไม่งั้นปุ่ม w-full เช่น "เปิดเอกสาร"/อัปโหลด ข้อความชิดซ้าย (คำขอผู้ใช้ 2026-09-23) responsive audit 2026-09-24: @layer ต้องครอบ @media จากด้านนอกเท่านั้น — ซ้อนกลับกัน (@media { @layer {…} }) แล้ว Tailwind v3 ยกเนื้อหา @layer ออกไปวางที่ components section โดยทิ้ง media wrapper กฎจึงหลุดไป apply บน desktop ด้วย (ยืนยันด้วยการ build จริง: ปุ่มหน้ารายการได้ min-height 44px + flex บนจอ desktop) */ @layer components { @media (pointer: coarse) { main.listing-page a, main.listing-page button { min-height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; } main.listing-page select, main.listing-page summary { min-height: 2.75rem; } header.appnav a, header.appnav button { min-height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; } } } /* ─── Print (หน้าสรุป) — design review 2026-09-17 L4 ─────────────────────── หน้าสรุปมีโอกาสถูกสั่งพิมพ์แนบสำนวนสูง — browser ตัด background ตอนพิมพ์ โดย default ทำให้แถบหัวน้ำเงินเหลือ text ขาวบนกระดาษขาว อ่านไม่ออกทั้งแถบ: แปลงเป็นกรอบเส้นน้ำเงิน + ตัวอักษรน้ำเงินแทน (อ่านได้บนเครื่องพิมพ์ขาวดำ ประหยัดหมึก) และไม่ผ่าหน้ากระดาษกลาง section ของหน้าเอกสาร (หน้าที่ยาวเกิน กระดาษเดียว browser เลื่อนทั้งก้อนขึ้นหน้าถัดไปเอง — break-inside เป็นคำ แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับแผงตัวกรอง (ทั้ง
มือถือและ aside desktop) ซ่อนด้วย print:hidden ใน JSX — รายการบนกระดาษเป็นฉบับเต็ม เสมอไม่สนตัวกรองที่ตั้งค้างไว้ (redesign explorer 2026-09-24: SummaryList render ชุดเต็มแยกไว้เป็น hidden print:block) */ @media print { main.summary-page header { background: #fff; border: 1.5pt solid #0033a1; } main.summary-page header h1, main.summary-page header p { color: #0033a1; } main.summary-page section { break-inside: avoid-page; } /* แถวรายการของหน้าเดียวกันไม่ถูกผ่ากลางระหว่างหน้ากระดาษ */ main.summary-page section > ul > li { break-inside: avoid-page; } /* หัวการ์ดรายหน้าเป็น sticky บนจอ — กลับเป็นตำแหน่งปกติบนกระดาษ */ main.summary-page .page-head { position: static; } } /* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B) → Genesis 2026-09-23: DM Sans (next/font ตัวแปร Latin) นำหน้า stack ให้ตัว Latin/ตัวเลขได้บุคลิก body ของ design system ก่อน จากนั้น webfont Sarabun (next/font — มี adjusted fallback ของ next/font ในตัว) รับ glyph ไทยโดยเฉพาะ แสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback ตอนฟอนต์ยังไม่ถึง (display:swap)/โหลดไม่ได้ — ไทยได้ Leelawadee UI (Windows) / Thonburi (iPad) / Noto Sans Thai (Android/Linux) / heading ใช้คลาส font-display ที่นำ General Sans มาก่อน Sarabun ด้วยหลักการเดียวกัน */ body { font-family: var(--font-dm-sans, "DM Sans"), var(--font-sarabun, "Sarabun"), -apple-system, "Segoe UI", "Helvetica Neue", Arial, "Leelawadee UI", "Thonburi", "Noto Sans Thai", sans-serif; } /* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน pull-to-refresh ของ Android ตอนอยู่ ๆ สุดขอบบน (reload = ล้าง undo history ฝั่ง client ที่ไม่เก็บใน DB) */ html { -webkit-tap-highlight-color: transparent; overscroll-behavior-y: contain; } /* 2026-09-22:
ของ viewer เป็น scroll container เดียวของหน้า (แนวตั้ง + แนวนอน — ซูมจนหน้ากว้างเกินจอแล้วปัดเลื่อนดูส่วนที่เกินได้) — จำกัด gesture สัมผัสให้เป็นการแพนของกรอบนี้เท่านั้น ไม่ปล่อยให้ browser pinch-zoom/double-tap zoom ทั้งหน้าเว็บ (อาการซูมแล้ว header/sidebar โต้ตามไปด้วย); ปุ่มซูม ± เป็น ทางเลือก single-pointer ตาม WCAG 2.5.1 — ต้องอยู่ก่อน .pdf-custom-select เพื่อให้โหมดมาร์กข้อความ (touch-action:none) ยังชนะตอนเปิดใช้ */ .pdf-scroll-main { touch-action: pan-x pan-y; } /* custom selection ของ tool highlight บนจอสัมผัส (hooks/useCustomSelection) — ปิด native selection/callout ของ OS และยึด gesture ลากเป็นของเราทั้งหมด (touch-action:none กัน browser ยึดไปเป็น scroll → pointercancel) hook ใส่/ถอด class นี้บน
ตาม enabled — ปิดแล้ว native selection กลับมาทันที (tool อื่น long-press copy ของ OS ใช้ได้ตามปกติ) */ .pdf-custom-select { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; } /* ช่องกรอกข้อมูลในหน้า (reply ของ comment pin, textbox) ที่อยู่ใต้
ตอน tool highlight — ต้องเลือก/คัดลอกข้อความในช่องและเลื่อนข้อความภายในช่อง (reply ยาว) ได้ตามปกติ ไม่โดน touch-action:none ของ class ด้านบนลากไปด้วย (การ scroll ในช่องจบที่ตัวช่องเองซึ่งเป็น scroll container — pointerdown ของ custom selection ก็เริ่มบน .textLayer เท่านั้น ไม่ชนกัน) */ .pdf-custom-select input, .pdf-custom-select textarea, .pdf-custom-select [contenteditable="true"] { user-select: text; -webkit-user-select: text; -webkit-touch-callout: default; touch-action: auto; } /* เส้นคั่นบางระหว่างแถวตั้งค่า (PanelRow) ที่ต่อกันในเมนู tool/แผง selection — ระยะ/เส้นเท่ากับแทรก
ไว้ระหว่างแถว ทุกจุด (แถวแรกของกลุ่มไม่มีเส้น — ไม่ซ้ำกับ divider ที่เมนูใส่ไว้เองแล้ว) (#E8E8EC = token line — เดิม #f1f5f9 เป็น slate-100 หลงเหลือก่อน Genesis) */ .panel-row + .panel-row { margin-top: 0.375rem; padding-top: 0.625rem; border-top: 1px solid #e8e8ec; } /* M6: safe-area — defensive (ค่าเป็น 0 จนกว่าจะใส่ viewport-fit=cover ใน meta ของ layout — ยังไม่ใส่ตามขอบเขตงานนี้ ใส่ไว้ก่อนเพื่อไม่ต้องแก้ CSS ซ้ำ) */ header { padding-top: env(safe-area-inset-top); } .comment-composer { padding-bottom: env(safe-area-inset-bottom); } /* UX audit 2026-09-16 L7: เคารพ prefers-reduced-motion — ตัด transition ของ sidebar, animation กระพริบของปุ่ม export และ smooth scroll ทุกจุด (scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior) UX review m7 2026-09-23: เพิ่ม blanket rule ครอบ transition/animation ที่เหลือ (hover-lift ของการ์ด, scale ของสวอตช์สี ฯลฯ) — ยก motion ทั้งหน้าให้จบทันที */ @media (prefers-reduced-motion: reduce) { aside[data-sidebar] { transition: none; } .animate-pulse { animation: none; } *, ::before, ::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } }