@vibeuncle/gpgb-ui 0.2.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 ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ Versions follow semver while 0.x: **minor** = new components or token changes that may shift appearance, **patch** = fixes. Apps pin with `^0.x.0`.
4
+
5
+ ## 0.2.0 — first public release
6
+ - Tokens: paper/ink/accent palette with dark mode, `accent-text`, `border-strong`, `focus`, `on-accent`/`on-danger`; radii, shadows, motion tokens.
7
+ - `ds-*` CSS components and a React package: buttons, forms, cards, badges, tabs, modal, confirm dialog, sheet, menu, popover, tooltip, toast, stamp, skeleton.
8
+ - Navigation: `SiteHeader` (graphic logo + divider), `NavLinks`, `NavDrawer`, `Breadcrumbs`, `StepIndicator`, `UserMenu`, `WorkspaceSwitcher`, `LangToggle`, `SiteFooter`; layouts `PageShell`, `CenteredPage`, `SidebarLayout`, `PageTitle`.
9
+ - `LinkProvider` so apps can supply `next/link`; `containerClassName` and `homeLabel` on `SiteHeader`.
10
+ - zh/en `labels`, focus-trap/dismiss hooks, browser test suite, static style guide, `DESIGN.md` + `DESIGN_SYSTEM.md`.
11
+ - `accent-text` retuned to `#a84a0a` / `#ffb783` so it passes AA on `accent-soft`.
package/DESIGN.md ADDED
@@ -0,0 +1,254 @@
1
+ ---
2
+ name: 大道大商 Design System
3
+ description: Warm paper, warm ink, one orange. A calm community-bulletin look for 大道大商 apps.
4
+ colors:
5
+ paper: "#faf6ee"
6
+ paper-deep: "#f1eadb"
7
+ surface: "#ffffff"
8
+ ink: "#221f1a"
9
+ ink-soft: "#6b6458"
10
+ ink-faint: "#766e59"
11
+ border-soft: "#e8e0d1"
12
+ border-strong: "#8f8676"
13
+ accent: "#ff7f20"
14
+ accent-hover: "#d96c1b"
15
+ accent-soft: "#ffebdb"
16
+ accent-text: "#a84a0a"
17
+ focus: "#d96c1b"
18
+ on-accent: "#ffffff"
19
+ danger: "#c62828"
20
+ on-danger: "#ffffff"
21
+ warn: "#8a5a20"
22
+ success: "#2e7d4f"
23
+ scrim: "#1c1a16"
24
+ paper-dark: "#1c1a16"
25
+ paper-deep-dark: "#2a2620"
26
+ surface-dark: "#262320"
27
+ ink-dark: "#f1ece1"
28
+ ink-soft-dark: "#b5ac9c"
29
+ ink-faint-dark: "#948c73"
30
+ border-soft-dark: "#3a352c"
31
+ border-strong-dark: "#7d7463"
32
+ accent-dark: "#ff994d"
33
+ accent-hover-dark: "#ffac6e"
34
+ accent-soft-dark: "#664921"
35
+ accent-text-dark: "#ffb783"
36
+ focus-dark: "#ff994d"
37
+ on-accent-dark: "#221f1a"
38
+ on-danger-dark: "#221f1a"
39
+ danger-dark: "#f87171"
40
+ warn-dark: "#e0b06a"
41
+ success-dark: "#6fcf97"
42
+ typography:
43
+ display:
44
+ fontFamily: "Noto Serif SC, Songti SC, serif"
45
+ fontSize: "2.25rem to 3rem"
46
+ fontWeight: 500
47
+ lineHeight: 1.2
48
+ letterSpacing: "-0.025em"
49
+ title:
50
+ fontFamily: "Noto Serif SC, Songti SC, serif"
51
+ fontSize: "1.25rem"
52
+ fontWeight: 500
53
+ lineHeight: 1.2
54
+ body:
55
+ fontFamily: "Noto Sans SC, PingFang SC, system-ui, sans-serif"
56
+ fontSize: "14px"
57
+ fontWeight: 400
58
+ lineHeight: 1.6
59
+ reading:
60
+ fontFamily: "Noto Sans SC, PingFang SC, system-ui, sans-serif"
61
+ fontSize: "16px"
62
+ lineHeight: 1.85
63
+ letterSpacing: "0.01em"
64
+ reading-wide:
65
+ fontFamily: "Noto Sans SC, PingFang SC, system-ui, sans-serif"
66
+ fontSize: "17px"
67
+ lineHeight: 1.85
68
+ letterSpacing: "0.01em"
69
+ button-lg:
70
+ fontFamily: "Noto Sans SC, PingFang SC, system-ui, sans-serif"
71
+ fontSize: "15px"
72
+ fontWeight: 500
73
+ hint:
74
+ fontFamily: "Noto Sans SC, PingFang SC, system-ui, sans-serif"
75
+ fontSize: "12px"
76
+ lineHeight: 1.6
77
+ caption:
78
+ fontFamily: "Noto Sans SC, PingFang SC, system-ui, sans-serif"
79
+ fontSize: "12px"
80
+ fontWeight: 400
81
+ letterSpacing: "0.08em"
82
+ badge-sm:
83
+ fontFamily: "Noto Sans SC, PingFang SC, system-ui, sans-serif"
84
+ fontSize: "11px"
85
+ fontWeight: 500
86
+ letterSpacing: "0.05em"
87
+ code:
88
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace"
89
+ fontSize: "12px"
90
+ label:
91
+ fontFamily: "Noto Sans SC, PingFang SC, system-ui, sans-serif"
92
+ fontSize: "13px"
93
+ fontWeight: 500
94
+ rounded:
95
+ xs: "4px"
96
+ sm: "6px"
97
+ md: "8px"
98
+ lg: "12px"
99
+ xl: "16px"
100
+ pill: "9999px"
101
+ spacing:
102
+ xs: "4px"
103
+ sm: "8px"
104
+ md: "16px"
105
+ lg: "24px"
106
+ xl: "40px"
107
+ components:
108
+ button-primary:
109
+ backgroundColor: "{colors.accent}"
110
+ textColor: "{colors.on-accent}"
111
+ rounded: "{rounded.pill}"
112
+ padding: "10px 20px"
113
+ button-primary-hover:
114
+ backgroundColor: "{colors.accent-hover}"
115
+ button-outline:
116
+ backgroundColor: "{colors.surface}"
117
+ textColor: "{colors.ink}"
118
+ rounded: "{rounded.pill}"
119
+ padding: "10px 20px"
120
+ button-ghost:
121
+ backgroundColor: "{colors.paper}"
122
+ textColor: "{colors.ink-soft}"
123
+ rounded: "{rounded.pill}"
124
+ button-danger:
125
+ backgroundColor: "{colors.danger}"
126
+ textColor: "{colors.on-danger}"
127
+ rounded: "{rounded.pill}"
128
+ input:
129
+ backgroundColor: "{colors.surface}"
130
+ textColor: "{colors.ink}"
131
+ rounded: "{rounded.md}"
132
+ padding: "10px 14px"
133
+ card:
134
+ backgroundColor: "{colors.surface}"
135
+ rounded: "{rounded.lg}"
136
+ modal:
137
+ backgroundColor: "{colors.surface}"
138
+ rounded: "{rounded.xl}"
139
+ padding: "24px"
140
+ badge-accent:
141
+ backgroundColor: "{colors.accent}"
142
+ textColor: "{colors.on-accent}"
143
+ rounded: "{rounded.pill}"
144
+ padding: "4px 12px"
145
+ tab-selected:
146
+ backgroundColor: "{colors.accent}"
147
+ textColor: "{colors.on-accent}"
148
+ rounded: "{rounded.sm}"
149
+ ---
150
+
151
+ # Design System: 大道大商
152
+
153
+ Source: `styles/tokens.css` and `styles/components.css`. Rendered reference: `styleguide/index.html`. Usage guide: `DESIGN_SYSTEM.md`.
154
+ Derived from the shipped build, not from intentions. The creative direction below was inferred from the existing apps (greatpath-draw reference) and was not interviewed; confirm or rename it.
155
+
156
+ ## Overview
157
+
158
+ **Creative North Star: "The Community Bulletin."** A well-kept notice board in a warm room: cream paper, dark ink, a single orange seal of approval. Calm, legible, a little editorial. It is a working tool for volunteers and small teams, so it favours scanability and trust over spectacle.
159
+
160
+ - Pages are cream (`paper`); content sits on white `surface`; text is warm near-black `ink`. Dark mode is the same room at night: charcoal paper, cream ink, a lighter orange.
161
+ - Orange is the only brand colour and is rare by design: primary action, selection, focus, hover. One primary button per view.
162
+ - Serif (Noto Serif SC) carries voice, titles and quotes. Sans (Noto Sans SC) does the work, including all long reading.
163
+ - Shapes are soft (pills and rounded cards) with hairline borders and faint shadows. Motion is short and quiet.
164
+ - Light/dark follows the OS (`prefers-color-scheme`); `data-theme` forces one for previews.
165
+
166
+ ## Colors
167
+
168
+ Strategy: restrained. Warm neutrals do 90% of the work; orange appears as fills, rings and small accents.
169
+
170
+ - **Surfaces:** `paper` page, `paper-deep` recessed panels and table hover, `surface` cards/inputs/modals.
171
+ - **Text:** `ink` body and headings; `ink-soft` secondary; `ink-faint` hints and placeholders (never on `paper-deep`, 4.2:1 there).
172
+ - **Lines:** `border-soft` for decorative dividers and card edges; `border-strong` for anything that must be perceivable as a control (input edges, switch track), 3:1+.
173
+ - **Accent family:** `accent` for fills; `accent-hover` for pressed/hover fills and the light-mode focus ring; `accent-soft` for tints and selected-row washes; `accent-text` when orange must read as text or an icon (raw `accent` is only 2.3:1 on paper).
174
+ - **On-fill text:** `on-accent` is white in light (the reference look, 2.5:1) and ink in dark (7.8:1; white would be 2.1:1). `on-danger` is white in light mode, ink in dark.
175
+ - **Feedback:** `danger`, `warn`, `success` are text/border colours, with dark-mode counterparts. `scrim` is the overlay base and stays dark in both themes.
176
+
177
+ Rule: no raw hex in app code; no Tailwind red/gray/orange.
178
+
179
+ ## Typography
180
+
181
+ - **Display** (`font-display`): Noto Serif SC 500, tracking -0.025em, line-height 1.2, balanced wrapping. Page title 36 to 48px; section titles 20 to 24px.
182
+ - **Body:** Noto Sans SC 14px / 1.6. Secondary copy in `ink-soft`; hints 12px `ds-hint`.
183
+ - **Reading** (`font-answer`): 16px (17px from 640px), line-height 1.85, strict CJK line-breaking. Sans on purpose: serif hairlines break up on phones.
184
+ - **Editorial** (`font-editorial`): serif at 1.85 for quotes.
185
+ - **Caption** (`ds-caption`): 12px, uppercase, tracking .08em. Short labels only; never for sentences.
186
+ - Bilingual labels: Chinese first, English after or beneath. Numerals in tables are tabular.
187
+
188
+ ## Layout
189
+
190
+ - 4px base. Page gutter 24px; `ds-container` max 72rem. Card padding 16 to 24px; field gap 16px; button icon gap 8px.
191
+ - Mobile-first; `sm` (640px) is the phone/tablet switch. On touch (`pointer: coarse`) inputs are 16px (no iOS zoom) and buttons are 44px tall; switches get a 44px hit area; tabs 40px.
192
+ - Sections separate with generous vertical space (40px) and a hairline; groups inside stay tight.
193
+
194
+ ## Elevation & Depth
195
+
196
+ Mostly flat: hairline borders do the separating. Shadows are soft with offset and blur: `sm` at rest (cards, primary button), `md` on hover or raise, `lg` for modals and toasts. Dark mode deepens the shadow alpha. Overlays use `scrim` at 75%, with no blur.
197
+
198
+ ## Shapes
199
+
200
+ Pill for anything you press or read as a label (buttons, badges, toasts). 16px modals, 12px cards, 8px inputs and tab groups, 6px tab items and skeletons. Images inside cards are clipped to the card radius.
201
+
202
+ ## Components
203
+
204
+ - **Button:** pill, 14px/500. Variants primary (accent fill, on-accent text), outline, ghost, danger. Sizes sm, md, lg, icon. States: hover (darker fill or accent-soft wash, ghost text goes `accent-text`), press (scale .97), focus (2px `focus` ring, 2px offset), disabled (.45 opacity), loading (`aria-busy`, label hidden, spinner). Icon-only needs `aria-label`.
205
+ - **Input / textarea / select:** 8px, `border-strong` edge, hover to `ink-soft`, focus ring in `focus` with matching border, error via `aria-invalid` (danger border and ring) plus a `ds-error` message with `role=alert`. Checkbox and radio are native, `accent-color` orange.
206
+ - **Switch:** 40x24 pill, `role=switch`; off track `border-strong`, on track `accent`, white thumb.
207
+ - **Card:** surface, 1px `border-soft`, 12px, shadow-sm. Interactive card: border to accent and shadow-md on hover.
208
+ - **Badge:** pill; accent, ink, tint, outline, danger tones; small variant 11px uppercase.
209
+ - **Tabs / segmented:** `border-soft` group, 6px items, selected = accent fill with on-accent text. Toggle groups use `aria-pressed`, real tabs `aria-selected`.
210
+ - **Modal:** surface, 16px, shadow-lg, backdrop-in then modal-in (200ms). Esc and backdrop close; scroll locked; labelled. Footer: outline cancel, then the primary or danger action.
211
+ - **Toast:** top-centre pill, ink fill with paper text (danger fill for errors), 3s.
212
+ - **Alert:** inline, 8px, accent-soft default, danger and warn outlined. **Empty state:** serif title, hint, one action. **Skeleton** mirrors final layout; **spinner** only for short waits.
213
+ - **Table:** hairline rows, 12px/14px cells, tabular numerals, hover `paper-deep`.
214
+ - **Header:** hairline bottom, graphic logo (h-6), a 1px vertical hairline divider (20px, `border-strong` at 55%) so the lettering mark and the app name don't read as one phrase, then the serif app title, optional nav, right-aligned actions; skip link to main. The logo flips to light in dark mode, including the manual toggle.
215
+ - **Navigation:** pill nav links (current = `accent-soft` wash, `aria-current="page"`); below 768px they move to a left drawer behind a menu button. Breadcrumbs for depth 3+. Step indicator: done = ink check, current = accent dot on a tint, upcoming = outline. Footer: small logo, copyright, links.
216
+ - **Menu / popover / tooltip:** surface panel, 1px `border-soft`, 12px, shadow-md, 6px padding; items 8px radius with an `accent-soft` hover/focus wash and an inset `focus` ring; separators `border-soft`; destructive items in `danger`. Tooltip: ink bubble with paper text, 12px, 350ms delay, hidden on touch.
217
+ - **Sheet:** bottom sheet (16px top radius, grab handle) or left drawer (320px max); slides 300ms; closed = inert; focus trapped while open.
218
+ - **Account:** 32px avatar (accent-soft, initial) opening a menu; workspace switcher is an outline pill with a chevron and a 仅查看 badge for viewers.
219
+ - **Layouts:** PageShell (header, 72rem container, footer), CenteredPage (one 400px, 16px-radius card on paper), SidebarLayout (240px sticky aside from 1024px; a bottom sheet on phones).
220
+ - **Browser surfaces:** caret and checkboxes orange; selection is `accent-soft`; scrollbar thumbs `border-soft`; links underline at 4px offset on hover.
221
+
222
+ ## Voice
223
+
224
+ Warm, plain, respectful; Chinese first, English beside or beneath. Buttons are verb + object (保存海报, never 确定). Errors give the cause, then the fix. Empty states say what belongs there plus one action. Chinese uses full-width punctuation and a space before numbers and Latin words. English is sentence case.
225
+
226
+ ## Motion
227
+
228
+ Calm, physical, short: things settle into place like paper on a board. Tokens: 150ms (hover, press), 200ms (modal, slide, card lift), 350ms (list entry), 560ms (stamp); entrances use `cubic-bezier(.16,1,.3,1)`, the switch thumb settles with `cubic-bezier(.34,1.25,.64,1)`.
229
+
230
+ The one authored moment is the **seal stamp** (`ds-stamp`): an orange seal with an inner ring that lands from 1.8x scale and -16deg, settles at -6deg, and sends out one fading ring. Used once per view, on a meaningful success. Supporting motion is quiet feedback: staggered list entry (45ms steps, first load only), scroll reveal (CSS scroll timeline, no JS), card lift of 2px, link underline drawing in from the left, a switch thumb with a small spring, and a skeleton sweep. Modals use backdrop-in then modal-in; toasts drop 12px.
231
+
232
+ Only transform, opacity and shadow animate, never layout. `prefers-reduced-motion` removes all of it.
233
+
234
+ ## Do's and Don'ts
235
+
236
+ Do
237
+ - Use tokens and `ds-*` classes; one primary button per view.
238
+ - Use `accent-text` when orange is text; `border-strong` for control edges; `focus` for rings.
239
+ - Keep serif for titles and quotes, sans for everything else.
240
+ - Give every state (hover, focus, disabled, loading, error, empty) a treatment.
241
+
242
+ Don't
243
+ - Use a menu for a hint, a tooltip for required information, or a modal for a task that needs neither interruption nor protected focus.
244
+ - Use orange-fill labels below 14px or below medium weight in light mode, or put white text on orange in dark mode. Orange as text must use `accent-text` (4.5:1 on paper).
245
+ - Use `ink-faint` on `paper-deep`, or `border-soft` as the only edge of a form control.
246
+ - Add colour beyond the tokens, a second brand colour, gradient text, or side-stripe borders on cards and alerts.
247
+ - Use caption (uppercase) styling for sentences, or Unicode glyphs in place of icons (Lucide, 16 to 20px, stroke 2).
248
+ - Animate layout, or replay entrances on content people are reading.
249
+ - Use the stamp more than once per view, or for anything minor.
250
+
251
+ ## Known exceptions
252
+
253
+ - White on orange fills in light mode is 2.5:1 (3.4:1 on hover), below AA. Accepted to match greatpath-draw. Mitigation: labels stay 14px+ medium, never the only signal, and focus uses the separate `focus` token.
254
+ - Disabled controls (.45 opacity) are exempt from contrast by WCAG.
@@ -0,0 +1,165 @@
1
+ # 大道大商 Design System (`@vibeuncle/gpgb-ui`)
2
+
3
+ Source of truth for the shared look of draw.gpgb.app, albums, jiqun-studio, jielong and future 大道大商 apps.
4
+ Reference app: **greatpath-draw**. Where apps disagreed, greatpath-draw won; gaps were filled from jiqun-studio
5
+ (feedback colours, shadows, radii, `ds-*` classes), gpgb-albums (easing) and jielong (`paper-deep`).
6
+
7
+ ## Principles
8
+ 1. **Warm paper + ink.** Pages are cream (`paper`), content sits on white `surface`, text is warm near-black `ink`.
9
+ 2. **One orange.** `accent` (#ff7f20, the VI primary) is the only brand colour: primary action, focus, selection, links on hover. One primary button per view.
10
+ 3. **Soft shapes.** Pills for actions and labels, rounded cards, hairline borders. Shadows are faint.
11
+ 4. **Serif for voice, sans for work.** Noto Serif SC for titles and quotes; Noto Sans SC for everything else, including long reading text.
12
+ 5. **Quiet motion.** Short ease-out entrances that answer an action; nothing loops except spinners/skeletons. Reduced-motion flattens all of it.
13
+ 6. **Dark mode is automatic** (`prefers-color-scheme`); never branch on theme in components, use tokens.
14
+
15
+ ## Colour tokens
16
+ | Token | Light | Dark | Use |
17
+ |---|---|---|---|
18
+ | `paper` | #faf6ee | #1c1a16 | Page background |
19
+ | `paper-deep` | #f1eadb | #2a2620 | Recessed panels, table hover |
20
+ | `surface` | #ffffff | #262320 | Cards, inputs, modals |
21
+ | `ink` | #221f1a | #f1ece1 | Body/headings |
22
+ | `ink-soft` | #6b6458 | #b5ac9c | Secondary text |
23
+ | `ink-faint` | #766e59 | #948c73 | Hints, placeholders (AA on paper) |
24
+ | `border-soft` | #e8e0d1 | #3a352c | Decorative dividers, card edges |
25
+ | `border-strong` | #8f8676 | #7d7463 | Form-control edges, switch track (3:1+) |
26
+ | `accent` / `-hover` / `-soft` | #ff7f20 / #d96c1b / #ffebdb | #ff994d / #ffac6e / #664921 | Brand fill, hover, tinted bg |
27
+ | `accent-text` | #a84a0a | #ffb783 | Orange used as text/icon (4.8:1+ on paper, surface, paper-deep and accent-soft; raw accent is 2.3:1 on paper) |
28
+ | `focus` | #d96c1b | #ff994d | Focus ring (3:1+) |
29
+ | `danger` | #c62828 | #f87171 | Errors, destructive |
30
+ | `warn` | #8a5a20 | #e0b06a | Warnings |
31
+ | `success` | #2e7d4f | #6fcf97 | Confirmations |
32
+ | `on-accent` | #ffffff | #221f1a | Text on accent fills. White in light (reference look), ink in dark |
33
+ | `on-danger` | #ffffff | #221f1a | Text on danger fills |
34
+ | `scrim` | #1c1a16 | same | Overlays (always dark) |
35
+
36
+ Use as Tailwind utilities: `bg-paper text-ink-soft border-border-soft`. No raw hex in app code. Don't use Tailwind's red/gray/orange.
37
+
38
+ ## Typography
39
+ - Display: `font-display` (Noto Serif SC 500, tracking -0.025em, lh 1.2). Page title `text-4xl sm:text-5xl`; section `text-xl`.
40
+ - Body: Noto Sans SC 14px (`text-sm`), lh 1.6. Secondary copy `text-ink-soft`; hints `ds-hint` (12px).
41
+ - Reading text: `font-answer` (16px phone / 17px sm+, lh 1.85, strict CJK line-breaking). Editorial quotes: `font-editorial`.
42
+ - Eyebrow/caption: `ds-caption` (12px, uppercase, tracking .08em).
43
+ - Bilingual labels: Chinese first, English in `ds-hint` beneath or after a `·`. Don't mix scripts mid-label in different fonts. `lang="zh-CN"` on `<html>`.
44
+ - Load fonts via `fonts.css` (fontsource) or `next/font` exposing the same family names. Brand-VI commercial fonts are not bundled.
45
+
46
+ ## Shape, elevation, spacing
47
+ - Radii: `pill` buttons/badges/toasts; `xl` 16 modals & hero cards; `lg` 12 cards; `md` 8 inputs/tabs; `sm` 6 tab items/skeletons. Images in cards: `lg` clipped.
48
+ - Shadows: `sm` cards at rest, `md` hover/raised, `lg` modals/toasts. Borders stay 1px `border-soft`.
49
+ - Spacing: 4px base; page gutter 24px (`ds-container`, max 72rem); card padding 16–24px; gap between form fields 16px; button gap 8px.
50
+ - Breakpoints: Tailwind defaults; design mobile-first, `sm` 640 is the phone/tablet switch.
51
+
52
+ ## Components (all `ds-*` classes; React wrappers in `src/`)
53
+ | Component | Class / React | Notes |
54
+ |---|---|---|
55
+ | Button | `ds-btn ds-btn-{primary,outline,ghost,danger}` `-sm -lg -icon` / `<Button>` | Pill. Press = scale .97. Disabled .45 opacity. Loading = `aria-busy="true"`. Icon-only needs `aria-label`. Primary left→right order: cancel(outline), confirm(primary). |
56
+ | Input/Textarea/Select | `ds-input` `-sm` / `<Input>` … | 8px radius; focus = accent border + ring; error = `aria-invalid` (danger border) + `ds-error`. Wrap with `<Field>` for label/hint/error. |
57
+ | Switch | `ds-switch` / `<Switch>` | `role=switch`; 44px hit area. |
58
+ | Checkbox/radio | `ds-check` | Native control, orange `accent-color`. |
59
+ | Card | `ds-card` `-interactive` / `<Card>` | Clickable cards get `-interactive` (accent border + shadow-md on hover). |
60
+ | Badge/chip | `ds-badge-{accent,ink,tint,outline,danger}` `-sm` / `<Badge>` | Status = tint/outline; count/new = accent. |
61
+ | Tabs | `ds-tabs` `ds-tab` / `<Tabs>` | Segmented; selected = solid accent with on-accent text. Toggle groups: `aria-pressed`. |
62
+ | Modal | `ds-backdrop ds-modal` / `<Modal>` | Esc/backdrop close, scroll lock, `aria-labelledby`. Enter: backdrop-in + modal-in. |
63
+ | Toast | `ds-toast` `-error` / `<Toast>` | Top-centre pill, 3s. |
64
+ | Alert | `ds-alert` `-danger -warn` / `<Alert>` | Inline, persistent messages. |
65
+ | Empty state | `ds-empty` / `<EmptyState>` | Serif title, hint, one action. |
66
+ | Skeleton/Spinner | `ds-skeleton`, `ds-spinner` | Skeleton mirrors final layout; spinner only for <3s waits. |
67
+ | Table | `ds-table` | Hairline rows, hover `paper-deep`. |
68
+ | Header | `ds-header` / `<SiteHeader>` | Graphic logo `assets/logo/daoshang-horizontal-mark-dark.png` (h-6, `brand-logo` flips it light in dark mode; also `…-bare-dark.png` without the brush mark) + a 1px vertical hairline (`ds-brand-divider`, 20px tall, `border-strong` at 55%) + serif app title + right-aligned actions. The divider separates the brand mark from the app name because the mark is lettering; it shows only when there is an app title. Copy the file into the app's `public/logo/`. |
69
+ | Link | `ds-link` | Soft ink, accent + underline on hover. |
70
+
71
+ ## Motion
72
+ Principle: calm, physical, short. Things settle into place like paper on a bulletin board; the single flourish is the **seal stamp**.
73
+ Tokens: `--duration-fast` 150ms (hover/press), `--duration-base` 200ms (modal, slide, lift), `--duration-slow` 350ms (list entry), `--duration-stamp` 560ms; `--ease-out` cubic-bezier(.16,1,.3,1) for entrances, `--ease-spring` for a gentle settle (switch thumb), `--ease-standard` for state changes, `--stagger-step` 45ms.
74
+
75
+ | Pattern | Class | Use |
76
+ |---|---|---|
77
+ | Entrances | `animate-backdrop-in`, `animate-modal-in`, `animate-entry-in`, `animate-slide-next/prev`, `toast-in` (built into `ds-toast`) | Overlays, panels, toasts, stepped flows |
78
+ | Stagger | `ds-stagger` on a list/grid parent | First load of lists, galleries, dashboards. 45ms steps, capped at 12 |
79
+ | Scroll reveal | `ds-reveal` | Long pages. CSS scroll timeline, no JS; unsupported browsers just show it |
80
+ | Seal stamp | `ds-stamp` / `<Stamp>` | Once per view on a meaningful success (published, saved, signed up) |
81
+ | Card lift | `ds-card-interactive` | Clickable cards: rise 2px + deeper shadow, settle on press |
82
+ | Link underline | `ds-link` | Underline draws in from the left on hover |
83
+ | Switch | `ds-switch` | Thumb settles with a small overshoot |
84
+ | Skeleton | `ds-skeleton` | Soft sweep while loading (not a blink) |
85
+ | Slideshow | `animate-fade-in` + `animate-drift` | Photo cross-fade and slow Ken Burns (from gpgb-albums) |
86
+
87
+ Rules: one flourish per view; everything else is quiet feedback that answers an action. Move at most 10px (the stamp excepted). Animate transform, opacity and shadow only, never layout. Stagger only on first load, never replay entrances on content people are reading. Nothing loops except spinner and skeleton. `prefers-reduced-motion` removes all animation and transition, scroll reveal included.
88
+
89
+ ## Navigation
90
+ | Piece | React | Notes |
91
+ |---|---|---|
92
+ | Header | `<SiteHeader logoSrc title nav actions menuButton containerClassName homeLabel>` | Graphic logo left; `nav` shows from `md`; `menuButton` shows below `md`. Includes a skip link to `#main` (give your `<main>` that id). `containerClassName` sets the content width (default 72rem `ds-container`; narrow apps pass e.g. `mx-auto max-w-2xl px-5`). Put short always-visible links in `actions` instead of `nav`. |
93
+ | Nav links | `<NavLinks items>` / `ds-nav-link` | Pill links; current = accent-soft wash + `aria-current="page"`. |
94
+ | Phone nav | `<MenuButton>` + `<NavDrawer items open onOpenChange>` | Left drawer (`Sheet side="left"`). Closes on link click, Esc, scrim. |
95
+ | Account | `<UserMenu>` / `<SignInButton>` | Avatar + menu: settings, extras, sign out. Signed out: outline 登录 button. |
96
+ | Workspace | `<WorkspaceSwitcher workspaces activeId onSwitch>` | Menu of radios; viewer role shows a "仅查看" badge. Reserve its width with a skeleton while loading. The app performs the switch. |
97
+ | Language | `<LangToggle value onChange>` | 中 / EN, `aria-pressed`. The app owns persistence. |
98
+ | Breadcrumbs | `<Breadcrumbs items>` | Depth 3+ only. Last item = current page, not a link. |
99
+ | Steps | `<StepIndicator steps current>` | Done = ink check, current = accent, upcoming = outline. Phones show only the current label. |
100
+ | Footer | `<SiteFooter logoSrc links note>` | Small logo, copyright, links. |
101
+
102
+ ## Menus, popovers, tooltips, sheets
103
+ Pick by job: **Menu** = list of actions; **Popover** = a little extra content or mini form; **Tooltip** = a name or shortcut for an icon button (never required info; hidden on touch); **ConfirmDialog** = before anything irreversible; **Sheet** = phone bottom sheet or nav drawer; **Modal** = a task that needs protected focus.
104
+ - Menu: opens below (`align="end"` for right-edge triggers), focuses the first item however it opens, Arrow/Home/End move (disabled items skipped), Esc closes and returns focus to the trigger, Enter/Space selects. Items with `href` render as links; `checked` makes a radio item; `danger` colours destructive items.
105
+ - Modal, ConfirmDialog and Sheet trap focus, close on Esc, lock scroll and restore focus to the opener. Footer order: outline cancel, then the primary or danger action, whose label names the action ("删除海报", not "确定").
106
+ - Positioning is simple (below the trigger, start or end aligned); there is no collision flipping yet. Keep triggers away from the bottom edge, or use a Sheet on phones.
107
+
108
+ ## Page layouts
109
+ `<PageShell header footer>` (header, 72rem container, footer: lists, galleries, dashboards) · `<CenteredPage>` (one 400px card: sign-in, invite, not-found) · `<SidebarLayout aside>` (240px sticky aside from `lg`; on phones move the aside into a bottom Sheet: editors, settings, admin) · `<PageTitle title description actions breadcrumbs>` (serif title, primary action first on phones).
110
+
111
+ ## Content & voice
112
+ Warm, plain, respectful: a helpful neighbour at the community centre. Chinese (简体) first, English beside or beneath. Say what happened and what to do next; never blame the person.
113
+ | Situation | Say | Avoid |
114
+ |---|---|---|
115
+ | Button | 保存海报 · Save poster (verb + object) | 确定 · OK · Submit |
116
+ | Destructive confirm | 删除海报 (names the thing) | 确定 · Yes |
117
+ | Error | 上传失败,图片超过 10 MB。请压缩后重试。 | 出错了!错误代码 500 |
118
+ | Empty state | 还没有海报。从模板开始,或上传背景图。 | 暂无数据 |
119
+ | Success | 已发布。 · Published. | 操作成功! |
120
+ | Loading (over 1s) | 正在生成海报… | 请稍候 |
121
+ | Permission | 你只有查看权限。联系管理员可申请编辑。 | 无权访问 |
122
+ Rules: cancel is always 取消 / Cancel. Errors give the cause then the fix, no codes up front, no "Oops". Empty states say what belongs there plus one action. The seal stamp is for big successes only. Chinese uses full-width punctuation (,。:); English uses sentence case, never all-caps sentences. Put a space between Chinese and numbers or Latin words (更新于 3 分钟前). Dates: 2026年10月5日 / 5 Oct 2026. Don't translate proper names. Icon-only buttons carry an `aria-label` in the page language.
123
+ Code: `labels.zh` / `labels.en` (and `t(lang, key)`) hold the standard strings (取消, 确认, 保存, 删除, 关闭, 重试, 返回, 下一步, 登录, 退出登录, 设置, 帮助…). Components accept `lang="zh" | "en"` (default zh).
124
+
125
+ ## Accessibility baseline
126
+ - Focus: `:focus-visible` 2px `focus` outline, 2px offset (inputs: 1px offset, border matches). Never `outline: none` without a replacement.
127
+ - Contrast: text meets AA in both themes (see the Contrast table in the style guide). Known gap: white on orange fills is 2.5:1 in light (3.4:1 on hover), below AA. Accepted to keep greatpath-draw's look: keep those labels 14px+ medium weight and never let them carry meaning alone. Dark mode uses ink on orange (7.8:1) because white would be 2.1:1. Orange as text uses `accent-text`. `ink-faint` is for paper/surface only, not `paper-deep`. Form-control edges use `border-strong` (3:1+); focus rings use `focus`.
128
+ - Touch: coarse pointers get 16px inputs (stops iOS zoom) and 44px min button height. No tap highlight/300ms delay.
129
+ - Semantics: dialogs `role=dialog aria-modal` with focus trap and restore; menus `role=menu/menuitem(radio)` with roving arrow keys; tabs `role=tablist/tab/aria-selected`; toggle groups `aria-pressed`; nav `aria-current="page"`, steps `aria-current="step"`; toasts `status`/`alert`; closed sheets are `inert`; icons that carry meaning have labels.
130
+ - Icons: Lucide, 16–20px, stroke 2, inherit `currentColor`.
131
+
132
+ ## Using it in an app
133
+ ```bash
134
+ npm install @vibeuncle/gpgb-ui # pin with ^0.x; Renovate/Dependabot keeps it current
135
+ ```
136
+ ```css
137
+ /* app/globals.css */
138
+ @import 'tailwindcss';
139
+ @import '@vibeuncle/gpgb-ui/styles.css';
140
+ @import '@vibeuncle/gpgb-ui/fonts.css'; /* optional */
141
+ ```
142
+ ```tsx
143
+ import { Button, Card, Modal } from '@vibeuncle/gpgb-ui'
144
+ ```
145
+ Next.js: wrap the app once in `<LinkProvider value={Link}>` (`import Link from 'next/link'`) so nav, breadcrumbs, footer and menu links do client-side navigation (default is a plain `<a>`, i.e. full page loads). Add `transpilePackages: ['@vibeuncle/gpgb-ui']` to `next.config.ts` (the package ships TS source). `styles.css` already contains `@source '../src'`, so your Tailwind generates the utilities the React components use; no extra setup. Components are client components where they need state (`'use client'` is in the files).
146
+
147
+ ## Migration map (when you adopt it)
148
+ | App's old name | Canonical |
149
+ |---|---|
150
+ | gpgb-albums `brand`, `brand-dark`, `brand-soft` | `accent`, `accent-hover`, `accent-soft` |
151
+ | gpgb-albums `ink-900/800/700`, `hairline`, `paper-100`, `night` | `ink`, `ink` / `ink-soft`, `border-soft`, `paper-deep`, `scrim` |
152
+ | jielong `seal`, `seal-deep`, `seal-wash`, `card`, `hairline` | `accent`, `accent-hover`, `accent-soft`, `surface`, `border-soft` |
153
+ | jiqun-studio `--brand-orange`, `--grey-*`, `--ochre-600` and other legacy aliases | `accent`, `border-soft`/`ink-soft`, `warn` |
154
+ | greatpath-draw inline `rounded-lg bg-accent …` buttons / `bg-red-600` toasts | `<Button>`, `<Toast tone="error">` |
155
+ | greatpath-draw `MobileSheet`, `WorkspaceSwitcher`, `StepIndicator`, `PosterPreviewModal` shell | `<Sheet>`, `<WorkspaceSwitcher>`, `<StepIndicator>`, `<Modal>` |
156
+ | jiqun-studio `BrandHeader`, `UserMenu`, `LangToggle` · jielong `SiteHeader` · albums `site-header`/`account-bar` | `<SiteHeader>`, `<UserMenu>`, `<LangToggle>` (+ `<NavLinks>` / `<NavDrawer>`) |
157
+ | `text-white` on `bg-accent` / `bg-brand` / `bg-seal` | `text-on-accent` (white in light, ink in dark). Orange text on paper: `text-accent-text` |
158
+ | `border-border-soft` on inputs | `border-border-strong` (use `ds-input`) |
159
+ Colour values already match, so renames are mechanical. The one visual change is dark mode, where text on orange becomes ink.
160
+
161
+ ## Theme preview
162
+ `data-theme="light|dark"` on any element forces that theme for it (the style guide uses this on `<html>`); otherwise the OS setting applies. Visual reference: `styleguide/index.html` (rebuild with `npm run styleguide:build`). Machine-readable spec: `DESIGN.md`.
163
+
164
+ ## Governance
165
+ Change tokens here first, then bump the apps; never edit a copy inside an app. Add a component only when 2+ apps need it. Update this file and `styles/` in the same commit.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @vibeuncle/gpgb-ui
2
+ Shared 大道大商 design system. Spec: [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md).
3
+ - `styles/tokens.css` — Tailwind v4 `@theme` tokens + dark mode
4
+ - `styles/components.css` — `ds-*` component classes, motion, a11y base
5
+ - `src/` — React components: Button, Field/Input/Select/Switch, Card/Badge/Alert/EmptyState, Tabs, Modal, ConfirmDialog, Sheet, Menu, Popover, Tooltip, Toast, Stamp, SiteHeader, NavLinks/NavDrawer, Breadcrumbs, StepIndicator, UserMenu, WorkspaceSwitcher, LangToggle, PageShell/CenteredPage/SidebarLayout/PageTitle, SiteFooter; `copy.ts` (zh/en labels); `hooks.ts` (focus trap, dismiss, scroll lock)
6
+ - `assets/logo/` — the graphic logo
7
+ - `styleguide/` — static visual reference (open `index.html`)
8
+ No apps are migrated yet. `npm run typecheck` needs react/lucide-react/typescript installed.
9
+
10
+ ## Install
11
+ ```bash
12
+ npm install @vibeuncle/gpgb-ui # peers: react, tailwindcss ^4, lucide-react
13
+ ```
14
+ Then follow "Using it in an app" in [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) (CSS import, `transpilePackages`, `LinkProvider`).
15
+
16
+ ## Releasing
17
+ 1. Change tokens/components; update the style guide and docs in the same commit; add a `CHANGELOG.md` entry.
18
+ 2. `npm run check` (typecheck + browser tests; also runs in CI).
19
+ 3. `npm version minor` (or `patch`), then `git push --follow-tags`.
20
+ 4. The tag triggers `.github/workflows/release.yml`, which publishes to npm when the repo secret `NPM_TOKEN` is set. Or publish by hand: `npm publish`.
21
+ 5. Apps pick it up via Renovate/Dependabot PRs (or `npm update @vibeuncle/gpgb-ui`).
22
+
23
+ Licence: proprietary (`UNLICENSED`): published for 大道大商 / VibeUncle apps, no reuse grant.
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@vibeuncle/gpgb-ui",
3
+ "version": "0.2.0",
4
+ "description": "大道大商 shared design system: tokens, CSS components, React components, style guide",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/chambre-HA/gpgb-ui.git"
9
+ },
10
+ "homepage": "https://github.com/chambre-HA/gpgb-ui#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/chambre-HA/gpgb-ui/issues"
13
+ },
14
+ "keywords": [
15
+ "design-system",
16
+ "tailwind",
17
+ "react",
18
+ "nextjs",
19
+ "大道大商"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "type": "module",
25
+ "sideEffects": [
26
+ "*.css"
27
+ ],
28
+ "files": [
29
+ "src",
30
+ "styles",
31
+ "assets",
32
+ "DESIGN.md",
33
+ "DESIGN_SYSTEM.md",
34
+ "README.md",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "exports": {
38
+ ".": "./src/index.ts",
39
+ "./tokens.css": "./styles/tokens.css",
40
+ "./components.css": "./styles/components.css",
41
+ "./fonts.css": "./styles/fonts.css",
42
+ "./styles.css": "./styles/index.css",
43
+ "./logo/*": "./assets/logo/*"
44
+ },
45
+ "peerDependencies": {
46
+ "react": ">=18",
47
+ "tailwindcss": "^4",
48
+ "lucide-react": ">=0.400"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "lucide-react": {
52
+ "optional": false
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@types/react": "^19",
57
+ "typescript": "^5",
58
+ "@tailwindcss/cli": "^4",
59
+ "@types/react-dom": "^19",
60
+ "esbuild": "^0.25",
61
+ "lucide-react": "^0.562.0",
62
+ "react": "^19",
63
+ "react-dom": "^19",
64
+ "tailwindcss": "^4"
65
+ },
66
+ "scripts": {
67
+ "typecheck": "tsc --noEmit",
68
+ "styleguide:build": "tailwindcss -i styleguide/input.css -o styleguide/styleguide.css --minify",
69
+ "test": "node test/run.mjs",
70
+ "check": "npm run typecheck && npm test",
71
+ "prepublishOnly": "npm run check",
72
+ "release": "npm run check && npm version"
73
+ }
74
+ }
package/src/cn.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function cn(...parts: Array<string | false | null | undefined>): string {
2
+ return parts.filter(Boolean).join(' ')
3
+ }
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+ import { ChevronDown, LogIn, LogOut, Settings } from 'lucide-react'
3
+ import { t } from '../copy'
4
+ import { useLink } from '../link'
5
+ import type { Lang } from '../copy'
6
+ import { Menu } from './Popover'
7
+ import type { MenuEntry } from './Popover'
8
+ import { Badge } from './Surface'
9
+
10
+ export function Avatar({ name, src }: { name: string; src?: string }) {
11
+ return <span className="ds-avatar" aria-hidden>{src ? <img src={src} alt="" /> : name.trim().slice(0, 1).toUpperCase()}</span>
12
+ }
13
+
14
+ /** Signed-in user control. Signed out, render a "登录" Button instead. `extra` items go above Sign out. */
15
+ export function UserMenu({ name, email, avatarSrc, onSettings, settingsHref, onSignOut, extra = [], lang }: {
16
+ name: string; email?: string; avatarSrc?: string; onSettings?: () => void; settingsHref?: string; onSignOut: () => void; extra?: MenuEntry[]; lang?: Lang
17
+ }) {
18
+ const items: MenuEntry[] = [
19
+ { type: 'label', label: email ? `${name} · ${email}` : name },
20
+ ...(onSettings || settingsHref ? [{ label: t(lang, 'settings'), icon: <Settings size={16} aria-hidden />, onSelect: onSettings, href: settingsHref } as MenuEntry] : []),
21
+ ...extra,
22
+ { type: 'separator' },
23
+ { label: t(lang, 'signOut'), icon: <LogOut size={16} aria-hidden />, onSelect: onSignOut },
24
+ ]
25
+ return (
26
+ <Menu align="end" label={t(lang, 'account')} items={items}
27
+ trigger={<button type="button" className="ds-btn ds-btn-ghost ds-btn-sm" aria-label={`${t(lang, 'account')}: ${name}`}><Avatar name={name} src={avatarSrc} /><ChevronDown size={14} aria-hidden /></button>} />
28
+ )
29
+ }
30
+
31
+ export function SignInButton({ onClick, href, lang }: { onClick?: () => void; href?: string; lang?: Lang }) {
32
+ const Link = useLink()
33
+ const cls = 'ds-btn ds-btn-outline ds-btn-sm'
34
+ return href
35
+ ? <Link className={cls} href={href}><LogIn size={16} aria-hidden />{t(lang, 'signIn')}</Link>
36
+ : <button type="button" className={cls} onClick={onClick}><LogIn size={16} aria-hidden />{t(lang, 'signIn')}</button>
37
+ }
38
+
39
+ export interface WorkspaceOption { id: string; name: string; role?: string }
40
+
41
+ /** Shows the active workspace and switches between the ones you belong to. The app performs the switch (and any reload).
42
+ * Reserve its footprint while the list loads (render <Skeleton className="h-[34px] w-36" />) so the header doesn't reflow. */
43
+ export function WorkspaceSwitcher({ workspaces, activeId, onSwitch, footer, lang }: {
44
+ workspaces: WorkspaceOption[]; activeId: string; onSwitch: (id: string) => void; footer?: MenuEntry[]; lang?: Lang
45
+ }) {
46
+ const active = workspaces.find(w => w.id === activeId) ?? workspaces[0]
47
+ const items: MenuEntry[] = [
48
+ { type: 'label', label: t(lang, 'switchWorkspace') },
49
+ ...workspaces.map(w => ({ label: w.name, checked: w.id === active?.id, hint: w.role === 'viewer' ? t(lang, 'viewer') : undefined, onSelect: () => w.id !== active?.id && onSwitch(w.id) }) as MenuEntry),
50
+ ...(footer?.length ? [{ type: 'separator' } as MenuEntry, ...footer] : []),
51
+ ]
52
+ return (
53
+ <Menu label={t(lang, 'workspace')} items={items}
54
+ trigger={<button type="button" className="ds-btn ds-btn-outline ds-btn-sm" aria-label={`${t(lang, 'workspace')}: ${active?.name}`}>
55
+ <span className="max-w-[10rem] truncate">{active?.name}</span>{active?.role === 'viewer' && <Badge tone="outline" small>{t(lang, 'viewer')}</Badge>}<ChevronDown size={14} aria-hidden /></button>} />
56
+ )
57
+ }
@@ -0,0 +1,26 @@
1
+ import type { ButtonHTMLAttributes } from 'react'
2
+ import { cn } from '../cn'
3
+
4
+ export type ButtonVariant = 'primary' | 'outline' | 'ghost' | 'danger'
5
+ export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon'
6
+
7
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
8
+ variant?: ButtonVariant
9
+ size?: ButtonSize
10
+ }
11
+
12
+ export function buttonClass(variant: ButtonVariant = 'primary', size: ButtonSize = 'md', extra?: string) {
13
+ return cn(
14
+ 'ds-btn',
15
+ `ds-btn-${variant}`,
16
+ size === 'sm' && 'ds-btn-sm',
17
+ size === 'lg' && 'ds-btn-lg',
18
+ size === 'icon' && 'ds-btn-icon',
19
+ extra,
20
+ )
21
+ }
22
+
23
+ /** Pill button. Use one `primary` per view; `outline` for secondary, `ghost` for toolbars. Icon-only needs aria-label. */
24
+ export function Button({ variant, size, className, type = 'button', ...rest }: ButtonProps) {
25
+ return <button type={type} className={buttonClass(variant, size, className)} {...rest} />
26
+ }