@etamong-playground/ui 0.34.2

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/README.md ADDED
@@ -0,0 +1,1302 @@
1
+ > **About** — One of several small shared libraries used across a personal "fleet" of small apps (error handling · audit logging · encryption-at-rest · i18n · UI · …). Authored and maintained with [Claude Code](https://www.anthropic.com/claude-code) (Anthropic's agentic CLI). Each README documents the design rationale behind the library.
2
+ >
3
+ > **This is a public repository** — keep internal infrastructure details (hostnames, secret/Vault paths, private URLs, internal issue/MR references) out of code, comments, and commit messages.
4
+
5
+ # @etamong-playground/ui
6
+
7
+ Shared frontend scaffold for a personal homelab app fleet. Ships the design-token contract
8
+ (`styles.css`), the **cmdk command palette** + discoverable trigger, Korean-IME-
9
+ safe go-to shortcuts, **toast + dialog** notification primitives, and the
10
+ **`DeployInfo`** build-version badge. Conventions: see the concepts sections below
11
+ (`frontend-conventions`, `design-system`, `command-palette`,
12
+ `app-notifications`, `build-version-info`).
13
+
14
+ Published to GitHub Packages; consumed by all app
15
+ frontends. **Current: v0.32.** Releasing + consuming are documented at the bottom.
16
+
17
+ Works in both house stacks — Next.js (React 19) and Vite + apiserver (React 18).
18
+ React/ReactDOM are peer deps.
19
+
20
+ ## What's in the box
21
+
22
+ | Export | Kind | What | Mount |
23
+ |---|---|---|---|
24
+ | `styles.css` | CSS | Design tokens (`--etu-*` namespaced; light/dark) + all component styles | Import once at the app root |
25
+ | `CommandPalette` | React component | The ⌘K palette: grouped sections, cross-locale keyword search, `adminOnly` filter, always-mounted search-actions row | Once, globally, when authenticated |
26
+ | `CommandPaletteTrigger` | React component | Discoverable "Search… ⌘K" search-box button (so users find the palette); dispatches `command-palette:open` | Sidebar / header |
27
+ | `useGoToShortcuts` | React hook | `g`-prefix two-key navigation, **Korean-IME-safe** (`e.code` fallback) | Call once where the palette mounts |
28
+ | `Toaster` | React component | Renders the toast queue (bottom-center) | Once at the app root |
29
+ | `toast(msg, kind?)` | function | Show a transient toast (`kind: "ok" \| "err" \| "info"`); returns id, dismissable | Anywhere |
30
+ | `DialogHost` | React component | Renders the pending `uiConfirm` / `uiPrompt` | Once at the app root |
31
+ | `uiConfirm(opts)` | Promise | Modal confirm; resolves `boolean` | Replaces `window.confirm` |
32
+ | `uiPrompt(opts)` | Promise | Modal text prompt; resolves `string \| null` | Replaces `window.prompt` |
33
+ | `DeployInfo` | React component | "deployed `<sha>` · `<rel time>`" badge; renders `null` when no build env | App-info section (settings / backoffice) — **not a footer** |
34
+ | `InstallBanner` | React component | Mobile-only PWA install banner. Real install button on Chrome/Android; "Share → 홈 화면에 추가" hint on iOS Safari; auto-hides when already installed | Once near the app root (same boundary as `<Toaster />`) |
35
+ | `useInstallPrompt()` | React hook | Lower-level — returns `{ canPrompt, promptInstall, isIOS, isStandalone }` for apps that want to render their own UI | Any client component |
36
+ | `StatusBanner` | React component | Top-of-app strip that polls `/.well-known/maintenance.json` and renders when service-admin declared a `degraded` / `maintenance` incident on the host (outage takes origin offline, no banner needed). Dismissible per session per incident | Once at the app root |
37
+ | `useStatusBanner()` | React hook | Lower-level — returns the parsed status JSON (`{ enabled, severity, message_ko, message_en, eta_iso, ... }`) for apps rendering their own UI | Any client component |
38
+ | `ErrorPage` | React component | Full-page friendly error surface; pairs with the httperr `ref` pattern, no raw error / repo links leak | Error boundary / Next.js `error.tsx` / 404 fallback route |
39
+ | `useRouteState` | React hook | In-page state slice synced with the URL query string (works with both regular and hash routers); restores on refresh, syncs with back/forward | Tabs, filters, sort, search term, expanded row id |
40
+ | `useSessionState` | React hook | Same shape as `useRouteState` but backed by `sessionStorage`, keyed per route | Scroll offset, cmdk query, unsubmitted form draft |
41
+ | `useInAppBack` | React hook | Tracks an in-app history stack via a marker in `history.state`; returns `{ canGoBack, goBack, push, replace }` | Once per app — wire to a back button + every in-app nav |
42
+ | `BackButton` | React component | Token-styled back button; mounts `useInAppBack` internally so `<BackButton fallback="/more" />` is the canonical one-liner. Renders when there's an in-app entry behind us OR `fallback`/`onClick` is set | Above page headings / detail views |
43
+ | `createFetch` | factory | `fetch` wrapper: JSON in/out, parses the httperr `{error, ref}` body into an `HttpError`, redirects to `oauth2-proxy` sign-in on 401 | Once per app — `const api = createFetch({ baseUrl: "/api" })` |
44
+ | `HttpError` | class | Thrown for every non-2xx; carries `status`, `ref`, `body` — drop `err.ref` into `<ErrorPage refCode={...}>` | `try { … } catch (e) { if (e instanceof HttpError) … }` |
45
+ | `useMe` | React hook | Fetches `/api/me` (or a custom `fetcher`), returns `{ me, loading, error, refresh }`; treats 401 as anonymous by default | Once near the app root |
46
+ | `signInUrl` / `signOutUrl` / `signIn` / `signOut` | functions | `oauth2-proxy` sign-in/out URL builders + navigation helpers | Login buttons, post-auth redirects |
47
+ | `EmptyState` | React component | "Nothing here yet" card; title + optional description / action / icon, `role="status"` | Empty lists, empty search results |
48
+ | `CopyButton` | React component | Token-styled copy button with success-state flip ("복사" → "복사됨"); fires a toast on success/error | Secret reveal, token / slug / ref copy |
49
+ | `useClipboard()` | React hook | Lower-level — `{ copied, copy(value) }`; clipboard API + legacy fallback | When you want to render your own copy UI |
50
+ | `registerServiceWorker(url, opts)` | function | Registers a service worker with the house update flow (aggressive `update()`, "새 버전" toast, auto-reload on `controllerchange`) | Once at app bootstrap, after window load |
51
+ | `networkFirstSwSource({ version, … })` | function | Returns the canonical online-first SW recipe as a string — write to `public/sw.js` at build time. Network-first nav + assets, never intercepts `/api/*`, versioned caches. **`version` MUST be a per-build identifier** (git SHA or build timestamp), not a hardcoded constant — see "PWA cache strategy" below | Build step (or served dynamically) |
52
+ | `installIOSPwaShell()` | function | Tags `<html>` with `etu-pwa-standalone` / `etu-ios-pwa` and re-locks `-webkit-text-size-adjust` in iOS PWA mode so Korean body text doesn't shrink in standalone launch. Opt-in `data-etu-lock-zoom` adds `maximum-scale=1` to the viewport meta to suppress input-focus zoom | Once at app bootstrap |
53
+ | `AdminGate` | React component | Renders `children` only when `me` passes `is_admin` / email allowlist / role / predicate (logical OR); otherwise renders `fallback` | Wrap any admin-only route or section |
54
+ | `AdminBadge` | React component | Small "관리자 전용" pill | Inline next to the page title |
55
+ | `BackofficeLayout` | React component | Page-head with title + AdminBadge + actions slot + body | Backoffice / admin-console route layout |
56
+ | `isAdminLike(input)` | function | The check behind `AdminGate` exposed as a pure function | Imperative gates / route guards |
57
+ | `AppInfoSection` | React component | Canonical "앱 정보" card — name, description, app version, build (`<DeployInfo>`), links, free-form rows | Settings / backoffice "About" route |
58
+ | `formatRelTime(when)` | function | `"3분 전"` / `"in 2 hours"` via `Intl.RelativeTimeFormat`; locale from the document | Lists, activity feeds, anywhere "ago" reads right |
59
+ | `formatAbsTime(when, opts?)` | function | Absolute time via `Intl.DateTimeFormat`; defaults to KST (`Asia/Seoul`) Korean. Style presets: `date` / `time` / `datetime` / `datetime-seconds` | Timestamps, log lines, tooltips |
60
+ | `RelTime` | React component | Auto-refreshing relative-time label (`<time dateTime>` with absolute time as `title`) | Anywhere `formatRelTime` would otherwise need re-renders |
61
+ | `UserMenu` | React component | Avatar trigger + dropdown with display name, "내 정보" link, "로그아웃". Renders "로그인" when `me` is null | Once in the app header (desktop + mobile) |
62
+ | `Avatar` | React component | Round profile picture; falls back to an initial letter on a token-colored circle | Stand-alone in lists, comments, etc. |
63
+ | `crossLocaleKeywords(dicts, getter)` | function | Build a cmdk `keywords` string that matches in ko AND en | Inline when defining items |
64
+ | `openCommandPalette()` | function | Dispatches the open event from anywhere | Custom triggers |
65
+ | `useGoToShortcuts` / `setTheme` / `getTheme` / `noFlashThemeScript` | helpers | Theme set/get + the `<head>` no-flash snippet for the `[data-theme]` dark convention | At/before first paint |
66
+
67
+ Helper-only entry (no React, safe for build-time / non-React runtimes):
68
+ `import { … } from "@etamong-playground/ui/helpers"` — re-exports
69
+ `crossLocaleKeywords`, `shortcutKey`, `noFlashThemeScript`, `getTheme`/`setTheme`,
70
+ `openCommandPalette`, `COMMAND_PALETTE_OPEN_EVENT`, `CODE_TO_KEY`.
71
+
72
+ ## Where to mount the hosts (Next vs Vite)
73
+
74
+ `<Toaster />`, `<DialogHost />`, and `<CommandPalette />` use React state and
75
+ event listeners — they need to live in a **client component**.
76
+
77
+ - **Vite** — just drop them in `main.tsx`/`App.tsx` (the whole app is client).
78
+ - **Next.js** — server layouts can't render them directly. Make a tiny client
79
+ wrapper and render *that* in `app/layout.tsx`:
80
+
81
+ ```tsx
82
+ // components/notifications.tsx
83
+ "use client";
84
+ import { Toaster, DialogHost } from "@etamong-playground/ui";
85
+ export function Notifications() { return (<><Toaster /><DialogHost /></>); }
86
+ ```
87
+
88
+ Then `<Notifications />` in the server-rendered root layout.
89
+
90
+ The `CommandPaletteTrigger` and `useGoToShortcuts` likewise need a client
91
+ boundary (they listen for keydown / dispatch events).
92
+
93
+ ## Install
94
+
95
+ Consumers resolve `@etamong-playground/*` from the GitHub Packages registry. In the app's
96
+ `.npmrc`:
97
+
98
+ ```
99
+ @etamong-playground:registry=https://npm.pkg.github.com/
100
+ ```
101
+
102
+ ```sh
103
+ pnpm add @etamong-playground/ui
104
+ ```
105
+
106
+ ## Design tokens
107
+
108
+ Import once at the app root:
109
+
110
+ ```ts
111
+ import "@etamong-playground/ui/styles.css";
112
+ ```
113
+
114
+ The command palette is styled from **namespaced `--etu-*` tokens** (light
115
+ defaults on `:root`, dark under either `[data-theme="dark"]` or the `.dark`
116
+ class) — deliberately prefixed so this file is safe to import into any app,
117
+ including shadcn/Tailwind apps that already own `--accent`/`--border`/`--ring`.
118
+ To theme the palette to your app, map a few `--etu-*` vars onto your own tokens:
119
+
120
+ ```css
121
+ /* shadcn app: */
122
+ :root, .dark {
123
+ --etu-surface: var(--popover);
124
+ --etu-border: var(--border);
125
+ --etu-text: var(--popover-foreground);
126
+ --etu-accent-soft: var(--accent);
127
+ }
128
+ ```
129
+
130
+ For apps using the `[data-theme]` dark-mode convention, set the theme before
131
+ first paint to avoid a flash:
132
+
133
+ ```ts
134
+ import { noFlashThemeScript } from "@etamong-playground/ui/helpers";
135
+ // Next: <script dangerouslySetInnerHTML={{ __html: noFlashThemeScript("myapp") }} />
136
+ // Vite: inline the same string in index.html <head>.
137
+ ```
138
+
139
+ `getTheme("myapp")` / `setTheme("myapp", "dark")` read and toggle it.
140
+
141
+ ## Command palette
142
+
143
+ Mount once, globally, when authenticated:
144
+
145
+ ```tsx
146
+ import { CommandPalette, crossLocaleKeywords } from "@etamong-playground/ui";
147
+ import { Home, Calendar } from "lucide-react";
148
+
149
+ const dicts = [ko, en];
150
+ const sections = [
151
+ {
152
+ id: "pages",
153
+ heading: t.palette.pages,
154
+ items: [
155
+ { id: "home", label: t.nav.home, icon: <Home size={16} />, href: "/",
156
+ keywords: crossLocaleKeywords(dicts, (d) => d.nav.home) },
157
+ { id: "schedules", label: t.nav.schedules, icon: <Calendar size={16} />,
158
+ href: "/schedules",
159
+ keywords: crossLocaleKeywords(dicts, (d) => d.nav.schedules) },
160
+ ],
161
+ },
162
+ ];
163
+
164
+ <CommandPalette sections={sections} isAdmin={isAdmin}
165
+ onNavigate={(href) => router.push(href)}
166
+ labels={{ placeholder: t.palette.placeholder, noResults: t.palette.noResults }} />
167
+ ```
168
+
169
+ Opens on ⌘K / Ctrl+K, on `/` (unless typing), and on the
170
+ `command-palette:open` DOM event (`openCommandPalette()`). `adminOnly` items are
171
+ hidden unless `isAdmin`. Search filters on `keywords` — build them with
172
+ `crossLocaleKeywords` so ko/en both match. Icons are your nodes; the package
173
+ pins no icon library.
174
+
175
+ ### Entities sections (load real content)
176
+
177
+ A nav-only palette returns "No results" when a user searches for their own
178
+ site/plan/vault by name. **Load the user's real objects** (sites, plans,
179
+ schedules, vaults) and add them as a data-driven section — each linking to its
180
+ detail route. This is part of the convention, not optional, for any app with a
181
+ list of named objects (see `concepts/command-palette`).
182
+
183
+ ```tsx
184
+ const sections = useMemo(() => {
185
+ const out: CommandSection[] = [navSection];
186
+ if (sites.length) {
187
+ out.push({
188
+ id: "sites",
189
+ heading: t.nav.sites,
190
+ items: sites.map((s) => ({
191
+ id: "site:" + s.slug, label: s.name, sublabel: s.slug,
192
+ keywords: `${s.name} ${s.slug}`,
193
+ onSelect: () => router.push(`/sites/${s.slug}`),
194
+ })),
195
+ });
196
+ }
197
+ return out;
198
+ }, [sites, t]);
199
+ ```
200
+
201
+ ### Search actions (catch-all "search for …" row)
202
+
203
+ `searchActions` is a bottom always-mounted group that receives the live query —
204
+ so an unmatched search still leads somewhere (a search/list route carrying the
205
+ text):
206
+
207
+ ```tsx
208
+ const searchActions: CommandSearchAction[] = [
209
+ { id: "search-all", label: t.palette.searchEverything,
210
+ run: (q) => router.push(`/search?q=${encodeURIComponent(q)}`) },
211
+ ];
212
+ <CommandPalette sections={sections} searchActions={searchActions} … />
213
+ ```
214
+
215
+ ## CommandPaletteTrigger
216
+
217
+ A token-styled "Search… ⌘K" search-box button — drop it in the sidebar or
218
+ header so users **discover** the palette. Clicking it dispatches
219
+ `command-palette:open`, no prop-drilling needed:
220
+
221
+ ```tsx
222
+ import { CommandPaletteTrigger } from "@etamong-playground/ui";
223
+
224
+ <CommandPaletteTrigger label={t.palette.search} />
225
+ ```
226
+
227
+ Shows a magnifier + the localized label + `⌘K` / `Ctrl+K` (auto-detected by
228
+ platform). The discoverable trigger is the difference between users finding the
229
+ palette vs. not — every multi-surface app should ship it.
230
+
231
+ ## Go-to shortcuts
232
+
233
+ Two-key navigation (`g` then a letter), Korean-IME-safe:
234
+
235
+ ```tsx
236
+ import { useGoToShortcuts } from "@etamong-playground/ui";
237
+
238
+ const pending = useGoToShortcuts(
239
+ [{ key: "h", href: "/" }, { key: "s", href: "/schedules" },
240
+ { key: "m", href: "/admin/members", adminOnly: true }],
241
+ (href) => router.push(href),
242
+ { isAdmin },
243
+ );
244
+ // render `pending` ("g" | null) as a small indicator
245
+ ```
246
+
247
+ ## Build
248
+
249
+ ```sh
250
+ pnpm install
251
+ pnpm build # tsup → dist (esm + cjs + d.ts), styles.css copied verbatim
252
+ pnpm typecheck
253
+ ```
254
+
255
+ CI runs `pnpm typecheck` + `pnpm build` on every pull request.
256
+
257
+ ## Notifications
258
+
259
+ Mount the hosts once at the app root (in Next, behind a `"use client"` wrapper):
260
+
261
+ ```tsx
262
+ import { Toaster, DialogHost, toast, uiConfirm, uiPrompt, dismissToast } from "@etamong-playground/ui";
263
+
264
+ // app root (client boundary in Next):
265
+ // <Toaster /> <DialogHost />
266
+
267
+ // Transient feedback — returns an id so you can dismiss early.
268
+ const id = toast("저장됐어요", "ok", 3000); // kind: "ok" | "err" | "info"
269
+ dismissToast(id);
270
+
271
+ // Modal confirm — resolves boolean. `danger` styles the confirm red.
272
+ if (await uiConfirm({
273
+ title: "삭제할까요?",
274
+ body: "되돌릴 수 없어요.",
275
+ confirmLabel: "삭제", cancelLabel: "취소", danger: true,
276
+ })) { /* … */ }
277
+
278
+ // Modal text prompt — resolves string | null (null on cancel).
279
+ const name = await uiPrompt({
280
+ title: "이름",
281
+ placeholder: "내 일정",
282
+ defaultValue: "내 일정",
283
+ confirmLabel: "만들기",
284
+ });
285
+ ```
286
+
287
+ `uiConfirm` / `uiPrompt` are promise-based — drop-in replacements for
288
+ `window.confirm` / `window.prompt`. An app with its own local `(title, opts)`
289
+ helpers can keep them as thin adapters that delegate to these (see festplan's
290
+ `uiConfirm(title, opts)` adapter).
291
+
292
+ `<Toaster />` and `<DialogHost />` are **singleton hosts** — mount each exactly
293
+ once at the root. The functions (`toast`, `uiConfirm`, `uiPrompt`) talk to the
294
+ mounted host via a module-level pub/sub, so call them from anywhere.
295
+
296
+ ## DeployInfo (build-version badge)
297
+
298
+ ```tsx
299
+ import { DeployInfo } from "@etamong-playground/ui";
300
+
301
+ // In an "앱 정보 / App info" section of /settings (preferred) or the backoffice:
302
+ <section>
303
+ <h2>{t.settings.appInfo}</h2>
304
+ <DeployInfo
305
+ version={import.meta.env.VITE_BUILD_SHA} // Vite
306
+ builtAt={import.meta.env.VITE_BUILD_TIME}
307
+ label={t.settings.deployedAt}
308
+ />
309
+ </section>
310
+ // Next: process.env.NEXT_PUBLIC_BUILD_SHA / _BUILD_TIME
311
+ ```
312
+
313
+ Shows `deployed <sha> · <relative time>` (absolute timestamp in the tooltip);
314
+ renders **`null`** when neither value is set, so it's safe to mount
315
+ unconditionally — local dev shows nothing.
316
+
317
+ **Placement** — **settings → 앱 정보** if the app has a settings page; otherwise
318
+ the **backoffice / console**. Apps with neither (a small dashboard-only app like
319
+ minccino) get a small `/about` page linked from the account area. **Not a
320
+ global footer** — that was the first pass, reworked per user feedback. For
321
+ labeled rows (버전 / 배포 시각) instead of the compact badge, see the in-app
322
+ implementations (res-train `/settings`, festplan `#/settings`, pages admin
323
+ Access view). Baking the build env in CI: see `concepts/build-version-info`.
324
+
325
+ ## InstallBanner (PWA install)
326
+
327
+ Mobile-only dismissable banner that does the right thing per platform:
328
+
329
+ ```tsx
330
+ import { InstallBanner } from "@etamong-playground/ui";
331
+
332
+ // Once near the app root (same boundary as <Toaster />):
333
+ <InstallBanner
334
+ label={t.install.hint} // "홈 화면에 추가하면 더 빠르게!"
335
+ iosHint={t.install.iosHint} // "공유 → 홈 화면에 추가"
336
+ installLabel={t.install.cta} // "설치"
337
+ storageKey="myapp-install-banner" // per-app, to avoid clashes
338
+ />
339
+ ```
340
+
341
+ - **Chrome / Android** — captures `beforeinstallprompt`, shows an install
342
+ button that fires the real native prompt.
343
+ - **iOS Safari** — no programmatic install. Shows a short
344
+ "Share → Add to Home Screen" hint instead.
345
+ - **Already installed** (`display-mode: standalone`) — renders nothing.
346
+ - **Dismiss + cooldown** — clicking the close button hides the banner for 3
347
+ days; gives up after 3 dismissals. Override with `cooldownMs` / `maxDismiss`.
348
+ - Hidden on `min-width: 768px`. For desktop, drop a small button using
349
+ `useInstallPrompt()` instead.
350
+
351
+ This is the `concepts/spa-navigation-state` rule's PWA-install requirement
352
+ packaged once — apps drop the component in, no per-app
353
+ `beforeinstallprompt` / iOS-detection boilerplate to maintain.
354
+
355
+ ```tsx
356
+ // Lower-level hook if you want to render your own UI:
357
+ const { canPrompt, promptInstall, isIOS, isStandalone } = useInstallPrompt();
358
+ ```
359
+
360
+ ## StatusBanner (service-admin declared-incident strip)
361
+
362
+ Sticky top-of-app strip that surfaces operator-declared incidents and downtime
363
+ windows. Polls the same-origin `/.well-known/maintenance.json` endpoint
364
+ served on every routed host — no app-side wiring, no API client to maintain.
365
+
366
+ ```tsx
367
+ import { StatusBanner } from "@etamong-playground/ui";
368
+
369
+ // Once at the app root, alongside <Toaster /> / <InstallBanner />:
370
+ <StatusBanner />
371
+ ```
372
+
373
+ - **Renders only for `degraded` / `maintenance`.** `outage` incidents take the
374
+ origin offline and serve a 503 maintenance page directly — the banner has
375
+ nothing to do in that case.
376
+ - **Language** is auto-picked from `document.documentElement.lang` (`ko` →
377
+ Korean, else English). Override with `lang="ko" | "en"`.
378
+ - **Session-dismissable** per `(severity, updated_at)`: closing the banner
379
+ hides it for the session, but a fresh incident (different severity or
380
+ updated_at) reappears. Pass `dismissible={false}` to disable.
381
+ - **ETA + message** rendered when the operator set them; falls back to the
382
+ other-language copy if one side is empty.
383
+ - **Renders `null`** while loading / on endpoint error / when not enabled —
384
+ safe to mount unconditionally.
385
+ - **Polls** every 60s by default (the endpoint sends `cache-control:
386
+ public, max-age=30`, so 60s guarantees a fresh value between polls). Pauses
387
+ while `document.hidden`, immediately re-fetches on visibility return.
388
+
389
+ The endpoint contract is documented in the status-hub admin app's README under "Status-hub contracts".
390
+
391
+ ```tsx
392
+ // Lower-level hook if you want to render your own UI (header pill,
393
+ // notifications page entry, etc.):
394
+ const status = useStatusBanner();
395
+ if (status?.enabled && status.severity !== "outage") {
396
+ // status: { enabled, severity, message_ko, message_en, eta_iso,
397
+ // retry_after_seconds, tags, updated_at }
398
+ }
399
+ ```
400
+
401
+ ## ErrorPage
402
+
403
+ Friendly full-page error surface. Pairs with the `httperr` `ref` pattern (see
404
+ `concepts/user-facing-error-messages`): show the clean message + the 8-hex
405
+ reference code, never the raw error / stack trace / repo path.
406
+
407
+ ```tsx
408
+ import { ErrorPage } from "@etamong-playground/ui";
409
+
410
+ // Next.js error.tsx (per-route error boundary):
411
+ "use client";
412
+ export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
413
+ return (
414
+ <ErrorPage
415
+ title="문제가 발생했어요"
416
+ description="잠시 후 다시 시도해 주세요."
417
+ refCode={error.digest} // or whatever ref your backend returns
418
+ onRetry={reset}
419
+ onHome={() => location.assign("/")}
420
+ />
421
+ );
422
+ }
423
+ ```
424
+
425
+ ```tsx
426
+ // Vite + React Router 404 / catch-all:
427
+ <Route path="*" element={
428
+ <ErrorPage
429
+ title="페이지를 찾을 수 없어요"
430
+ description="주소를 다시 확인해 주세요."
431
+ onHome={() => navigate("/")}
432
+ />
433
+ } />
434
+ ```
435
+
436
+ Props:
437
+
438
+ - `title`, `description` — Korean defaults; override per-route.
439
+ - `refCode` — the 8-hex `ref` from your backend (`httperr` produces this).
440
+ Shown discreetly under the actions so the user can quote it.
441
+ - `onRetry`, `onHome` — optional handlers. Render their buttons only when set.
442
+ - `labels` — override the `retry` / `home` / `refLabel` strings (defaults are
443
+ Korean).
444
+ - `icon` — replace the default circle-alert glyph with your own node.
445
+
446
+ The component is token-styled (`--etu-*`), so it inherits the app's dark/light
447
+ theme automatically. It contains **no repo links**, no file paths, no stack
448
+ traces — by design (`concepts/no-repo-exposure`).
449
+
450
+ ## useRouteState / useSessionState
451
+
452
+ Two hooks for the "F5 keeps me on this view, with the same tab/filter/sort
453
+ selected" half of the SPA navigation contract
454
+ (`concepts/spa-navigation-state`). Router-agnostic — they read and write
455
+ `window.history` directly, so they work with hash routers, path routers,
456
+ and apps without a router lib at all.
457
+
458
+ ```tsx
459
+ import { useRouteState, useSessionState } from "@etamong-playground/ui";
460
+
461
+ // URL-backed: ends up in the query string (?tab=members), restores on
462
+ // refresh, syncs with browser back/forward.
463
+ const [tab, setTab] = useRouteState<"overview" | "deploys" | "members">("tab", "overview");
464
+
465
+ // Pretty URL — pass plain string codecs so the value isn't JSON-quoted:
466
+ const [tab2, setTab2] = useRouteState("tab", "overview", {
467
+ serialize: (v) => v,
468
+ deserialize: (raw) => raw as typeof tab,
469
+ });
470
+
471
+ // sessionStorage-backed: never enters the URL, scoped per route by default.
472
+ const [draft, setDraft] = useSessionState("draft", "");
473
+ const [scroll, setScroll] = useSessionState("scrollY", 0);
474
+ ```
475
+
476
+ Both hooks have the same `[value, setValue]` shape as `useState`, including
477
+ the functional updater form (`setTab(prev => prev === "a" ? "b" : "a")`).
478
+
479
+ Options:
480
+
481
+ - **`serialize` / `deserialize`** — defaults to `JSON.stringify` /
482
+ `JSON.parse`, so booleans, numbers, and arrays round-trip without extra
483
+ work. Override for cleaner URLs.
484
+ - **`replace`** (`useRouteState` only) — defaults to `true`, so noisy state
485
+ like search-as-you-type doesn't pile up in the back history. Set
486
+ `replace: false` when each change *should* be a back-button stop.
487
+ - **`scope`** (`useSessionState` only) — overrides the default per-route
488
+ scope (`pathname + hash`). Pass a static string for state that should
489
+ span routes.
490
+
491
+ The hooks listen on `popstate` and `hashchange`, plus a private
492
+ `etu:route-state` event they fire after their own writes — so multiple
493
+ components reading the same key stay in sync.
494
+
495
+ SSR-safe: on the server they return the `initial` value; the URL/session
496
+ read happens on mount in an effect.
497
+
498
+ ## useInAppBack / BackButton
499
+
500
+ The other half of the SPA navigation contract: the back button — both the
501
+ browser's and your in-UI one — should stay inside the app.
502
+
503
+ `useInAppBack` tracks an in-app history stack by writing a marker into
504
+ `history.state` on every in-app navigation. `canGoBack` is true when at
505
+ least one in-app entry sits behind the current one; `goBack()` calls
506
+ `history.back()` when true, otherwise it runs the `fallback` so
507
+ cold-entry users (someone landed on a deep link from outside) still go
508
+ somewhere sensible.
509
+
510
+ The canonical one-liner (v0.27.0+) — `<BackButton>` mounts the hook
511
+ internally, so apps that don't need the hook's values elsewhere just
512
+ drop in:
513
+
514
+ ```tsx
515
+ import { BackButton } from "@etamong-playground/ui";
516
+
517
+ // Hash/path-routed apps — string fallback (pushState + popstate)
518
+ <BackButton fallback="/more" />
519
+
520
+ // Next.js / React Router — pass the router action as a callback
521
+ <BackButton fallback={() => router.push("/more")} />
522
+ ```
523
+
524
+ Mount the hook explicitly when you need the values somewhere besides the
525
+ button (e.g. swipe gesture, keyboard shortcut, custom layout):
526
+
527
+ ```tsx
528
+ import { useInAppBack, BackButton } from "@etamong-playground/ui";
529
+
530
+ function App() {
531
+ const back = useInAppBack({ fallback: "/more" });
532
+
533
+ function openSite(slug: string) {
534
+ back.push(`#/sites/${slug}`); // grows the in-app stack
535
+ }
536
+ function changeTab(tab: string) {
537
+ back.replace(`#/sites/foo/${tab}`); // does NOT grow the stack
538
+ }
539
+
540
+ return (
541
+ <>
542
+ <BackButton {...back} />
543
+ {/* …rest of the app */}
544
+ </>
545
+ );
546
+ }
547
+ ```
548
+
549
+ Notes:
550
+
551
+ - The hook is router-agnostic — it reads and writes `window.history`
552
+ directly. Wire it through your router's `push`/`replace` or use the
553
+ hook's own `push`/`replace` helpers.
554
+ - Pairs cleanly with `useRouteState`, which uses `replaceState`. URL-
555
+ synced in-page state (tab, filter) doesn't grow the back stack.
556
+ - The first mount marks the current entry as in-app at depth `0`, so any
557
+ later `push()` has a baseline to count from. Browser back across a
558
+ push restores the marker; a hard reload starts fresh from `0`
559
+ (correct: the page IS the entry point).
560
+ - `<BackButton>` renders when there's an in-app entry behind us OR when
561
+ `fallback`/`onClick` is set OR `alwaysShow` is on. Default label:
562
+ "뒤로"; override via `label`.
563
+ - The `onExit` option on `useInAppBack` (from v0.8.0) is `@deprecated`
564
+ in favour of `fallback`. It still works — calls go through the same
565
+ code path — but the JSDoc nudges new code toward `fallback`.
566
+
567
+ ## createFetch / HttpError
568
+
569
+ A small `fetch` wrapper that bakes in the house conventions:
570
+ the httperr JSON shape (`{error, ref}`),
571
+ sign-in redirect on 401, JSON in / JSON out by default.
572
+
573
+ ```ts
574
+ import { createFetch, HttpError } from "@etamong-playground/ui";
575
+
576
+ export const api = createFetch({ baseUrl: "/api" });
577
+
578
+ // Then anywhere in the app:
579
+ const me = await api.get<{ email: string; is_admin: boolean }>("/me");
580
+ const created = await api.post<Site>("/sites", { name: "blog", visibility: "public" });
581
+ const list = await api.get<Site[]>("/sites", { query: { q: "blog" } });
582
+ ```
583
+
584
+ On a non-2xx response, the wrapper throws an `HttpError` that carries the
585
+ server's `ref` code. Drop it into `<ErrorPage>`:
586
+
587
+ ```tsx
588
+ try {
589
+ await api.post("/sites", payload);
590
+ } catch (e) {
591
+ if (e instanceof HttpError) {
592
+ return <ErrorPage description={e.message} refCode={e.ref} onRetry={retry} />;
593
+ }
594
+ throw e;
595
+ }
596
+ ```
597
+
598
+ Options:
599
+
600
+ - **`baseUrl`** — prepended to relative paths.
601
+ - **`onAuthError`** — called on 401. Default: redirects to
602
+ `/oauth2/start?rd=<current url>` (the `oauth2-proxy` sign-in flow).
603
+ Pass `() => {}` to disable.
604
+ - **`onError`** — fires for every non-2xx after the error is built but
605
+ before it's thrown. Use for telemetry / global toast; doesn't suppress
606
+ the throw.
607
+ - **`headers`** — static object or factory. Common case: an
608
+ `Authorization` header for non-browser callers (CLI / cron).
609
+ - **`fetchImpl`** — override the global `fetch` (tests / SSR).
610
+
611
+ Per-call options on every method: `query` (object → query string),
612
+ `headers`, `signal` (AbortController), `raw: true` (return the raw
613
+ `Response` without JSON parsing — for downloads / streaming).
614
+
615
+ The wrapper:
616
+
617
+ - sets `Accept: application/json` and `credentials: "same-origin"` by
618
+ default (works with cookie-based browser sessions);
619
+ - serializes plain-object bodies to JSON and sets `Content-Type:
620
+ application/json`; passes `FormData` / `Blob` / strings through
621
+ untouched;
622
+ - handles 204 / empty responses (resolves `undefined`);
623
+ - returns `Response` directly when `raw: true`.
624
+
625
+ ## useMe + sign-in / sign-out helpers
626
+
627
+ Apps in the fleet sit behind `oauth2-proxy` and expose a small
628
+ `/me` endpoint with the authenticated identity. This hook + the URL
629
+ helpers cover the repeated wiring.
630
+
631
+ ```tsx
632
+ import { useMe, signIn, signOut, type BaseMe } from "@etamong-playground/ui";
633
+
634
+ // App-specific shape — extends BaseMe ({ email, preferred_username?,
635
+ // is_admin?, roles? }).
636
+ interface Me extends BaseMe {
637
+ can_create_apps?: boolean;
638
+ }
639
+
640
+ function Header() {
641
+ const { me, loading, error } = useMe<Me>();
642
+ if (loading) return null;
643
+ if (error || !me) return <button onClick={() => signIn()}>로그인</button>;
644
+ return (
645
+ <div>
646
+ {me.preferred_username ?? me.email}
647
+ {me.is_admin && <span className="badge">관리자</span>}
648
+ <button onClick={() => signOut("/")}>로그아웃</button>
649
+ </div>
650
+ );
651
+ }
652
+ ```
653
+
654
+ Options:
655
+
656
+ - **`endpoint`** — default `/api/me`. Ignored when `fetcher` is set.
657
+ - **`fetcher`** — pass `() => api.get<Me>("/me")` when the app's API
658
+ base path differs from the default, or to inherit `createFetch`'s
659
+ error handling.
660
+ - **`treat401AsAnonymous`** — default `true`. 401 from the default
661
+ fetcher resolves to `me: null, error: null`. Set `false` if your app
662
+ considers an unauthenticated user a hard error. Ignored with a custom
663
+ `fetcher`.
664
+
665
+ `refresh()` fires an `etu:me-refresh` event so multiple `useMe`
666
+ consumers re-fetch together (e.g. after a token-add flow flips
667
+ `can_create_apps`).
668
+
669
+ The URL helpers follow the oauth2-proxy convention:
670
+
671
+ - `signInUrl(rd?)` → `/oauth2/start?rd=<encoded>` (default `rd` = current URL).
672
+ - `signOutUrl(rd?)` → `/oauth2/sign_out?rd=<encoded>` (default `rd` = `/`).
673
+ - `signIn(rd?)` / `signOut(rd?)` navigate the browser to those URLs.
674
+
675
+ ## EmptyState
676
+
677
+ The "nothing here yet" card. Every list / grid view has one; this is
678
+ the single one to use.
679
+
680
+ ```tsx
681
+ import { EmptyState } from "@etamong-playground/ui";
682
+
683
+ <EmptyState
684
+ title="아직 사이트가 없어요"
685
+ description="새 사이트를 만들어 시작해 보세요."
686
+ action={<button className="cta" onClick={onNew}>새 사이트</button>}
687
+ />
688
+
689
+ // Compact variant for sidebar / inline use:
690
+ <EmptyState compact title="결과 없음" description="검색어를 바꿔 보세요." />
691
+ ```
692
+
693
+ Props:
694
+
695
+ - `title` — required headline.
696
+ - `description` — optional one-line description (ReactNode).
697
+ - `action` — optional CTA / footnote node.
698
+ - `icon` — replace the default cube glyph; pass `null` to omit.
699
+ - `compact` — smaller padding + smaller type.
700
+
701
+ Marked `role="status"` for screen readers.
702
+
703
+ ## CopyButton + useClipboard
704
+
705
+ For the secret-reveal / token-copy / slug-copy / ref-copy moments.
706
+ Pairs with the package's `toast()` for the "복사됨" confirmation, and
707
+ falls back to a hidden `<textarea>` + `document.execCommand("copy")`
708
+ when `navigator.clipboard` isn't available (non-https / older mobile).
709
+
710
+ ```tsx
711
+ import { CopyButton, useClipboard } from "@etamong-playground/ui";
712
+
713
+ // Standard text button:
714
+ <CopyButton value={token} />
715
+
716
+ // Icon-only, sitting next to a value display:
717
+ <code>{slug}</code> <CopyButton value={slug} iconOnly />
718
+
719
+ // Custom UI — useClipboard returns the state machine:
720
+ function MyButton({ value }) {
721
+ const { copied, copy } = useClipboard();
722
+ return (
723
+ <button onClick={() => copy(value)}>
724
+ {copied ? "✓ 복사됨" : "복사"}
725
+ </button>
726
+ );
727
+ }
728
+ ```
729
+
730
+ `<CopyButton>` props:
731
+
732
+ - `value` — required string to copy.
733
+ - `label` / `successLabel` — default `"복사"` / `"복사됨"`.
734
+ - `iconOnly` — render just the icon (default: icon + label).
735
+ - `icon` — override the default copy/check glyph; pass `null` to omit.
736
+ - `ariaLabel` — used when `iconOnly`; defaults to `label`.
737
+ - `resetMs` — how long the copied state lingers. Default `1500`.
738
+ - `toastOnSuccess` / `toastOnError` — toast text; pass `null` to suppress.
739
+
740
+ ## Service worker (registration + online-first SW recipe)
741
+
742
+ Two pieces for the planning `concepts/pwa-service-worker` rule: a
743
+ registration helper for the app, and a generator for the SW file itself.
744
+ The preset is biased toward **online users see the latest build** — the
745
+ cache is only the offline safety net.
746
+
747
+ ### registerServiceWorker
748
+
749
+ ```ts
750
+ import { registerServiceWorker } from "@etamong-playground/ui";
751
+
752
+ const sw = registerServiceWorker("/sw.js", {
753
+ // Default behavior: toast says "새 버전이 준비됐어요. 새로고침할까요?"
754
+ // and the next nav uses the new SW. autoReloadOnUpdate: true skips the
755
+ // prompt and reloads as soon as the new SW is ready.
756
+ });
757
+ ```
758
+
759
+ What the helper does on top of `navigator.serviceWorker.register`:
760
+
761
+ - Calls `registration.update()` aggressively — on load, on
762
+ `visibilitychange` → visible, and on a 2-minute interval — so a
763
+ long-lived installed tab catches a new deploy without a manual reload.
764
+ - Listens for `controllerchange` and reloads the page **once** when the
765
+ new SW takes over. `onActivate` fires right before the reload so the
766
+ app can persist transient state.
767
+ - When a new SW finishes installing and is waiting, shows the "새 버전"
768
+ toast. The waiting SW is activated when the user navigates / reloads,
769
+ or you can call `sw.applyUpdate()` to do it programmatically.
770
+
771
+ Returns a handle: `{ registration, hasUpdate, applyUpdate,
772
+ checkForUpdate, unregister }`.
773
+
774
+ ### networkFirstSwSource
775
+
776
+ Generates the SW file itself. Use it from a build step so the
777
+ `version` is the build SHA:
778
+
779
+ ```ts
780
+ // build.mjs
781
+ import { networkFirstSwSource } from "@etamong-playground/ui";
782
+ import { writeFile } from "node:fs/promises";
783
+
784
+ const sha = process.env.BUILD_SHA ?? Date.now().toString(36);
785
+ await writeFile(
786
+ "public/sw.js",
787
+ networkFirstSwSource({
788
+ version: sha,
789
+ networkTimeoutMs: 3000,
790
+ passThroughPrefixes: ["/oauth2/", "/sse/"],
791
+ }),
792
+ );
793
+ ```
794
+
795
+ What the recipe does:
796
+
797
+ - **Never intercepts** non-GET, cross-origin requests, or any URL whose
798
+ path starts with `/api/` or one of `passThroughPrefixes`. Auth and
799
+ live state always hit the network.
800
+ - **Navigations** (`request.mode === "navigate"`): network-first with
801
+ `networkTimeoutMs` (default 3s). If the network wins, the response is
802
+ cached + returned. If the network times out (offline / flaky), the
803
+ cached copy is served.
804
+ - **Same-origin GET assets**: same network-first strategy — fresh wins
805
+ online, cache covers offline.
806
+ - Caches are versioned (`etu-nav-<version>` / `etu-asset-<version>`); on
807
+ `activate` everything else is deleted, then `clients.claim()`.
808
+ - `skipWaiting()` on install + a `SKIP_WAITING` message handler so
809
+ `sw.applyUpdate()` can force the takeover.
810
+
811
+ When **not** to use the preset:
812
+
813
+ - The shortener single-segment-route family (`/{code}` reaches the
814
+ apiserver, not the SPA) — keep the bespoke `navigateFallbackAllowlist`
815
+ recipe in `concepts/pwa-service-worker`.
816
+ - Push-only SWs (schedule-manager) — no caching at all.
817
+ - Scoped stale-while-revalidate of specific safe-read endpoints
818
+ (minccino) — narrower than this preset; keep the hand-rolled regex.
819
+
820
+ ### PWA cache strategy (fleet rule)
821
+
822
+ The user-visible symptom of getting this wrong: "even after deploy, the app
823
+ keeps showing the old screen until I force-reload". The cure is **online
824
+ users always see the latest deploy; the cache is only the offline fallback**,
825
+ keyed by a build identifier so every deploy rolls the cache forward.
826
+
827
+ Two things every app must do:
828
+
829
+ 1. **Use `networkFirstSwSource()`** (or document why workbox precaching is
830
+ required — and if so, gate the workbox `revision`/`cacheNames` per build
831
+ as well).
832
+ 2. **Pass a per-build `version`** — never a hardcoded constant. The cache is
833
+ versioned by `etu-nav-<version>` / `etu-asset-<version>`; on activate the
834
+ SW deletes every cache that doesn't match. If `version` never changes,
835
+ the cache never rolls over and offline-cached HTML/JS stays sticky.
836
+
837
+ A small Vite snippet that injects the git SHA at build time:
838
+
839
+ ```ts
840
+ // vite.config.ts
841
+ import { execSync } from "node:child_process";
842
+ const sha = (() => {
843
+ try { return execSync("git rev-parse --short HEAD").toString().trim(); }
844
+ catch { return Date.now().toString(36); }
845
+ })();
846
+
847
+ export default defineConfig({
848
+ define: { "import.meta.env.VITE_BUILD_SHA": JSON.stringify(sha) },
849
+ // …
850
+ });
851
+ ```
852
+
853
+ Then in a build hook:
854
+
855
+ ```ts
856
+ import { networkFirstSwSource } from "@etamong-playground/ui";
857
+ await writeFile(
858
+ "public/sw.js",
859
+ networkFirstSwSource({ version: process.env.VITE_BUILD_SHA ?? sha }),
860
+ );
861
+ ```
862
+
863
+ Apps using `vite-plugin-pwa` (festplan) get workbox `autoUpdate` by default,
864
+ which is correct in theory but historically has shipped with hardcoded
865
+ precache revisions that don't roll over per build. Verify the workbox
866
+ config either (a) injects the SHA into the precache manifest, or (b) move
867
+ the app to `networkFirstSwSource()`. Either is acceptable; both must be
868
+ per-build versioned.
869
+
870
+ The canonical strategy is described in the `concepts/pwa-cache-and-ios-shell` design document.
871
+
872
+ ## iOS PWA shell (`installIOSPwaShell`)
873
+
874
+ The complaint pattern: install an etamong app to the iPhone home screen,
875
+ launch it from there, and Korean body text looks "broken" / shrunk vs.
876
+ Safari. iOS's automatic text-size-adjust kicks in in standalone mode
877
+ because the Safari toolbar reservation is gone.
878
+
879
+ The `styles.css` reset already locks `-webkit-text-size-adjust: 100%`
880
+ on `html`. `installIOSPwaShell()` is the runtime belt-and-braces — call it
881
+ once from your app bootstrap:
882
+
883
+ ```ts
884
+ import { installIOSPwaShell } from "@etamong-playground/ui";
885
+ installIOSPwaShell();
886
+ ```
887
+
888
+ What it does:
889
+
890
+ - Detects standalone (`navigator.standalone === true` OR
891
+ `matchMedia('(display-mode: standalone)').matches`).
892
+ - Adds `html.etu-pwa-standalone`; if also iOS, adds `html.etu-ios-pwa`.
893
+ - Re-asserts the text-size-adjust lock via an inline style on `<html>`
894
+ (defense against a late-mounted stylesheet that clobbers the reset).
895
+ - Opt-in: if your `<html>` has `data-etu-lock-zoom`, also appends
896
+ `maximum-scale=1` to the viewport meta — kills the input-focus auto-zoom.
897
+ Off by default because it also blocks accessibility zoom.
898
+
899
+ Apps that already follow `concepts/ios-pwa-safe-area` (viewport
900
+ `viewport-fit=cover`, `apple-mobile-web-app-*` metas, safe-area padding on
901
+ fixed bars) keep doing that — this helper is additive, just guarantees the
902
+ font lock holds.
903
+
904
+ ## Backoffice scaffold (AdminGate + AdminBadge + BackofficeLayout)
905
+
906
+ The admin-gate + 관리자 전용 badge + page-head layout every backoffice
907
+ route in the fleet re-implements. Pairs with `useMe<T>` — pass the `me`
908
+ straight through.
909
+
910
+ ```tsx
911
+ import { useMe, AdminGate, BackofficeLayout } from "@etamong-playground/ui";
912
+
913
+ interface Me extends BaseMe { can_create_apps?: boolean }
914
+
915
+ function Console() {
916
+ const { me } = useMe<Me>();
917
+ return (
918
+ <AdminGate
919
+ me={me}
920
+ emails={["admin@example.com", "ops@example.com"]}
921
+ predicate={(m) => m.can_create_apps === true}
922
+ fallback={<div>권한이 없어요.</div>}
923
+ >
924
+ <BackofficeLayout
925
+ title="앱 콘솔"
926
+ description="앱 생성·배포·롤백을 관리합니다."
927
+ actions={<button onClick={onNewApp}>새 앱</button>}
928
+ >
929
+ <AppList />
930
+ </BackofficeLayout>
931
+ </AdminGate>
932
+ );
933
+ }
934
+ ```
935
+
936
+ The gate is a **logical OR** across these signals:
937
+
938
+ - `me.is_admin === true` (always counts; no config needed).
939
+ - `emails` — case-insensitive allowlist; useful for the LLM
940
+ prompt-audit consoles where the admin set isn't expressed in the IdP.
941
+ - `roles` — if `me.roles` intersects this set.
942
+ - `predicate(me)` — app-specific flag (`can_create_apps`, etc.).
943
+
944
+ If you need the boolean without the wrapping component:
945
+
946
+ ```ts
947
+ import { isAdminLike } from "@etamong-playground/ui";
948
+ if (isAdminLike({ me, emails: ADMIN_EMAILS })) router.push("/admin");
949
+ ```
950
+
951
+ `<BackofficeLayout>` renders the `<AdminBadge>` next to the title by
952
+ default. Pass `badge={null}` to hide it, or `badge={<CustomBadge />}` to
953
+ override.
954
+
955
+ ## AppInfoSection (canonical "앱 정보" card)
956
+
957
+ The standing placement rule: app version and release time belong in
958
+ `/settings` or the backoffice "About" route, not in a page footer. This
959
+ component is the canonical layout — wraps `<DeployInfo>` so apps stop
960
+ hand-rolling that placement.
961
+
962
+ ```tsx
963
+ import { AppInfoSection } from "@etamong-playground/ui";
964
+
965
+ <AppInfoSection
966
+ name="schedule-manager"
967
+ description="회의실 예약 관리 시스템"
968
+ icon={<img src="/icon.svg" alt="" />}
969
+ appVersion="1.4.2"
970
+ version={BUILD_SHA}
971
+ builtAt={BUILT_AT}
972
+ links={[
973
+ { label: "도움말", href: "/help" },
974
+ { label: "이용약관", href: "/terms" },
975
+ { label: "개인정보처리방침", href: "/privacy" },
976
+ ]}
977
+ >
978
+ {/* Free-form rows after the standard ones */}
979
+ <div className="etu-app-info-row">
980
+ <dt>Plan</dt>
981
+ <dd>Pro</dd>
982
+ </div>
983
+ </AppInfoSection>
984
+ ```
985
+
986
+ Props:
987
+
988
+ - `name` / `description` / `icon` — identity block (top of the card).
989
+ - `appVersion` — the semver shown as the "버전" row (typically your
990
+ `package.json` `version`).
991
+ - `version` / `builtAt` — forwarded to `<DeployInfo>` for the "빌드" row
992
+ (shows `deployed <sha7> · <rel time>`).
993
+ - `links` — link row at the bottom; external URLs open in a new tab.
994
+ - `children` — free-form rows inside the `<dl>` — wrap each in a
995
+ `<div className="etu-app-info-row">` for consistent two-column
996
+ layout.
997
+ - `heading` — default `"앱 정보"`. Pass `null` to omit.
998
+
999
+ Both the identity block and the `<dl>` rows are conditional: if you
1000
+ only pass `version`/`builtAt`, the card collapses to just the build
1001
+ row.
1002
+
1003
+ ## Time helpers (formatRelTime / formatAbsTime / RelTime)
1004
+
1005
+ The relative-time + KST-absolute formatting that used to live inside
1006
+ `<DeployInfo>`, pulled out and shared. Apps stop reinventing
1007
+ `"3분 전"` / KST conversion.
1008
+
1009
+ ```tsx
1010
+ import { formatRelTime, formatAbsTime, RelTime } from "@etamong-playground/ui";
1011
+
1012
+ formatRelTime("2026-06-13T03:29:00Z");
1013
+ // → "3분 전" (when locale defaults to ko)
1014
+
1015
+ formatAbsTime("2026-06-13T03:29:00Z");
1016
+ // → "2026. 06. 13. 12:29" (KST default)
1017
+
1018
+ formatAbsTime(Date.now(), { withZoneSuffix: true });
1019
+ // → "2026. 06. 13. 12:34 KST"
1020
+
1021
+ // Self-refreshing relative label — `<time dateTime>` with the absolute
1022
+ // time in the `title` so hover reveals the exact KST timestamp.
1023
+ <RelTime when={item.createdAt} />
1024
+ ```
1025
+
1026
+ `formatRelTime` options:
1027
+
1028
+ - `locale` — defaults to the document/browser default. Pass `"ko"` /
1029
+ `"en"` to force.
1030
+ - `numeric` — default `"auto"` (gives `"어제"` / `"yesterday"` instead
1031
+ of `"1 day ago"`).
1032
+ - `now` — reference time for "now"; defaults to `Date.now()`.
1033
+
1034
+ `formatAbsTime` options:
1035
+
1036
+ - `timeZone` — default `"Asia/Seoul"`.
1037
+ - `locale` — default `"ko-KR"`.
1038
+ - `style` — preset (`"date"`, `"time"`, `"datetime"`,
1039
+ `"datetime-seconds"`); ignored when `formatOptions` is set.
1040
+ - `formatOptions` — raw `Intl.DateTimeFormatOptions` for full control.
1041
+ - `withZoneSuffix` — appends ` KST` (or the literal zone string for
1042
+ non-default zones).
1043
+
1044
+ `<RelTime>` refresh cadence is "auto": every 15 s under a minute, every
1045
+ minute under an hour, every 10 minutes beyond. The title (absolute
1046
+ time) stays in sync because it's derived in the same render.
1047
+
1048
+ Invalid timestamps (bad ISO, `undefined`) render to empty strings —
1049
+ safe to use directly on partial data.
1050
+
1051
+ ## UserMenu + Avatar
1052
+
1053
+ The fleet-wide profile-picture + "내 정보" link + 로그아웃 surface every
1054
+ app's header should expose so users have one consistent place to find
1055
+ themselves. Composes with `useMe<T>` (v0.10).
1056
+
1057
+ ```tsx
1058
+ import { useMe, UserMenu } from "@etamong-playground/ui";
1059
+
1060
+ interface Me extends BaseMe { /* picture / preferred_username / is_admin … */ }
1061
+
1062
+ function Header() {
1063
+ const { me } = useMe<Me>();
1064
+ return (
1065
+ <header className="app-header">
1066
+ {/* …logo, nav… */}
1067
+ <UserMenu me={me} myInfoHref="/me" />
1068
+ </header>
1069
+ );
1070
+ }
1071
+ ```
1072
+
1073
+ The trigger is the avatar. Click → dropdown with:
1074
+
1075
+ - Display name (`me.name` ?? `me.preferred_username` ?? `me.email`).
1076
+ - Email line (when distinct from the display name).
1077
+ - `admin` pill when `me.is_admin` (suppress via `showAdminBadge={false}`).
1078
+ - Optional `extraItems` rows above the standard ones.
1079
+ - The "내 정보" link (set `myInfoHref={null}` to hide).
1080
+ - The "로그아웃" button.
1081
+
1082
+ Escape and click-outside close it.
1083
+
1084
+ Anonymous (`me == null`) renders a "로그인" link pointing at
1085
+ `signInUrl()`; pass `signedOutAction` to override.
1086
+
1087
+ Logout default is `signOut("/")` (oauth2-proxy `/oauth2/sign_out?rd=/`).
1088
+ Apps with their own logout endpoint pass `onSignOut`:
1089
+
1090
+ ```tsx
1091
+ <UserMenu
1092
+ me={me}
1093
+ onSignOut={() => {
1094
+ void fetch("/api/auth/logout", { method: "POST" });
1095
+ window.location.href = "/";
1096
+ }}
1097
+ />
1098
+ ```
1099
+
1100
+ Apps wanting just the avatar (lists, comments, prompts) can use the
1101
+ stand-alone `<Avatar>`:
1102
+
1103
+ ```tsx
1104
+ <Avatar src={comment.author.picture} fallback={comment.author.email} size={24} />
1105
+ ```
1106
+
1107
+ Pictures that fail to load fall back to the initial letter automatically.
1108
+
1109
+ ## Sidebar + MobileTabBar (fleet nav shell)
1110
+
1111
+ `<Sidebar>` is the desktop nav shell; `<MobileTabBar>` is the mobile
1112
+ bottom bar. Together they implement the fleet sidebar-composition
1113
+ and mobile-more-page contracts. Drive both from the same `primary` and `secondary` arrays —
1114
+ one source of truth, two renderers. Both are CSS-hidden at the
1115
+ opposite breakpoint, so mounting both unconditionally is correct.
1116
+
1117
+ ```tsx
1118
+ import { Sidebar, MobileTabBar, AppInfoSection, type SidebarItem } from "@etamong-playground/ui";
1119
+ import { Home, Calendar, Users, Settings, ShieldCheck, MoreHorizontal } from "lucide-react";
1120
+
1121
+ const primary: SidebarItem[] = [
1122
+ { id: "home", label: "홈", icon: <Home size={18} />, active: view === "home", onClick: () => go("home") },
1123
+ { id: "schedule", label: "일정", icon: <Calendar size={18} />, active: view === "schedule", onClick: () => go("schedule") },
1124
+ { id: "members", label: "구성원", icon: <Users size={18} />, active: view === "members", onClick: () => go("members") },
1125
+ ];
1126
+
1127
+ const secondary: SidebarItem[] = [
1128
+ { id: "settings", label: "설정", icon: <Settings size={18} />, active: view === "settings", onClick: () => go("settings") },
1129
+ { id: "admin", label: "Admin", icon: <ShieldCheck size={18} />, active: view === "admin", onClick: () => go("admin"), /* gate caller-side: only push when me?.is_admin */ },
1130
+ ];
1131
+
1132
+ function Shell({ children }: { children: ReactNode }) {
1133
+ return (
1134
+ <div className="etu-app-shell">
1135
+ <Sidebar
1136
+ appName="schedule-manager"
1137
+ primary={primary}
1138
+ secondary={secondary}
1139
+ footer={
1140
+ <>
1141
+ <AppInfoSection name={me?.name} description={me?.email} appVersion={pkg.version} version={SHA} builtAt={BUILT_AT} heading={null} />
1142
+ <button className="etu-sidebar-item" onClick={signOut}>로그아웃</button>
1143
+ </>
1144
+ }
1145
+ />
1146
+ <main>{children}</main>
1147
+ <MobileTabBar
1148
+ items={[
1149
+ ...primary.slice(0, 4),
1150
+ { id: "more", label: "더보기", icon: <MoreHorizontal size={22} />, active: view === "more", onClick: () => go("more") },
1151
+ ]}
1152
+ />
1153
+ </div>
1154
+ );
1155
+ }
1156
+ ```
1157
+
1158
+ Notes:
1159
+
1160
+ - **Settings + Logout always live on `/more` on mobile.** The mobile tab bar
1161
+ shows up to 4 primary destinations + 더보기; the operator taps 더보기 →
1162
+ `/more` to find Settings, Logout, Admin, etc. Never put Settings on a
1163
+ tab; never show a header-dropdown `<UserMenu>` on mobile.
1164
+ - **No `userMenu` prop on `<Sidebar>`.** Identity + Logout live in
1165
+ `footer`. Header dropdowns are the retired anti-pattern.
1166
+ - **Active state is caller-computed.** Both components are
1167
+ router-agnostic and never read the URL.
1168
+ - **CSS variable `--etu-sidebar-w` overrides the 240px default width.**
1169
+
1170
+ ### Captioned secondary subsections (large apps)
1171
+
1172
+ Once an app's secondary list grows past ~6 rows, swap the flat
1173
+ `secondary` prop for `secondarySections` — captioned, concern-based
1174
+ groups (`OPERATE / INVENTORY / GOVERNANCE`, …) that render as separate
1175
+ `<nav>` blocks. The same array drives the mobile `/more` drill-down
1176
+ rows. See the sidebar-composition "Ordering and grouping" rule for guidance.
1177
+
1178
+ ```tsx
1179
+ import { Sidebar, type SidebarSecondarySection } from "@etamong-playground/ui";
1180
+
1181
+ const secondarySections: SidebarSecondarySection[] = [
1182
+ {
1183
+ id: "operate",
1184
+ caption: "OPERATE",
1185
+ items: [
1186
+ { id: "audit", label: "Audit", onClick: () => go("audit") },
1187
+ { id: "schedule", label: "Schedule", onClick: () => go("schedule") },
1188
+ ],
1189
+ },
1190
+ {
1191
+ id: "governance",
1192
+ caption: "GOVERNANCE",
1193
+ items: [
1194
+ { id: "legal", label: "Legal", onClick: () => go("legal") },
1195
+ { id: "settings", label: "Settings", onClick: () => go("settings") },
1196
+ ],
1197
+ },
1198
+ ];
1199
+
1200
+ <Sidebar
1201
+ appName="service-admin"
1202
+ primary={primary}
1203
+ secondarySections={secondarySections}
1204
+ footer={footer}
1205
+ />;
1206
+ ```
1207
+
1208
+ When both `secondary` and `secondarySections` are passed,
1209
+ `secondarySections` wins (and a dev-only `console.warn` fires).
1210
+
1211
+ ## NavigationBar + floating tab bar (iOS 26 Liquid Glass)
1212
+
1213
+ v0.23.0 adds `<NavigationBar>` — an iOS-style small-title bar — as the default
1214
+ per-page chrome across the fleet, and refreshes `<MobileTabBar>` to a floating
1215
+ pill with the iOS 26 **Liquid Glass** material (translucent backdrop, depth-aware
1216
+ border). The global avatar top bar is retired; profile access lives on `/more`
1217
+ + the desktop sidebar footer.
1218
+
1219
+ ```tsx
1220
+ import { NavigationBar } from "@etamong-playground/ui";
1221
+
1222
+ <NavigationBar
1223
+ title="일정 상세"
1224
+ back={() => go("schedule")}
1225
+ trailing={<button className="etu-icon-btn" onClick={openMenu}>⋯</button>}
1226
+ />
1227
+ ```
1228
+
1229
+ Props (see `NavigationBarProps`):
1230
+
1231
+ - `back`: `() => void` | `string` (push history + popstate) | `true` (calls
1232
+ `history.back()`) | falsy (no back affordance).
1233
+ - `sticky` (default `true`) — sticks to top + applies `env(safe-area-inset-top)`.
1234
+ - `fadeOnScroll` (default `true`) — bar starts at 0.92 opacity and gains a
1235
+ shadow after the page scrolls past 24px.
1236
+ - `borderless` — drop the hairline border (for full-bleed transparent shells).
1237
+
1238
+ ### Android Chrome / Samsung Internet compatibility floor
1239
+
1240
+ - Min hit area is **48px** (Material 3 floor — supersedes iOS 44pt).
1241
+ - Every `backdrop-filter` is paired with an `@supports not` solid-`--etu-surface`
1242
+ fallback, and `color-mix()` rules degrade to the underlying token.
1243
+ - Glyphs are generic Unicode (`‹`, `›`, `…`, `+`) — no SF Symbols.
1244
+ - Android system-back fires `popstate` naturally; `back: true` consuming
1245
+ `history.back()` works the same on both platforms.
1246
+
1247
+ ### `.etu-glass` utility
1248
+
1249
+ Both `.etu-navbar` and `.etu-mtb` (`.etu-mobile-tab-bar`) opt into the
1250
+ `.etu-glass` material. Apply it to any other surface (sheet, popover, dock) to
1251
+ match the fleet's iOS-26 visual.
1252
+
1253
+ ## Releasing
1254
+
1255
+ The package publishes from CI **on a version tag** — no manual `pnpm publish`.
1256
+
1257
+ ```sh
1258
+ # 1. bump the version on a branch → PR → merge to main
1259
+ # (edit "version" in package.json, e.g. 0.4.0 → 0.5.0)
1260
+ # 2. tag the merged commit and push the tag:
1261
+ git checkout main && git pull
1262
+ git tag v0.5.0 # the tag MUST equal package.json's version
1263
+ git push origin v0.5.0
1264
+ ```
1265
+
1266
+ The tag pipeline verifies `vX.Y.Z` matches `package.json`, builds, and publishes
1267
+ to GitHub Packages. Versioning is **semver, but `0.x`**: cut **minor** bumps
1268
+ (0.4 → 0.5) for new components/exports and **patch** (0.4.0 → 0.4.1) for fixes.
1269
+ Re-tagging an already-published version fails loudly (npm won't overwrite).
1270
+
1271
+ ## Consuming in an app
1272
+
1273
+ Three things are needed (or the app's CI 404s / fails to resolve the package):
1274
+
1275
+ 1. **App `.npmrc`** — GitHub Packages registry:
1276
+ `@etamong-playground:registry=https://npm.pkg.github.com/`
1277
+ 2. **`package.json`** — `"@etamong-playground/ui": "^0.5.0"`.
1278
+ 3. **Dockerfile deps stage** — `ARG NPM_TOKEN` + before install:
1279
+ `RUN echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" >> .npmrc`;
1280
+ the build job passes the token via `--build-arg NPM_TOKEN`. (CI check
1281
+ jobs need the same `_authToken` line in `before_script`.)
1282
+
1283
+ **Picking up a new release:** bump the pin in `package.json` **and** refresh the
1284
+ lockfile, then commit both — the app CIs use `--frozen-lockfile`, so the lockfile
1285
+ must move for the new version to install:
1286
+
1287
+ ```sh
1288
+ pnpm add @etamong-playground/ui@^0.5.0 # updates package.json + pnpm-lock.yaml
1289
+ git add package.json pnpm-lock.yaml && git commit -m "bump @etamong-playground/ui to 0.5.0"
1290
+ ```
1291
+
1292
+ Note `^0.x` is narrow: `^0.4.0` = `>=0.4.0 <0.5.0`, so a **minor** bump needs the
1293
+ pin changed in every consumer (a patch stays in range but still needs the
1294
+ lockfile updated).
1295
+
1296
+ ## Acknowledgements
1297
+
1298
+ Uses [`cmdk`](https://github.com/pacocoursey/cmdk) (MIT) for the command palette.
1299
+
1300
+ ## License
1301
+
1302
+ MIT — see [LICENSE](LICENSE).