@suphark/ui 0.11.2 → 0.13.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.13.0] — 2026-09-25
6
+
7
+ ### Added
8
+
9
+ - **สเกลตัวอักษรตามบทบาท + ตอบสนองจอ** (ขอจาก Pi Done — บนมือถืออ่านเล็กเกินไป): คลาสใหม่ `text-title-lg` `text-title` `text-subtitle` `text-body` `text-caption` `text-label` ค่าเป็น **mobile-first แล้วแน่นขึ้นบนจอ ≥ 48rem** (body 16→15px, caption 14→13px, subtitle 17→16px) — เหตุผลเดียวกับที่ iOS ใช้ body 17px ส่วน macOS ใช้ 13px: มือถือถือห่างและใช้นิ้ว เดสก์ท็อปอ่านใกล้และชี้ด้วยเมาส์
10
+ - ทำได้โดย `@theme` ชี้ `--text-*` ไปที่ `--fs-*` แล้วเปลี่ยน `--fs-*` ใน media query — utility ยังเป็นคลาสเดียวและยังใช้ variant ได้ (`md:text-body`); **ไม่แตะ root font-size** ซึ่งจะทับค่าที่ผู้ใช้ตั้งไว้ในเบราว์เซอร์
11
+ - `text-xs … text-2xl` ยังใช้ได้เหมือนเดิม ไม่ใช่ breaking — แต่ `text-xs` (12px) ไม่ควรเป็นขนาดเนื้อหาอีกต่อไป
12
+ - `TYPOGRAPHY_ROLES` export จาก `@suphark/ui` เป็นแหล่งความจริงเดียวของตาราง (showcase `/design-system#tokens-typography` render จากค่านี้ ไม่เขียนซ้ำ)
13
+ - **ความสูงขั้นต่ำของรายการเมนูบนอุปกรณ์สัมผัส**: `@media (pointer: coarse)` ตั้ง `min-height: 44px` ให้ `dropdown-menu-item` / `checkbox-item` / `radio-item` / `sub-trigger` / `select-item` / `command-item` — เดิมไม่มีความสูงเลย (`py-1` ≈ 26px) ต่ำกว่าเกณฑ์ Apple 44pt / Material 48dp; ใช้ `pointer: coarse` ไม่ใช่ความกว้างจอ เพราะหน้าต่างแคบบนเดสก์ท็อปยังใช้เมาส์
14
+ - audit: กฎ `raw-text-scale` (warning, opt-in ด้วย `--semantic-text`) จับ `text-xs/text-sm/…` ที่ยังไม่ย้ายไป role class — ให้แอปทยอยย้ายทีละส่วนแล้วเปิดกฎกันถอยหลัง
15
+
16
+ ### Fixed
17
+
18
+ - **`arbitrary-text` ของ audit ไม่เคยจับ `text-[0.8rem]`**: regex เดิม `\d+` ตามด้วยหน่วยทันที จึงข้ามค่าทศนิยมทั้งหมด (ทั้งใน `audit.mjs` และ `audit-tailwind-typography.mjs`) — แก้เป็น `\d*\.?\d+` แล้วพบของค้าง 15 จุดที่ผ่าน CI เขียวมาตลอด
19
+ - แทนขนาด arbitrary 12 จุดด้วย role class: `text-[0.7rem]`/`text-[0.625rem]`/`text-[0.6rem]` → `text-label` (HeaderCrumb badge, SubNav, TopTabs, IconPicker, Initials, Stepper, SecretReveal, PrimarySecondaryText, showcase site-header) และข้อความ error ของ `FormFieldInput` → `text-caption` (บนมือถือขึ้นเป็น 14px อ่านออกและพ้นเกณฑ์ iOS zoom)
20
+ - ที่ยังเหลือคือใน `components/ui/**` (`button.tsx` `toggle.tsx` `calendar.tsx` = `text-[0.8rem]`) ซึ่งเป็นไฟล์ที่ shadcn generate — ห้ามแก้มือ รอรอบ regenerate
21
+
22
+ ## [0.12.0] — 2026-09-23
23
+
24
+ ### Added
25
+
26
+ - **`OrganizationSwitcher` prop `itemAction(org)`** (ขอจาก Pi Auth) — ปุ่มท้ายแถวของแต่ละองค์กรในเมนู เช่น ⚙ "ตั้งค่าองค์กร" แบบ Vercel/Clerk: แอปส่ง element ที่กดได้มาเอง (พร้อม aria-label/title) component ห่อให้และ **หยุด click/keydown ไม่ให้สลับองค์กร**; คืน `null` = แถวนั้นไม่มีปุ่ม; มี `itemAction` แล้วองค์กรเดียวก็ยังมีเมนู (เดิมยุบเป็นป้ายเฉย ๆ เมื่อไม่มี `footer`) — เดิมแอปต้องวาง "ตั้งค่าองค์กร" เป็นรายการแยกใน `footer`
27
+ - **`OrganizationSwitcher` prop `selectableActive`** (ขอจาก Pi Auth) — แถวที่ active กดได้ด้วย เรียก `onSwitch` เหมือนแถวอื่น (ค่าเริ่มต้น false = คงพฤติกรรมเดิม กดแล้วไม่ทำอะไร); สำหรับเมนูที่ "เลือก" = ไปหน้าของสิ่งนั้นไม่ใช่แค่สลับ เช่น crumb บัญชีของ Pi Auth ที่กดชื่อบัญชีแล้วเข้าคอนโซลบัญชีนั้น — ตัดรายการ "คอนโซลบัญชี" ท้ายเมนูที่ซ้ำช่องทางออกได้
28
+ - showcase: หน้า `organization-switcher` ตัวแรกใส่ ⚙ ท้ายแถว; tests 2 กรณี (รวม 130)
29
+
5
30
  ## [0.11.2] — 2026-09-22
6
31
 
7
32
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.11.2",
3
+ "version": "0.13.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 เอง",
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
 
4
4
  /** โฟลเดอร์ที่จะตรวจ: argument แรก (ค่าเริ่มต้น `src`) — แอปปลายทางรันจาก node_modules ได้ */
5
5
  const SRC_DIR = path.resolve(process.cwd(), process.argv[2] ?? "src");
6
- const ARBITRARY_TEXT_REGEX = /text-\[\d+(?:px|rem|em)\]/g;
6
+ const ARBITRARY_TEXT_REGEX = /text-\[\d*\.?\d+(?:px|rem|em)\]/g;
7
7
 
8
8
  /**
9
9
  * Recursively find all .ts and .tsx files in directory
package/scripts/audit.mjs CHANGED
@@ -33,6 +33,8 @@ const STRICT = flags.has("--strict");
33
33
  const PALETTE = !flags.has("--no-palette");
34
34
  const SKIP_BASE_UI = flags.has("--no-base-ui");
35
35
  const IMPORTANT = flags.has("--important");
36
+ // opt-in ระหว่างทยอยย้ายไป role class (text-body/text-caption/…) ทีละส่วนของแอป
37
+ const SEMANTIC_TEXT = flags.has("--semantic-text");
36
38
 
37
39
  const SKIP_DIRS = new Set([
38
40
  "node_modules",
@@ -67,8 +69,9 @@ const RULES = [
67
69
  {
68
70
  id: "arbitrary-text",
69
71
  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
+ regex: /\btext-\[\d*\.?\d+(?:px|rem|em)\]/g,
73
+ message:
74
+ "ห้าม text-[Npx] — ใช้ role class (text-body, text-caption, text-label …) ตาม docs/design-system.md",
72
75
  },
73
76
  {
74
77
  id: "important",
@@ -77,6 +80,13 @@ const RULES = [
77
80
  message:
78
81
  "utility ที่ลงท้าย ! — แอปที่ scan แพ็กเกจผ่าน @source อาจไม่ generate class นี้ ปรับ specificity แทน",
79
82
  },
83
+ {
84
+ id: "raw-text-scale",
85
+ level: "warning",
86
+ regex: /(?<![\w-])(?:[a-z-]+:)*text-(?:xs|sm|base|lg|xl|[2-9]xl)(?![\w-])/g,
87
+ message:
88
+ "t-shirt size ไม่บอกบทบาทและตอบสนองจอไม่ได้ — ใช้ text-body / text-caption / text-label / text-subtitle / text-title",
89
+ },
80
90
  {
81
91
  id: "raw-palette",
82
92
  level: "warning",
@@ -134,6 +144,7 @@ for (const file of files) {
134
144
  for (const rule of RULES) {
135
145
  if (rule.id === "raw-palette" && !PALETTE) continue;
136
146
  if (rule.id === "important" && !IMPORTANT) continue;
147
+ if (rule.id === "raw-text-scale" && !SEMANTIC_TEXT) continue;
137
148
  for (let i = 0; i < masked.length; i++) {
138
149
  const line = masked[i] ?? "";
139
150
  rule.regex.lastIndex = 0;
@@ -202,6 +213,7 @@ for (const rule of RULES) {
202
213
  const n = byRule.get(rule.id) ?? 0;
203
214
  if (rule.id === "raw-palette" && !PALETTE) continue;
204
215
  if (rule.id === "important" && !IMPORTANT) continue;
216
+ if (rule.id === "raw-text-scale" && !SEMANTIC_TEXT) continue;
205
217
  console.log(
206
218
  ` ${n === 0 ? "✓" : rule.level === "error" || STRICT ? "✗" : "△"} ${rule.id.padEnd(15)} ${n}${n ? ` — ${rule.message}` : ""}`,
207
219
  );
@@ -48,7 +48,7 @@ export function HeaderCrumb({
48
48
  {hint && <span className="hidden truncate text-muted-foreground md:inline">{hint}</span>}
49
49
  {/* badge ซ่อนบนจอแคบ (ที่เหลือให้ชื่อ) — เห็นได้ในเมนู/popover */}
50
50
  {badge && (
51
- <span className="hidden shrink-0 rounded-md border bg-muted px-1.5 py-px font-medium text-[0.7rem] text-muted-foreground leading-4 sm:inline">
51
+ <span className="hidden shrink-0 rounded-md border bg-muted px-1.5 py-px font-medium text-label text-muted-foreground leading-4 sm:inline">
52
52
  {badge}
53
53
  </span>
54
54
  )}
@@ -40,6 +40,16 @@ export interface OrganizationSwitcherProps {
40
40
  hint?: (org: OrganizationOption) => ReactNode;
41
41
  /** slot ท้ายเมนู เช่น "สร้างองค์กร…", "จัดการองค์กร" */
42
42
  footer?: ReactNode;
43
+ /**
44
+ * ปุ่มท้ายแถวของแต่ละองค์กร เช่น ⚙ "ตั้งค่าองค์กร" (แบบ Vercel/Clerk) — คืน `null` เพื่อไม่แสดงในแถวนั้น
45
+ * คลิกแล้ว **ไม่สลับองค์กร** (component หยุด event ให้); ส่ง element ที่กดได้เองและใส่ aria-label/title ด้วย
46
+ */
47
+ itemAction?: (org: OrganizationOption) => ReactNode;
48
+ /**
49
+ * แถวที่ active กดได้ด้วย (เรียก `onSwitch` เหมือนแถวอื่น) — ค่าเริ่มต้น false = กดแล้วไม่ทำอะไร
50
+ * ใช้เมื่อ "เลือก" = ไปหน้าของสิ่งนั้น ไม่ใช่แค่สลับ (เช่น crumb บัญชี: กดชื่อบัญชี = เข้าคอนโซลบัญชีนั้น)
51
+ */
52
+ selectableActive?: boolean;
43
53
  /** `header` = HeaderCrumb ใน header (layout top); `sidebar` = ปุ่มเต็มกว้างใน `sidebarHeader` ของ AppShell */
44
54
  placement?: "header" | "sidebar";
45
55
  className?: string;
@@ -64,6 +74,8 @@ export function OrganizationSwitcher({
64
74
  heading,
65
75
  hint = (o) => (o.slug ? `/${o.slug}` : undefined),
66
76
  footer,
77
+ itemAction,
78
+ selectableActive = false,
67
79
  placement = "header",
68
80
  className,
69
81
  }: OrganizationSwitcherProps) {
@@ -72,7 +84,7 @@ export function OrganizationSwitcher({
72
84
  const [pending, start] = useTransition();
73
85
  const active = organizations.find((o) => o.id === activeId);
74
86
  const label = active ? active.name : (fallbackLabel ?? text.switcher?.choose ?? "เลือกองค์กร");
75
- const single = organizations.length <= 1 && !footer;
87
+ const single = organizations.length <= 1 && !footer && !itemAction;
76
88
 
77
89
  const trigger =
78
90
  placement === "sidebar" ? (
@@ -102,7 +114,7 @@ export function OrganizationSwitcher({
102
114
  if (single) return trigger;
103
115
 
104
116
  function pick(id: string) {
105
- if (id === activeId) return;
117
+ if (id === activeId && !selectableActive) return;
106
118
  start(async () => {
107
119
  await onSwitch(id);
108
120
  if (refresh) router.refresh();
@@ -119,6 +131,7 @@ export function OrganizationSwitcher({
119
131
  </DropdownMenuLabel>
120
132
  {organizations.map((o) => {
121
133
  const isActive = o.id === activeId;
134
+ const action = itemAction?.(o);
122
135
  return (
123
136
  <DropdownMenuItem
124
137
  key={o.id}
@@ -134,6 +147,17 @@ export function OrganizationSwitcher({
134
147
  )}
135
148
  </span>
136
149
  {isActive && <Check className="size-4 shrink-0" aria-hidden="true" />}
150
+ {action && (
151
+ // ปุ่มท้ายแถว (⚙) — กดแล้วต้องไม่สลับองค์กร จึงหยุด event ไม่ให้ถึง onClick ของแถว
152
+ // biome-ignore lint/a11y/noStaticElementInteractions: ตัวห่อไม่ใช่ปุ่ม — แอปส่ง element ที่กดได้มาเอง
153
+ <span
154
+ className="-my-1 -mr-1 flex shrink-0 items-center"
155
+ onClick={(e) => e.stopPropagation()}
156
+ onKeyDown={(e) => e.stopPropagation()}
157
+ >
158
+ {action}
159
+ </span>
160
+ )}
137
161
  </DropdownMenuItem>
138
162
  );
139
163
  })}
@@ -111,7 +111,7 @@ export function SubNav({
111
111
  >
112
112
  {/* หัวกลุ่มแบบ Clerk/Linear — ตัวเล็ก uppercase จางกว่ารายการ ให้แยกจากรายการได้ชัด (ขอจาก Pi Auth) */}
113
113
  {s.title && (
114
- <p className="px-2 pt-1 pb-1 font-semibold text-[0.7rem] text-muted-foreground/70 uppercase tracking-wider">
114
+ <p className="px-2 pt-1 pb-1 font-semibold text-label text-muted-foreground/70 uppercase tracking-wider">
115
115
  {s.title}
116
116
  </p>
117
117
  )}
@@ -132,7 +132,7 @@ export function SubNav({
132
132
  {i.icon}
133
133
  <span className="truncate">{i.title}</span>
134
134
  {i.badge !== undefined && (
135
- <span className="ml-auto rounded-full bg-muted px-1.5 text-[0.7rem] text-muted-foreground">
135
+ <span className="ml-auto rounded-full bg-muted px-1.5 text-label text-muted-foreground">
136
136
  {i.badge}
137
137
  </span>
138
138
  )}
@@ -56,7 +56,7 @@ export function TopTabs({ nav, className }: { nav: AppNavGroup[]; className?: st
56
56
  {tab.icon}
57
57
  <span className="truncate">{tab.title}</span>
58
58
  {tab.badge !== undefined && (
59
- <span className="rounded-full bg-muted px-1.5 py-px font-medium text-[0.7rem] text-muted-foreground leading-4">
59
+ <span className="rounded-full bg-muted px-1.5 py-px font-medium text-label text-muted-foreground leading-4">
60
60
  {tab.badge}
61
61
  </span>
62
62
  )}
@@ -9,6 +9,7 @@ import {
9
9
  CardTitle,
10
10
  } from "@suphark/ui/components/ui/card";
11
11
  import { Kbd, KbdGroup } from "@suphark/ui/components/ui/kbd";
12
+ import { TYPOGRAPHY_ROLES } from "@suphark/ui/lib/typography";
12
13
  import { AlignLeft, Check, Copy, Info, Palette, Type } from "lucide-react";
13
14
  import * as React from "react";
14
15
 
@@ -111,44 +112,16 @@ export function ShowcaseFoundations() {
111
112
  { name: "Chart 5 (Red/Rose)", class: "bg-chart-5 text-white", token: "var(--chart-5)" },
112
113
  ];
113
114
 
114
- const typeScale = [
115
- {
116
- role: "Page Title",
117
- className: "text-2xl font-bold tracking-tight",
118
- sample: "Bidding Package Review & Award",
119
- usage: "ชื่อหน้าหลักของแต่ละ Module ใน PageShell",
120
- },
121
- {
122
- role: "Section Title",
123
- className: "text-lg font-semibold tracking-normal",
124
- sample: "Vendor Qualification & Price Breakdown",
125
- usage: "หัวข้อหลักของ Card, Dialog, Sheet, หรือ Tabs panel",
126
- },
127
- {
128
- role: "Subsection Title",
129
- className: "text-base font-semibold",
130
- sample: "Material Specification & BOQ Rates",
131
- usage: "หัวข้อย่อยใน Form sections หรือ Card sub-blocks",
132
- },
133
- {
134
- role: "Base Body",
135
- className: "text-sm leading-normal",
136
- sample: "All bids submitted before the deadline are subject to procurement committee review.",
137
- usage: "เนื้อหาหลัก, Table row data, Description, Input labels",
138
- },
139
- {
140
- role: "Caption / Metadata",
141
- className: "text-xs text-muted-foreground",
142
- sample: "Last modified by procurement.manager@company.com • 15 mins ago",
143
- usage: "Helper text, timestamp, audit metadata, table header captions",
144
- },
145
- {
146
- role: "Monospace / Numbers",
147
- className: "font-mono text-xs text-foreground",
148
- sample: "PO-2026-08-9941 / THB 14,850,200.00 / SHA-256",
149
- usage: "Code identifiers, Document numbers, Financial decimals, Hashes",
150
- },
151
- ];
115
+ // ตัวอย่างข้อความของแต่ละบทบาท — ขนาด/ชื่อบทบาทมาจาก TYPOGRAPHY_ROLES (แหล่งเดียวกับ styles.css)
116
+ const samples: Record<string, string> = {
117
+ "text-title-lg": "Bidding Package Review & Award",
118
+ "text-title": "Vendor Qualification & Price Breakdown",
119
+ "text-subtitle": "Material Specification & BOQ Rates",
120
+ "text-body":
121
+ "All bids submitted before the deadline are subject to procurement committee review.",
122
+ "text-caption": "Last modified by procurement.manager@company.com • 15 mins ago",
123
+ "text-label": "PROCUREMENT STATUS",
124
+ };
152
125
 
153
126
  const spatialRules = [
154
127
  {
@@ -290,24 +263,28 @@ export function ShowcaseFoundations() {
290
263
 
291
264
  <Card>
292
265
  <CardContent className="p-0 divide-y divide-border">
293
- {typeScale.map((item) => (
266
+ {TYPOGRAPHY_ROLES.map((item) => (
294
267
  <div
295
- key={item.role}
268
+ key={item.className}
296
269
  className="flex flex-col gap-2 p-4 transition-colors hover:bg-muted/30 lg:flex-row lg:items-center lg:justify-between"
297
270
  >
298
271
  <div className="flex flex-col gap-1 w-full lg:w-1/3">
299
272
  <div className="flex items-center gap-2">
300
- <span className="font-semibold text-xs text-foreground">{item.role}</span>
273
+ <span className="font-semibold text-xs text-foreground">{item.name}</span>
301
274
  <Badge variant="outline" className="text-xs font-mono py-0">
302
- {item.className.split(" ")[0]}
275
+ {item.className}
303
276
  </Badge>
304
277
  </div>
305
- <code className="text-xs text-muted-foreground font-mono">{item.className}</code>
278
+ <code className="text-xs text-muted-foreground font-mono">
279
+ {item.mobile === item.desktop
280
+ ? item.mobile
281
+ : `${item.mobile} → ${item.desktop} (≥ 48rem)`}
282
+ </code>
306
283
  <p className="text-xs text-muted-foreground">{item.usage}</p>
307
284
  </div>
308
285
  <div className="w-full lg:w-2/3 pt-1 lg:pt-0">
309
286
  <div className={`rounded-md bg-muted/40 p-3 border ${item.className}`}>
310
- {item.sample}
287
+ {samples[item.className]}
311
288
  </div>
312
289
  </div>
313
290
  </div>
@@ -205,7 +205,7 @@ function UniqueCheckInput<T extends FieldValues>({
205
205
  )}
206
206
  </InputGroup>
207
207
  {isDuplicate && (
208
- <p className="mt-1 font-medium text-[0.8rem] text-destructive">{uniqueErrorMessage}</p>
208
+ <p className="mt-1 font-medium text-caption text-destructive">{uniqueErrorMessage}</p>
209
209
  )}
210
210
  </div>
211
211
  );
@@ -368,7 +368,7 @@ export function FormFieldInput<T extends FieldValues>({
368
368
  })()}
369
369
  </FormControl>
370
370
  {props.description && (
371
- <p className="text-[0.8rem] text-muted-foreground">{props.description}</p>
371
+ <p className="text-caption text-muted-foreground">{props.description}</p>
372
372
  )}
373
373
  <FormMessage />
374
374
  </FormItem>
@@ -116,7 +116,7 @@ export function IconPicker<T extends string = string>({
116
116
  <selected.icon className="size-5" aria-hidden="true" />
117
117
  </span>
118
118
  <div className="min-w-0">
119
- <p className="font-semibold text-[0.625rem] text-muted-foreground/70 uppercase tracking-wider">
119
+ <p className="font-semibold text-label text-muted-foreground/70 uppercase tracking-wider">
120
120
  {text.preview}
121
121
  </p>
122
122
  <p className="truncate font-bold text-foreground text-xs">{labelOf(selected)}</p>
@@ -23,7 +23,7 @@ export function Initials({
23
23
  <Avatar className={cn("shrink-0", className)} {...props}>
24
24
  {src && <AvatarImage src={src} alt="" />}
25
25
  <AvatarFallback
26
- className="text-[0.7rem] font-medium"
26
+ className="text-label font-medium"
27
27
  style={{ backgroundColor: `oklch(0.93 0.04 ${hue})`, color: `oklch(0.4 0.08 ${hue})` }}
28
28
  >
29
29
  {initialsOf(name)}
@@ -58,7 +58,7 @@ export function Stepper({
58
58
  {step.statusLabel && (
59
59
  <StatusBadge
60
60
  tone={TONE[step.status]}
61
- className="w-fit px-1.5 py-0 font-normal text-[0.7rem] leading-4"
61
+ className="w-fit px-1.5 py-0 font-normal text-label leading-4"
62
62
  >
63
63
  {step.statusLabel}
64
64
  </StatusBadge>
@@ -28,7 +28,7 @@ export function SecretReveal({
28
28
  <dl className="flex flex-col gap-2">
29
29
  {Object.entries(values).map(([k, v]) => (
30
30
  <div key={k} className="flex flex-col gap-1">
31
- <dt className="font-mono text-[0.7rem] text-muted-foreground">{k}</dt>
31
+ <dt className="font-mono text-label text-muted-foreground">{k}</dt>
32
32
  <dd className="flex items-center gap-2">
33
33
  <code className="min-w-0 flex-1 break-all rounded-md bg-background/70 px-2 py-1 font-mono text-xs">
34
34
  {v}
@@ -157,7 +157,7 @@ export function PrimarySecondaryText({
157
157
  if (listStyle === "bullet") {
158
158
  return (
159
159
  <div key={idx} className="flex items-start gap-2">
160
- <span className="mt-1.5 text-[0.6rem] text-muted-foreground leading-none">•</span>
160
+ <span className="mt-1.5 text-label text-muted-foreground leading-none">•</span>
161
161
  {content}
162
162
  </div>
163
163
  );
package/src/index.ts CHANGED
@@ -240,4 +240,8 @@ export {
240
240
  statusColorThemes,
241
241
  TAILWIND_COLORS,
242
242
  } from "./lib/status-colors";
243
+ export {
244
+ TYPOGRAPHY_ROLES,
245
+ type TypographyRole,
246
+ } from "./lib/typography";
243
247
  export { cn } from "./lib/utils";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * สเกลตัวอักษร "ตามบทบาท" — แหล่งความจริงเดียวของ type scale
3
+ *
4
+ * ค่าจริงอยู่ใน `styles.css` (@theme → `--text-*` ชี้ไปที่ `--fs-*` เพื่อให้เปลี่ยนตาม
5
+ * breakpoint ได้) ไฟล์นี้คือรายการบทบาทสำหรับเอกสาร/showcase จะได้ไม่เขียนสเกลซ้ำสองที่
6
+ * แล้วคลาดกันภายหลัง — เพิ่มบทบาทใหม่ต้องแก้ทั้งสองไฟล์คู่กันเสมอ
7
+ */
8
+ export type TypographyRole = {
9
+ /** ชื่อคลาสที่ใช้จริง เช่น `text-body` */
10
+ className: string;
11
+ /** ชื่อบทบาทสำหรับเอกสาร */
12
+ name: string;
13
+ /** ขนาดบนมือถือ (< 48rem) */
14
+ mobile: string;
15
+ /** ขนาดบนจอกว้าง (≥ 48rem) — ว่าง = เท่ากับมือถือ */
16
+ desktop: string;
17
+ /** ใช้กับอะไร */
18
+ usage: string;
19
+ };
20
+
21
+ export const TYPOGRAPHY_ROLES: readonly TypographyRole[] = [
22
+ {
23
+ className: "text-title-lg",
24
+ name: "Page Title",
25
+ mobile: "24px",
26
+ desktop: "24px",
27
+ usage: "ชื่อหน้าหลักของแต่ละ Module ใน PageShell",
28
+ },
29
+ {
30
+ className: "text-title",
31
+ name: "Section Title",
32
+ mobile: "18px",
33
+ desktop: "18px",
34
+ usage: "หัวข้อหลักของ Card, Dialog, Sheet, Tabs panel",
35
+ },
36
+ {
37
+ className: "text-subtitle",
38
+ name: "Subsection Title",
39
+ mobile: "17px",
40
+ desktop: "16px",
41
+ usage: "หัวข้อย่อยใน Form section หรือ Card sub-block",
42
+ },
43
+ {
44
+ className: "text-body",
45
+ name: "Body",
46
+ mobile: "16px",
47
+ desktop: "15px",
48
+ usage: "เนื้อหาหลัก, ชื่อรายการ, Table row, ข้อความใน Dialog",
49
+ },
50
+ {
51
+ className: "text-caption",
52
+ name: "Caption / Metadata",
53
+ mobile: "14px",
54
+ desktop: "13px",
55
+ usage: "คำอธิบายรอง, timestamp, metadata chip, ตัวนับ",
56
+ },
57
+ {
58
+ className: "text-label",
59
+ name: "Field Label",
60
+ mobile: "12px",
61
+ desktop: "12px",
62
+ usage: "ป้ายชื่อฟิลด์ (มักคู่กับ uppercase/font-bold), overline",
63
+ },
64
+ ] as const;
package/src/styles.css CHANGED
@@ -68,6 +68,58 @@
68
68
  --radius-xl: calc(var(--radius) + 4px);
69
69
  }
70
70
 
71
+ /*
72
+ * Typography by role, not by t-shirt size.
73
+ *
74
+ * Each token points at a `--fs-*` variable rather than a literal, so the value
75
+ * can change per breakpoint while the generated utility stays one class
76
+ * (`text-body`, and its variants such as `md:text-body`, keep working).
77
+ * Values below are mobile-first and get *denser* on wide screens: a phone is
78
+ * held further away and tapped, a desktop is read up close with a mouse — the
79
+ * same reason iOS uses a 17px body where macOS uses 13px.
80
+ *
81
+ * Apps should reach for these roles instead of `text-xs`/`text-sm`, which
82
+ * carry no meaning and cannot respond to the viewport.
83
+ */
84
+ @theme {
85
+ --text-title-lg: var(--fs-title-lg);
86
+ --text-title-lg--line-height: var(--lh-title-lg);
87
+ --text-title: var(--fs-title);
88
+ --text-title--line-height: var(--lh-title);
89
+ --text-subtitle: var(--fs-subtitle);
90
+ --text-subtitle--line-height: var(--lh-subtitle);
91
+ --text-body: var(--fs-body);
92
+ --text-body--line-height: var(--lh-body);
93
+ --text-caption: var(--fs-caption);
94
+ --text-caption--line-height: var(--lh-caption);
95
+ --text-label: var(--fs-label);
96
+ --text-label--line-height: var(--lh-label);
97
+ }
98
+
99
+ :root {
100
+ /* Phones and small tablets. */
101
+ --fs-title-lg: 1.5rem; /* 24px */
102
+ --lh-title-lg: 1.25;
103
+ --fs-title: 1.125rem; /* 18px */
104
+ --lh-title: 1.3;
105
+ --fs-subtitle: 1.0625rem; /* 17px */
106
+ --lh-subtitle: 1.4;
107
+ --fs-body: 1rem; /* 16px */
108
+ --lh-body: 1.45;
109
+ --fs-caption: 0.875rem; /* 14px */
110
+ --lh-caption: 1.4;
111
+ --fs-label: 0.75rem; /* 12px */
112
+ --lh-label: 1.35;
113
+ }
114
+
115
+ @media (width >= 48rem) {
116
+ :root {
117
+ --fs-subtitle: 1rem; /* 16px */
118
+ --fs-body: 0.9375rem; /* 15px */
119
+ --fs-caption: 0.8125rem; /* 13px */
120
+ }
121
+ }
122
+
71
123
  :root {
72
124
  --radius: 0.625rem;
73
125
  --background: oklch(1 0 0);
@@ -230,6 +282,23 @@ body,
230
282
  background-color: color-mix(in oklch, var(--muted), var(--foreground) 14%);
231
283
  }
232
284
 
285
+ /*
286
+ * นิ้วไม่ใช่เมาส์: รายการในเมนู/ดรอปดาวน์ไม่ได้ตั้งความสูงไว้เลย (`py-1` ≈ 26px) ซึ่งต่ำกว่าเกณฑ์
287
+ * Apple 44pt / Material 48dp มาก บนอุปกรณ์สัมผัสจึงยกพื้นความสูงให้ — ใช้ `pointer: coarse`
288
+ * (คือ "นิ้ว") ไม่ใช่ความกว้างจอ เพราะหน้าต่างแคบบนเดสก์ท็อปยังชี้ด้วยเมาส์อยู่
289
+ * override ที่นี่แบบไม่อยู่ใน layer จึงชนะ utility โดยไม่แก้ component ของ shadcn
290
+ */
291
+ @media (pointer: coarse) {
292
+ [data-slot="dropdown-menu-item"],
293
+ [data-slot="dropdown-menu-checkbox-item"],
294
+ [data-slot="dropdown-menu-radio-item"],
295
+ [data-slot="dropdown-menu-sub-trigger"],
296
+ [data-slot="select-item"],
297
+ [data-slot="command-item"] {
298
+ min-height: 2.75rem; /* 44px */
299
+ }
300
+ }
301
+
233
302
  /* ผู้ใช้ตั้ง "ลดการเคลื่อนไหว" → ตัด animation/transition ของทุก component (tw-animate, animate-spin, dialog zoom …) */
234
303
  @media (prefers-reduced-motion: reduce) {
235
304
  *,