@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.
@@ -0,0 +1,1800 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, AnchorHTMLAttributes, MouseEvent } from 'react';
3
+
4
+ /** A single command-palette entry. */
5
+ interface CommandItem {
6
+ /** Stable unique id (used as the React key). */
7
+ id: string;
8
+ /** Visible label in the active locale. */
9
+ label: string;
10
+ /** Right-aligned secondary text (e.g. a parent group name). */
11
+ sublabel?: string;
12
+ /**
13
+ * The string cmdk filters on. To make search work across languages, build
14
+ * this with `crossLocaleKeywords` so it contains the label in every locale.
15
+ * Defaults to `label` when omitted.
16
+ */
17
+ keywords?: string;
18
+ /** Leading icon — any node (lucide, inline SVG). Icon library stays the app's choice. */
19
+ icon?: ReactNode;
20
+ /** Navigation target; selecting calls `onNavigate(href)`. */
21
+ href?: string;
22
+ /** Action callback; selecting invokes it. Takes precedence over `href`. */
23
+ onSelect?: () => void;
24
+ /** Hidden unless the palette is rendered with `isAdmin`. */
25
+ adminOnly?: boolean;
26
+ }
27
+ /** An ordered, headed group of items. */
28
+ interface CommandSection {
29
+ id: string;
30
+ heading: string;
31
+ items: CommandItem[];
32
+ /** Keep the group mounted even with no text match (e.g. a "search for …" row). */
33
+ forceMount?: boolean;
34
+ }
35
+ /**
36
+ * A bottom, always-mounted "search for …" action that receives the live query.
37
+ * Selecting one typically pushes to a search/list route carrying the text, so an
38
+ * unmatched query still becomes a real search.
39
+ */
40
+ interface CommandSearchAction {
41
+ id: string;
42
+ label: string;
43
+ keywords?: string;
44
+ icon?: ReactNode;
45
+ /** Called with the current input text on select. */
46
+ run: (query: string) => void;
47
+ }
48
+ /** Overridable UI strings (defaults are English). */
49
+ interface CommandPaletteLabels {
50
+ placeholder: string;
51
+ noResults: string;
52
+ /** Heading for the always-mounted search-actions group. */
53
+ searchHeading: string;
54
+ }
55
+
56
+ interface CommandPaletteProps {
57
+ /** Ordered groups rendered top-to-bottom. */
58
+ sections: CommandSection[];
59
+ /** Always-mounted "search for …" actions, rendered last; receive the live query. */
60
+ searchActions?: CommandSearchAction[];
61
+ /** Called with an item's `href` on select (e.g. `router.push`). */
62
+ onNavigate?: (href: string) => void;
63
+ /** When false, items marked `adminOnly` are filtered out. Default false. */
64
+ isAdmin?: boolean;
65
+ /** Override the input placeholder / empty-state text. */
66
+ labels?: Partial<CommandPaletteLabels>;
67
+ /** Also open on "/" (ignored inside inputs). Default true. */
68
+ openOnSlash?: boolean;
69
+ /** Controlled open state. Omit to let the palette own it (⌘K / "/" / event). */
70
+ open?: boolean;
71
+ onOpenChange?: (open: boolean) => void;
72
+ }
73
+ /**
74
+ * A configurable ⌘K command palette built on cmdk and styled from the
75
+ * @etamong-playground/ui tokens (import "@etamong-playground/ui/styles.css"). Mount it once,
76
+ * globally, when the user is authenticated.
77
+ *
78
+ * Opens on ⌘K / Ctrl+K, on "/" (unless typing), and on the
79
+ * `command-palette:open` DOM event. Search filters on each item's `keywords`
80
+ * (build them with `crossLocaleKeywords` for cross-language matching).
81
+ */
82
+ declare function CommandPalette({ sections, searchActions, onNavigate, isAdmin, labels, openOnSlash, open: controlledOpen, onOpenChange, }: CommandPaletteProps): react.JSX.Element;
83
+
84
+ interface CommandPaletteTriggerProps {
85
+ /** Visible placeholder-style label, e.g. "Search…". */
86
+ label?: string;
87
+ /** Extra class merged with `etu-palette-trigger`. */
88
+ className?: string;
89
+ }
90
+ /**
91
+ * A search-box-styled button that opens the command palette — so users discover
92
+ * it exists. Shows a magnifier + label + the ⌘K / Ctrl+K hint and dispatches the
93
+ * `command-palette:open` event. Styled from @etamong-playground/ui tokens; drop it in a
94
+ * sidebar/header.
95
+ */
96
+ declare function CommandPaletteTrigger({ label, className }: CommandPaletteTriggerProps): react.JSX.Element;
97
+
98
+ interface GoToRoute {
99
+ /** Second key after the "g" prefix (e.g. "s" for `g s`). */
100
+ key: string;
101
+ /** Destination passed to `onNavigate`. */
102
+ href: string;
103
+ /** Skipped unless `isAdmin`. */
104
+ adminOnly?: boolean;
105
+ }
106
+ interface GoToOptions {
107
+ isAdmin?: boolean;
108
+ /** How long the "g" prefix stays armed, ms. Default 1500. */
109
+ timeoutMs?: number;
110
+ }
111
+ /**
112
+ * Two-key "go-to" navigation: press `g`, then a letter within the timeout, to
113
+ * jump. Korean-IME-safe (resolves the logical key via `shortcutKey`/`e.code`),
114
+ * ignores text-entry targets, modifier combos, and key repeats.
115
+ *
116
+ * Returns the armed prefix ("g") or null — render it as a small indicator.
117
+ *
118
+ * @example
119
+ * const pending = useGoToShortcuts(
120
+ * [{ key: "h", href: "/" }, { key: "s", href: "/schedules" }],
121
+ * (href) => router.push(href),
122
+ * { isAdmin },
123
+ * );
124
+ */
125
+ declare function useGoToShortcuts(routes: GoToRoute[], onNavigate: (href: string) => void, options?: GoToOptions): string | null;
126
+
127
+ /**
128
+ * Concatenate one label across every locale dictionary so cmdk search matches
129
+ * regardless of the active language — a Korean user typing an English term (or
130
+ * vice-versa) still finds the item. This is mandatory in a ko-first ecosystem.
131
+ *
132
+ * @example
133
+ * import ko from "./locales/ko";
134
+ * import en from "./locales/en";
135
+ * const dicts = [ko, en];
136
+ * crossLocaleKeywords(dicts, (d) => d.nav.schedules) // "일정 Schedules"
137
+ */
138
+ declare function crossLocaleKeywords<D>(dicts: readonly D[], getter: (dict: D) => string): string;
139
+ /** True when the keyboard event originates from a text-entry control. */
140
+ declare function isInputTarget(e: KeyboardEvent): boolean;
141
+ /**
142
+ * Map physical key codes to logical keys so shortcuts survive a Korean IME:
143
+ * when an IME is active `e.key` is a Hangul syllable, not the Latin letter, so
144
+ * single-letter shortcuts must fall back to `e.code`.
145
+ */
146
+ declare const CODE_TO_KEY: Readonly<Record<string, string>>;
147
+ /**
148
+ * Resolve the logical shortcut key for an event. Prefers `e.key` when it is
149
+ * already an ASCII shortcut (`/`, `?`, or a–z); otherwise falls back to the
150
+ * physical-code map (covers IME output).
151
+ */
152
+ declare function shortcutKey(e: KeyboardEvent): string;
153
+ /** The custom DOM event any UI affordance can dispatch to open the palette. */
154
+ declare const COMMAND_PALETTE_OPEN_EVENT = "command-palette:open";
155
+ /** Dispatch `command-palette:open` so a button/menu can open the palette. */
156
+ declare function openCommandPalette(): void;
157
+
158
+ /**
159
+ * Theme helpers for the `[data-theme]` mechanism that styles.css implements.
160
+ *
161
+ * `data-theme` must be set on <html> BEFORE first paint, or the page flashes
162
+ * the default theme before flipping to the user's choice. Inject
163
+ * `noFlashThemeScript` synchronously in <head> — for Next, in a <script
164
+ * dangerouslySetInnerHTML>; for a Vite app, inline in index.html.
165
+ *
166
+ * Resolution order (matches the fleet rule in
167
+ * planning/wiki/concepts/theme-system-dark-fallback.md):
168
+ *
169
+ * 1. saved user choice in localStorage
170
+ * 2. OS preference via `prefers-color-scheme`
171
+ * 3. dark — the fleet-wide fallback when we can't tell
172
+ *
173
+ * Prior to v0.28 the fallback was "light"; the fleet now treats dark as
174
+ * the audience-of-record default (most surfaces are dark; designs assume
175
+ * dark first).
176
+ */
177
+ type Theme = "light" | "dark";
178
+ /**
179
+ * A self-contained <head> snippet (no deps) that sets `data-theme` from the
180
+ * saved choice, falling back to the OS preference, then dark. Pass the same
181
+ * `appKey` you pass to `getTheme`/`setTheme`.
182
+ */
183
+ declare function noFlashThemeScript(appKey: string): string;
184
+ /** Read the active theme (saved choice → OS preference → dark). */
185
+ declare function getTheme(appKey: string): Theme;
186
+ /** Persist and apply a theme to <html>. */
187
+ declare function setTheme(appKey: string, theme: Theme): void;
188
+
189
+ type ToastKind = "ok" | "err" | "info";
190
+ interface ToastItem {
191
+ id: number;
192
+ message: ReactNode;
193
+ kind: ToastKind;
194
+ }
195
+ /** Show a transient toast. Returns the id (so callers can dismiss early). */
196
+ declare function toast(message: ReactNode, kind?: ToastKind, durationMs?: number): number;
197
+ /** Dismiss a toast by id. */
198
+ declare function dismissToast(id: number): void;
199
+ /**
200
+ * Mount once at the app root (alongside the router/shell). Renders the toast
201
+ * queue bottom-center, styled from the @etamong-playground/ui tokens
202
+ * (import "@etamong-playground/ui/styles.css").
203
+ */
204
+ declare function Toaster(): react.JSX.Element | null;
205
+
206
+ interface BaseReq {
207
+ title?: ReactNode;
208
+ body?: ReactNode;
209
+ confirmLabel?: string;
210
+ cancelLabel?: string;
211
+ }
212
+ interface ConfirmReq extends BaseReq {
213
+ kind: "confirm";
214
+ danger?: boolean;
215
+ resolve: (ok: boolean) => void;
216
+ }
217
+ interface PromptReq extends BaseReq {
218
+ kind: "prompt";
219
+ placeholder?: string;
220
+ defaultValue?: string;
221
+ resolve: (value: string | null) => void;
222
+ }
223
+ /** Promise-based confirm — replaces window.confirm(). Resolves true/false. */
224
+ declare function uiConfirm(opts: Omit<ConfirmReq, "kind" | "resolve">): Promise<boolean>;
225
+ /** Promise-based prompt — replaces window.prompt(). Resolves the text or null. */
226
+ declare function uiPrompt(opts: Omit<PromptReq, "kind" | "resolve">): Promise<string | null>;
227
+ /**
228
+ * Mount once at the app root. Renders the pending uiConfirm/uiPrompt dialog with
229
+ * Escape (cancel), Enter (confirm), and backdrop-click (cancel) handling. Styled
230
+ * from the @etamong-playground/ui tokens (import "@etamong-playground/ui/styles.css").
231
+ */
232
+ declare function DialogHost(): react.JSX.Element | null;
233
+
234
+ /**
235
+ * Build-version + deploy-time badge. Apps bake the deployed commit SHA and the
236
+ * build timestamp into their frontend at build time (CI build-arg →
237
+ * `VITE_BUILD_SHA`/`NEXT_PUBLIC_BUILD_SHA` etc.) and render this in a
238
+ * backoffice/console footer so operators can see *what* is live and *when* it
239
+ * shipped. Styled from the @etamong-playground/ui tokens.
240
+ */
241
+ interface DeployInfoProps {
242
+ /** Deployed commit SHA (full or short). Displayed shortened to 7 chars. */
243
+ version?: string;
244
+ /** ISO-8601 build/deploy timestamp. Rendered relative; absolute in the tooltip. */
245
+ builtAt?: string;
246
+ /** Leading label. Default "deployed". */
247
+ label?: string;
248
+ /** If set, the version renders as a link (e.g. the commit URL). */
249
+ href?: string;
250
+ /** Extra class merged with `etu-deploy-info`. */
251
+ className?: string;
252
+ }
253
+ /**
254
+ * Renders e.g. `deployed a1b2c3d · 2 days ago`. Returns null when neither a
255
+ * version nor a timestamp is available (e.g. a local dev build), so callers can
256
+ * mount it unconditionally.
257
+ */
258
+ declare function DeployInfo({ version, builtAt, label, href, className }: DeployInfoProps): react.JSX.Element | null;
259
+
260
+ /**
261
+ * PWA install affordance — a small dismissable mobile banner that shows the
262
+ * right thing per platform:
263
+ *
264
+ * - Chrome / Android: captures `beforeinstallprompt`, shows an "Install"
265
+ * button that fires the native prompt.
266
+ * - iOS Safari: no programmatic install — show a short "Share → Add to Home
267
+ * Screen" hint instead.
268
+ * - Anywhere already installed (`display-mode: standalone`): renders nothing.
269
+ *
270
+ * The banner is mobile-only by default (hides on `min-width: 768px`); for a
271
+ * desktop affordance, ship a small inline button or call `useInstallPrompt`
272
+ * yourself.
273
+ *
274
+ * Per-app: drop `<InstallBanner />` once near the root (same boundary as
275
+ * `<Toaster />`). Renders nothing in unsupported environments, so it's safe to
276
+ * mount unconditionally.
277
+ */
278
+ interface InstallBannerProps {
279
+ /** Banner body text on supported (Chrome/Android) platforms. */
280
+ label?: string;
281
+ /** Banner body text on iOS Safari (where no programmatic prompt is possible). */
282
+ iosHint?: string;
283
+ /** Install button label (Chrome/Android only). */
284
+ installLabel?: string;
285
+ /** Aria-label for the close button. */
286
+ dismissLabel?: string;
287
+ /** Custom icon node rendered at the start of the banner. */
288
+ icon?: React.ReactNode;
289
+ /** Cooldown between re-shows after a dismiss, in ms. Default 3 days. */
290
+ cooldownMs?: number;
291
+ /** Stop offering after this many dismisses. Default 3. */
292
+ maxDismiss?: number;
293
+ /** localStorage key for persistence. Pick a per-app value to avoid clashes. */
294
+ storageKey?: string;
295
+ /** Extra class merged with `etu-install-banner`. */
296
+ className?: string;
297
+ }
298
+ /**
299
+ * Lower-level hook for apps that want to render their own UI.
300
+ *
301
+ * - `canPrompt` — Chrome/Android has fired `beforeinstallprompt`; call `promptInstall()` to show it.
302
+ * - `isIOS` — render iOS guidance text.
303
+ * - `isStandalone` — the app is already installed; render nothing.
304
+ */
305
+ declare function useInstallPrompt(): {
306
+ canPrompt: boolean;
307
+ promptInstall: () => Promise<"accepted" | "dismissed" | "unsupported">;
308
+ isIOS: boolean;
309
+ isStandalone: boolean;
310
+ };
311
+ declare function InstallBanner({ label, iosHint, installLabel, dismissLabel, icon, cooldownMs, maxDismiss, storageKey, className, }: InstallBannerProps): react.JSX.Element | null;
312
+
313
+ /**
314
+ * Polls the per-host status feed and returns the parsed banner state.
315
+ * Lower-level than `<StatusBanner>` — use this when you want to render your
316
+ * own UI (e.g. a header pill, a notifications page entry) instead of the
317
+ * default banner strip.
318
+ */
319
+ type Severity = "outage" | "degraded" | "maintenance";
320
+ interface StatusBannerData {
321
+ enabled: boolean;
322
+ severity: Severity | null;
323
+ message_ko: string;
324
+ message_en: string;
325
+ eta_iso: string | null;
326
+ retry_after_seconds: number | null;
327
+ tags: string[];
328
+ updated_at: string | null;
329
+ }
330
+ interface UseStatusBannerOptions {
331
+ /** Endpoint to poll. Default `/.well-known/maintenance.json` (same-origin). */
332
+ endpoint?: string;
333
+ /** Poll interval in ms. Default 60_000 (the endpoint sends `max-age=30`, so
334
+ * a 60s tick guarantees the cached layer rotates between polls). */
335
+ pollMs?: number;
336
+ }
337
+ /**
338
+ * Returns the latest parsed status, or `null` while loading / on error.
339
+ * Pauses polling while the document is hidden (visibilitychange) and resumes
340
+ * with an immediate fetch when it returns to the foreground.
341
+ */
342
+ declare function useStatusBanner(opts?: UseStatusBannerOptions): StatusBannerData | null;
343
+
344
+ interface StatusBannerProps {
345
+ /** Override the polling endpoint. Default `/.well-known/maintenance.json`
346
+ * (same-origin). Use a cross-origin URL only for an embedded view of
347
+ * another fleet host's status; the worker endpoint sends `cache-control: max-age=30`. */
348
+ endpoint?: string;
349
+ /** Override the JSON poll interval (ms). Default 60_000 (the endpoint caches
350
+ * for 30s, so two polls/minute is the floor that's useful). */
351
+ pollMs?: number;
352
+ /** Force-pick a language; default reads `document.documentElement.lang`. */
353
+ lang?: "ko" | "en";
354
+ /** Override class on the outer banner. Merged with `etu-status-banner`. */
355
+ className?: string;
356
+ /** Render the dismiss "x" button. Dismissal lasts for the session only;
357
+ * a fresh banner of a different severity reappears. Default true. */
358
+ dismissible?: boolean;
359
+ }
360
+ declare function StatusBanner({ endpoint, pollMs, lang, className, dismissible, }: StatusBannerProps): react.JSX.Element | null;
361
+
362
+ interface ErrorPageProps {
363
+ /** Headline (e.g. "문제가 발생했어요"). */
364
+ title?: string;
365
+ /** A user-friendly sentence — what happened + what to do. */
366
+ description?: string;
367
+ /**
368
+ * The 8-hex `ref` code from the backend / log (the `httperr` reference). Shown
369
+ * to the user so they can quote it in a report. Omit when there's no ref.
370
+ */
371
+ refCode?: string;
372
+ /** Optional retry handler; renders a "다시 시도" button when set. */
373
+ onRetry?: () => void;
374
+ /** Optional home handler; renders a "홈으로" button when set. */
375
+ onHome?: () => void;
376
+ /**
377
+ * Override the labels on the buttons / ref line. Defaults are Korean.
378
+ */
379
+ labels?: Partial<{
380
+ retry: string;
381
+ home: string;
382
+ refLabel: string;
383
+ }>;
384
+ /** Custom icon node (defaults to a circle-alert glyph). */
385
+ icon?: ReactNode;
386
+ /** Extra class merged with `etu-error-page`. */
387
+ className?: string;
388
+ }
389
+ declare function ErrorPage({ title, description, refCode, onRetry, onHome, labels, icon, className, }: ErrorPageProps): react.JSX.Element;
390
+
391
+ /**
392
+ * Router-agnostic in-page state hooks for the SPA navigation/state contract
393
+ * (planning concepts/spa-navigation-state).
394
+ *
395
+ * Two flavors:
396
+ *
397
+ * - `useRouteState(key, initial, opts?)` — backed by the URL query string.
398
+ * Use for state the user benefits from sharing or restoring after refresh:
399
+ * which tab is open, filter, sort, search term, expanded row id.
400
+ *
401
+ * - `useSessionState(key, initial, opts?)` — backed by `sessionStorage`,
402
+ * keyed by route. Use for truly local state you don't want in the URL:
403
+ * scroll position, cmdk query, unsubmitted form draft.
404
+ *
405
+ * Both hooks:
406
+ * - Hydrate from their backing store on mount.
407
+ * - Listen to `popstate` / `hashchange` so back/forward and direct URL
408
+ * edits stay in sync.
409
+ * - Are SSR-safe: on the server they just return `initial` until mount.
410
+ * - Don't assume a router; they read/write `window.history` directly.
411
+ *
412
+ * URL serialization defaults to `JSON.stringify` so booleans / numbers /
413
+ * arrays round-trip. Pass `{ serialize, deserialize }` for a cleaner
414
+ * representation when you want pretty URLs (e.g. tab names as plain
415
+ * strings instead of `"overview"` with the quotes).
416
+ */
417
+ type Updater<T> = T | ((prev: T) => T);
418
+ interface UseRouteStateOptions<T> {
419
+ /** How to encode the value into the URL. Default: `JSON.stringify`. */
420
+ serialize?: (value: T) => string;
421
+ /** How to decode the URL value back. Default: `JSON.parse`. */
422
+ deserialize?: (raw: string) => T;
423
+ /**
424
+ * Replace the entry instead of pushing a new one. Useful for noisy state
425
+ * (search-as-you-type filter) that shouldn't fill the back history.
426
+ * Default: `true` (replace).
427
+ */
428
+ replace?: boolean;
429
+ }
430
+ interface UseSessionStateOptions<T> {
431
+ serialize?: (value: T) => string;
432
+ deserialize?: (raw: string) => T;
433
+ /**
434
+ * Override the per-route scope. Default: `window.location.pathname +
435
+ * window.location.hash` so each in-app view gets its own slot. Pass a
436
+ * static string when you want the state to span routes.
437
+ */
438
+ scope?: string;
439
+ }
440
+ declare function useRouteState<T>(key: string, initial: T, opts?: UseRouteStateOptions<T>): [T, (next: Updater<T>) => void];
441
+ declare function useSessionState<T>(key: string, initial: T, opts?: UseSessionStateOptions<T>): [T, (next: Updater<T>) => void];
442
+
443
+ /**
444
+ * In-app history stack for the SPA navigation contract Rule 2
445
+ * (planning concepts/spa-navigation-state): the browser back button — and
446
+ * any in-UI "back" button — should stay inside the app instead of bouncing
447
+ * the user out to whatever site they came from.
448
+ *
449
+ * How it works:
450
+ * - Every in-app navigation goes through `push(url)` / `replace(url)`,
451
+ * which writes a marker into `history.state` (`{ etuInApp: true,
452
+ * etuDepth: N }`).
453
+ * - `canGoBack` is true when the current entry has `etuDepth > 0` — i.e.
454
+ * there's at least one in-app entry behind us.
455
+ * - `goBack()` calls `history.back()` when `canGoBack`; otherwise it
456
+ * runs the `fallback` handler (e.g. router.push("/more")) so the UI
457
+ * button still does something sensible on a cold entry.
458
+ *
459
+ * Composes cleanly with `useRouteState`: that hook uses `replaceState`,
460
+ * so URL-synced in-page state (tab, filter) doesn't grow the back stack
461
+ * and doesn't confuse the depth counter.
462
+ *
463
+ * The hook is router-agnostic — it reads and writes `window.history`
464
+ * directly. Pair it with whatever you use for routing.
465
+ *
466
+ * Most consumers should just render `<BackButton fallback="/more" />` —
467
+ * BackButton mounts this hook internally so apps don't have to plumb the
468
+ * canGoBack/goBack split themselves. The standalone hook is exposed for
469
+ * apps that need the values somewhere besides the button (e.g. a swipe
470
+ * gesture, a keyboard shortcut, a custom layout).
471
+ */
472
+ interface UseInAppBackResult {
473
+ /** True when there's at least one in-app entry behind the current one. */
474
+ canGoBack: boolean;
475
+ /**
476
+ * Go to the previous in-app view. When `canGoBack` is false, runs the
477
+ * `fallback` passed to the hook (or `onExit` for legacy callers; no-op
478
+ * if neither is set).
479
+ */
480
+ goBack: () => void;
481
+ /** Push a new in-app entry (URL + depth increment). */
482
+ push: (url: string) => void;
483
+ /** Replace the current entry without growing the stack. */
484
+ replace: (url: string) => void;
485
+ }
486
+ /**
487
+ * Where to go when the user clicks "back" but there's no in-app history
488
+ * behind them (cold entry from an external link, or after the browser
489
+ * dropped the state on reload).
490
+ *
491
+ * - **string** — treated as a URL. Calls `history.pushState(null, "", url)`
492
+ * and dispatches a synthetic `popstate` so hash/path routers re-render.
493
+ * Use for hash-routed apps and other vanilla setups.
494
+ * - **function** — called as-is. Use for Next.js / React Router etc:
495
+ * `fallback={() => router.push("/more")}`.
496
+ */
497
+ type InAppBackFallback = string | (() => void);
498
+ interface UseInAppBackOptions {
499
+ /**
500
+ * Where to go when `canGoBack` is false. Accepts a URL string or a
501
+ * callback (most consumers want the callback when they already have a
502
+ * router instance).
503
+ */
504
+ fallback?: InAppBackFallback;
505
+ /**
506
+ * @deprecated since v0.27.0 — use `fallback`. Kept for back-compat with
507
+ * v0.8.0–v0.26.0 callers; behaves identically when `fallback` is unset.
508
+ */
509
+ onExit?: () => void;
510
+ }
511
+ /**
512
+ * Runs a fallback. String fallbacks pushState + fire popstate so the
513
+ * caller's router picks up the URL change without a full reload.
514
+ */
515
+ declare function runInAppBackFallback(fallback: InAppBackFallback): void;
516
+ declare function useInAppBack(opts?: UseInAppBackOptions): UseInAppBackResult;
517
+
518
+ interface BackButtonProps {
519
+ /**
520
+ * From `useInAppBack().canGoBack`. Omit if you're using the canonical
521
+ * one-liner — BackButton mounts the hook internally.
522
+ */
523
+ canGoBack?: boolean;
524
+ /**
525
+ * From `useInAppBack().goBack`. Omit if you're using the canonical
526
+ * one-liner — BackButton mounts the hook internally.
527
+ */
528
+ goBack?: () => void;
529
+ /**
530
+ * Direct click handler — use when you don't want to thread the hook
531
+ * result through. If passed, takes precedence over `goBack`.
532
+ */
533
+ onClick?: () => void;
534
+ /**
535
+ * Where to go when there's no in-app history behind us (cold entry).
536
+ * String → URL (pushState + popstate). Function → run as-is (typical
537
+ * for Next.js: `() => router.push("/more")`). Used when no explicit
538
+ * `goBack`/`onClick` is provided.
539
+ */
540
+ fallback?: InAppBackFallback;
541
+ /** Default: "뒤로". */
542
+ label?: string;
543
+ /** Replace the default chevron-left glyph. */
544
+ icon?: ReactNode;
545
+ /** Extra class merged onto `etu-back-button`. */
546
+ className?: string;
547
+ /**
548
+ * Render the button even when there's nowhere to go. Useful when the
549
+ * caller wants a stable layout — the click is a no-op unless an
550
+ * `onClick` / `goBack` / `fallback` is also wired.
551
+ */
552
+ alwaysShow?: boolean;
553
+ }
554
+ declare function BackButton({ canGoBack, goBack, onClick, fallback, label, icon, className, alwaysShow, }: BackButtonProps): react.JSX.Element | null;
555
+
556
+ /**
557
+ * Tiny `fetch` wrapper with the etamong-lab house conventions baked in:
558
+ *
559
+ * - **httperr `ref` parsing** — non-2xx JSON bodies shaped like
560
+ * `{ error: string, ref: string }` (planning concepts/user-facing-
561
+ * error-messages, shared/libs/httperr) become an `HttpError` whose
562
+ * `ref` drops straight into `<ErrorPage refCode={err.ref}>`.
563
+ * - **OIDC sign-in on 401** — 401 responses trigger `onAuthError`,
564
+ * which by default redirects to `oauth2-proxy`'s sign-in flow with
565
+ * the current URL as `rd`.
566
+ * - **JSON in / JSON out by default** — request bodies that are plain
567
+ * objects get `Content-Type: application/json` + serialized; non-empty
568
+ * 2xx responses with a JSON content type are parsed.
569
+ *
570
+ * Designed to be the one fetch wrapper an etamong-lab app needs.
571
+ * Compose with route-state / error-page / toast on the consumer side.
572
+ */
573
+ interface CreateFetchOptions {
574
+ /**
575
+ * Base URL prepended to relative paths. `"/api"` (default: empty —
576
+ * paths are used as-is).
577
+ */
578
+ baseUrl?: string;
579
+ /**
580
+ * Called on a 401 response. Default: redirect the browser to
581
+ * `/oauth2/start?rd=<current url>`. Pass a no-op when the app handles
582
+ * auth elsewhere (e.g. inside its router).
583
+ */
584
+ onAuthError?: () => void;
585
+ /**
586
+ * Called for every non-2xx response after the `HttpError` is built but
587
+ * before it's thrown. Use for telemetry / global toast. Doesn't affect
588
+ * the throw — the caller still gets the error.
589
+ */
590
+ onError?: (err: HttpError) => void;
591
+ /**
592
+ * Extra headers to add to every request. Static object or a function
593
+ * (evaluated per request). Common use: an `Authorization` header for
594
+ * token-based callers (not browser sessions).
595
+ */
596
+ headers?: HeadersInit | (() => HeadersInit);
597
+ /**
598
+ * Override the global `fetch` (for tests / SSR). Default: `globalThis.fetch`.
599
+ */
600
+ fetchImpl?: typeof fetch;
601
+ }
602
+ interface HttpErrorBody {
603
+ /** Server-supplied user-facing message. */
604
+ error?: string;
605
+ /** 8-hex correlation id from the server log (httperr). */
606
+ ref?: string;
607
+ /** Any extra fields the server included. */
608
+ [extra: string]: unknown;
609
+ }
610
+ /**
611
+ * Error thrown for every non-2xx response. The `ref` field is the join
612
+ * key into the server-side error log and is what `<ErrorPage>` shows.
613
+ */
614
+ declare class HttpError extends Error {
615
+ status: number;
616
+ ref?: string;
617
+ body?: HttpErrorBody | string;
618
+ url: string;
619
+ constructor(status: number, url: string, body: HttpErrorBody | string | undefined);
620
+ }
621
+ interface RequestOptions {
622
+ /** Query-string params; values are stringified. */
623
+ query?: Record<string, string | number | boolean | null | undefined>;
624
+ /** Per-call header override (merged on top of the factory headers). */
625
+ headers?: HeadersInit;
626
+ /** Per-call body. Objects → JSON. `FormData` / `Blob` / strings pass through. */
627
+ body?: unknown;
628
+ /** Abort signal. */
629
+ signal?: AbortSignal;
630
+ /**
631
+ * Skip JSON parsing and return the raw `Response` instead. Useful for
632
+ * downloads / streaming.
633
+ */
634
+ raw?: boolean;
635
+ }
636
+ interface FetchClient {
637
+ request<T = unknown>(method: string, path: string, opts?: RequestOptions): Promise<T>;
638
+ get<T = unknown>(path: string, opts?: RequestOptions): Promise<T>;
639
+ post<T = unknown>(path: string, body?: unknown, opts?: RequestOptions): Promise<T>;
640
+ put<T = unknown>(path: string, body?: unknown, opts?: RequestOptions): Promise<T>;
641
+ patch<T = unknown>(path: string, body?: unknown, opts?: RequestOptions): Promise<T>;
642
+ delete<T = unknown>(path: string, opts?: RequestOptions): Promise<T>;
643
+ }
644
+ declare function createFetch(opts?: CreateFetchOptions): FetchClient;
645
+
646
+ /**
647
+ * `useMe()` + OIDC sign-in/out URL helpers — the small auth-status surface
648
+ * every etamong-lab app reimplements.
649
+ *
650
+ * Pairs with `oauth2-proxy` (the fleet's standard auth proxy): the sign-in
651
+ * URL is `/oauth2/start?rd=<return-url>`, sign-out is `/oauth2/sign_out`.
652
+ *
653
+ * The `/me` endpoint and the exact shape are app-specific, so the hook is
654
+ * generic over the body type. Pass a `fetcher` (typically from
655
+ * `createFetch`) and a type — the hook handles the loading/error/refresh
656
+ * state machine + listens for an `etu:me-refresh` event so other
657
+ * components can ask for a re-fetch.
658
+ */
659
+ interface BaseMe {
660
+ /** Always present — the authenticated identity. */
661
+ email: string;
662
+ /** OIDC `preferred_username` if the IdP supplies it. */
663
+ preferred_username?: string;
664
+ /** OIDC `name` claim — full display name. */
665
+ name?: string;
666
+ /** OIDC `picture` claim — URL of the user's profile picture. */
667
+ picture?: string;
668
+ /** Server-side admin flag (use this, not allowlist-by-email on the client). */
669
+ is_admin?: boolean;
670
+ /** Role claims from the IdP. */
671
+ roles?: string[];
672
+ }
673
+ interface UseMeOptions<T extends BaseMe> {
674
+ /**
675
+ * URL to fetch. Default: `/api/me`. Ignored when `fetcher` is set.
676
+ */
677
+ endpoint?: string;
678
+ /**
679
+ * Custom fetcher — usually the `api.get<T>("/me")` from your
680
+ * `createFetch` client. Use this when the app's API is mounted under a
681
+ * non-default base path or needs custom headers.
682
+ */
683
+ fetcher?: () => Promise<T>;
684
+ /**
685
+ * When `true` (default), 401 from the default fetcher counts as
686
+ * "anonymous" rather than an error — `me` becomes `null`, `error`
687
+ * stays `null`. Useful when the app has public surfaces. Ignored when
688
+ * a custom `fetcher` is set.
689
+ */
690
+ treat401AsAnonymous?: boolean;
691
+ }
692
+ interface UseMeResult<T extends BaseMe> {
693
+ me: T | null;
694
+ loading: boolean;
695
+ error: Error | null;
696
+ /** Re-fetch /me. Also fires `etu:me-refresh` so other readers re-fetch too. */
697
+ refresh: () => void;
698
+ }
699
+ declare function useMe<T extends BaseMe = BaseMe>(opts?: UseMeOptions<T>): UseMeResult<T>;
700
+ /**
701
+ * Build the oauth2-proxy sign-in URL. Pass `rd` to override the
702
+ * post-sign-in redirect target (default: the current URL).
703
+ */
704
+ declare function signInUrl(rd?: string): string;
705
+ /**
706
+ * Build the oauth2-proxy sign-out URL. Pass `rd` for the post-sign-out
707
+ * redirect (default: `/`).
708
+ */
709
+ declare function signOutUrl(rd?: string): string;
710
+ /** Navigate to the sign-in URL. */
711
+ declare function signIn(rd?: string): void;
712
+ /** Navigate to the sign-out URL. */
713
+ declare function signOut(rd?: string): void;
714
+
715
+ interface EmptyStateProps {
716
+ /** Headline. */
717
+ title: string;
718
+ /** Optional one-line description. */
719
+ description?: ReactNode;
720
+ /** Optional CTA / footnote node (typically a button or a hint). */
721
+ action?: ReactNode;
722
+ /** Replace the default empty-box glyph. Pass `null` to omit. */
723
+ icon?: ReactNode;
724
+ /** Smaller padding + smaller type — for inline / sidebar usage. */
725
+ compact?: boolean;
726
+ /** Extra class merged with `etu-empty-state`. */
727
+ className?: string;
728
+ }
729
+ declare function EmptyState({ title, description, action, icon, compact, className, }: EmptyStateProps): react.JSX.Element;
730
+
731
+ interface UseClipboardOptions {
732
+ /** Default: 1500ms. How long `copied` stays true after a successful copy. */
733
+ resetMs?: number;
734
+ /** Toast text on success. Default: "복사됨". Pass `null` to suppress. */
735
+ toastOnSuccess?: string | null;
736
+ /** Toast text on failure. Default: "복사 실패". Pass `null` to suppress. */
737
+ toastOnError?: string | null;
738
+ }
739
+ interface UseClipboardResult {
740
+ copied: boolean;
741
+ copy: (value: string) => Promise<boolean>;
742
+ }
743
+ declare function useClipboard(opts?: UseClipboardOptions): UseClipboardResult;
744
+ interface CopyButtonProps extends UseClipboardOptions {
745
+ /** The string to copy on click. */
746
+ value: string;
747
+ /** Button label. Default: "복사" (switches to "복사됨" briefly after success). */
748
+ label?: string;
749
+ /** Label shown right after a successful copy. Default: "복사됨". */
750
+ successLabel?: string;
751
+ /** Replace the default copy glyph. Pass `null` to omit. */
752
+ icon?: ReactNode;
753
+ /** Extra class merged with `etu-copy-button`. */
754
+ className?: string;
755
+ /**
756
+ * Render only the icon (no label). Good for inline placement next to a
757
+ * value display.
758
+ */
759
+ iconOnly?: boolean;
760
+ /** ARIA label when `iconOnly`. Default: "복사". */
761
+ ariaLabel?: string;
762
+ }
763
+ declare function CopyButton({ value, label, successLabel, icon, className, iconOnly, ariaLabel, resetMs, toastOnSuccess, toastOnError, }: CopyButtonProps): react.JSX.Element;
764
+
765
+ interface OpenInBrowserButtonProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "target" | "rel"> {
766
+ /** The URL to open. */
767
+ href: string;
768
+ /** Button label. Default: "브라우저에서 열기". */
769
+ label?: string;
770
+ /** Replace the default external-link glyph. Pass `null` to omit. */
771
+ icon?: ReactNode;
772
+ /**
773
+ * Render only the icon (no label). Good for inline placement next to a URL
774
+ * display or inside a toolbar.
775
+ */
776
+ iconOnly?: boolean;
777
+ /** ARIA label when `iconOnly`. Default: same as `label`. */
778
+ ariaLabel?: string;
779
+ /**
780
+ * Visual variant. `ghost` (default) = transparent button with border,
781
+ * matches `<CopyButton>`. `primary` = filled accent.
782
+ */
783
+ variant?: "ghost" | "primary";
784
+ /** Extra class merged with `etu-open-in-browser-button`. */
785
+ className?: string;
786
+ }
787
+ declare function OpenInBrowserButton({ href, label, icon, iconOnly, ariaLabel, variant, className, ...rest }: OpenInBrowserButtonProps): react.JSX.Element;
788
+
789
+ /**
790
+ * Service-worker helpers for the etamong-lab PWA recipe (planning
791
+ * concepts/pwa-service-worker). Two pieces:
792
+ *
793
+ * - `registerServiceWorker(url, opts)` — client-side registration with
794
+ * the update flow baked in: aggressive `registration.update()` checks
795
+ * (on load + visibilitychange + a slow interval), a "새 버전" toast on
796
+ * a waiting SW, and an auto-reload on `controllerchange`.
797
+ *
798
+ * - `networkFirstSwSource({ version, navigateAllowlist })` — returns the
799
+ * canonical hand-rolled SW source as a string. Apps write it to
800
+ * `public/sw.js` (or generate it at build time). The recipe:
801
+ * • Never intercepts non-GET or `/api/*` (fresh auth + live state).
802
+ * • Navigation requests: **network-first** with a short timeout, cache
803
+ * as the offline fallback. So online users always see the latest
804
+ * deploy; offline still works.
805
+ * • Static assets: **network-first** with a short timeout too, cache
806
+ * as the offline fallback. Same bias toward "fresh wins".
807
+ * • Caches are versioned by `version`; on `activate` older versions
808
+ * are deleted, then `clients.claim()`.
809
+ * • The SW calls `skipWaiting()` immediately on `install` so updates
810
+ * propagate on the next nav.
811
+ *
812
+ * This is the "online-first" preset. Apps with a tighter cache budget
813
+ * or a hand-rolled scoped strategy (minccino's specific endpoints, the
814
+ * shortener single-segment-route guard) should stick with their
815
+ * bespoke SW.
816
+ */
817
+ interface RegisterServiceWorkerOptions {
818
+ /**
819
+ * Show a "새 버전이 있어요" toast when a new SW finishes installing and
820
+ * is waiting. Clicking it activates the new SW and reloads. Default: true.
821
+ */
822
+ notifyOnUpdate?: boolean;
823
+ /**
824
+ * Auto-activate the waiting SW + reload as soon as it's detected, with
825
+ * no user prompt. Use when you don't care about transient state loss.
826
+ * Default: false (the toast is the canonical path).
827
+ */
828
+ autoReloadOnUpdate?: boolean;
829
+ /**
830
+ * Override the toast text. Default: "새 버전이 준비됐어요. 새로고침할까요?".
831
+ */
832
+ updateToastText?: string;
833
+ /**
834
+ * Interval (ms) between background `registration.update()` calls — so
835
+ * long-lived installed tabs catch new deploys without a reload. Default:
836
+ * 2 minutes. Pass `0` to disable the interval (still updates on load
837
+ * + visibilitychange).
838
+ */
839
+ updateIntervalMs?: number;
840
+ /**
841
+ * Service-worker `register()` options (typically `{ scope }`).
842
+ */
843
+ registerOptions?: RegistrationOptions;
844
+ /**
845
+ * Called when the new SW activates and the page is about to reload.
846
+ * Last chance to persist transient state.
847
+ */
848
+ onActivate?: () => void;
849
+ /**
850
+ * The current build identifier — typically the deploy SHA from
851
+ * `DeployInfo`. When supplied, a dev-mode assertion fires a
852
+ * `console.warn` if this changes across reloads but the SW file body
853
+ * is byte-for-byte identical. That signal means the SW source isn't
854
+ * stamped per build, so browsers never observe a new SW and never
855
+ * roll over to the new deploy — the canonical iOS PWA staleness
856
+ * trigger (see planning concepts/pwa-cache-and-ios-shell and the
857
+ * `viewport-fit-assertions` companion pattern).
858
+ *
859
+ * Dev-only — production short-circuits via `NODE_ENV === "production"`.
860
+ */
861
+ currentBuild?: string;
862
+ }
863
+ interface ServiceWorkerHandle {
864
+ /** The underlying registration once it resolves. */
865
+ readonly registration: ServiceWorkerRegistration | null;
866
+ /** True while a waiting SW exists (update available). */
867
+ readonly hasUpdate: boolean;
868
+ /** Force the waiting SW to take over + reload. No-op if no waiting SW. */
869
+ applyUpdate: () => void;
870
+ /** Manually trigger `registration.update()`. */
871
+ checkForUpdate: () => Promise<void>;
872
+ /** Unregister this SW. Useful in tests / when toggling off. */
873
+ unregister: () => Promise<boolean>;
874
+ }
875
+ /**
876
+ * Register a service worker with the etamong-lab update-flow conventions.
877
+ * Returns a handle for programmatic control. Safe to call from `useEffect`
878
+ * (no React dependency — works from any client-side bootstrap).
879
+ */
880
+ declare function registerServiceWorker(url: string, opts?: RegisterServiceWorkerOptions): ServiceWorkerHandle;
881
+ interface NetworkFirstSwOptions {
882
+ /**
883
+ * Cache version suffix. Use a build SHA so caches roll over on every
884
+ * deploy. Required.
885
+ */
886
+ version: string;
887
+ /**
888
+ * Network timeout (ms) before the SW falls back to the cached
889
+ * navigation/asset. Default: 3000.
890
+ */
891
+ networkTimeoutMs?: number;
892
+ /**
893
+ * Extra URL prefixes the SW should leave alone (in addition to the
894
+ * always-skipped `/api/`). Use for OIDC callbacks, webhook routes,
895
+ * single-segment apiserver routes. Default: `[]`.
896
+ *
897
+ * Each entry is a prefix match against `url.pathname`.
898
+ */
899
+ passThroughPrefixes?: string[];
900
+ }
901
+ /**
902
+ * Returns the canonical "online-first" SW source as a string. Write it to
903
+ * `public/sw.js` at build time (most apps already have a build step that
904
+ * inlines a `SW_VERSION` constant):
905
+ *
906
+ * import { networkFirstSwSource } from "@etamong-playground/ui";
907
+ * await fs.writeFile(
908
+ * "public/sw.js",
909
+ * networkFirstSwSource({ version: process.env.BUILD_SHA }),
910
+ * );
911
+ *
912
+ * Or serve it dynamically from the app's own backend at `/sw.js`.
913
+ *
914
+ * The recipe:
915
+ * - Never intercepts non-GET or anything under `/api/` (or the
916
+ * `passThroughPrefixes`) — auth & live state always hit the network.
917
+ * - Navigation requests: network-first with `networkTimeoutMs`, cache
918
+ * fallback for offline. So a new deploy lands the moment the next
919
+ * navigation succeeds.
920
+ * - Static assets: network-first with the same timeout, cache fallback.
921
+ * - On `activate`, all caches not matching `version` are deleted.
922
+ * - `skipWaiting()` on install + `clients.claim()` on activate so the
923
+ * new SW takes over the next time the page navigates.
924
+ */
925
+ declare function networkFirstSwSource(opts: NetworkFirstSwOptions): string;
926
+
927
+ interface AdminCheckInput<T extends BaseMe = BaseMe> {
928
+ /** The current identity (from `useMe`). `null` = anonymous. */
929
+ me: T | null | undefined;
930
+ /** App-managed allowlist (case-insensitive on the local part / domain). */
931
+ emails?: string[];
932
+ /** Pass if `me.roles` intersects this set. */
933
+ roles?: string[];
934
+ /** Last-resort custom check (e.g. flags like `can_create_apps`). */
935
+ predicate?: (me: T) => boolean;
936
+ }
937
+ /**
938
+ * Returns `true` when *any* of the configured signals says the user is
939
+ * allowed in. With nothing configured, only `me.is_admin` counts.
940
+ */
941
+ declare function isAdminLike<T extends BaseMe>(input: AdminCheckInput<T>): boolean;
942
+ interface AdminGateProps<T extends BaseMe = BaseMe> extends AdminCheckInput<T> {
943
+ /** Rendered when the user is allowed in. */
944
+ children: ReactNode;
945
+ /**
946
+ * Rendered when the user is NOT allowed in. Default: `null` (nothing).
947
+ * Pass a friendly "권한이 없어요" surface or a redirect-trigger here.
948
+ */
949
+ fallback?: ReactNode;
950
+ }
951
+ declare function AdminGate<T extends BaseMe = BaseMe>(props: AdminGateProps<T>): react.JSX.Element;
952
+ interface AdminBadgeProps {
953
+ /** Default: "관리자 전용". */
954
+ label?: string;
955
+ /** Extra class merged with `etu-admin-badge`. */
956
+ className?: string;
957
+ }
958
+ /**
959
+ * Small inline badge that marks a page / section as admin-only. Pair with
960
+ * `AdminGate` so users without access never see it in the first place,
961
+ * but admins always know the surface they're on.
962
+ */
963
+ declare function AdminBadge({ label, className }: AdminBadgeProps): react.JSX.Element;
964
+ interface BackofficeLayoutProps {
965
+ /** Page title. */
966
+ title: ReactNode;
967
+ /** Optional subtitle line below the title. */
968
+ description?: ReactNode;
969
+ /** Right-side actions (buttons, search, filter). */
970
+ actions?: ReactNode;
971
+ /** Override the "관리자 전용" badge — pass `null` to hide it entirely. */
972
+ badge?: ReactNode | null;
973
+ /** Page body. */
974
+ children: ReactNode;
975
+ /** Extra class merged with `etu-backoffice`. */
976
+ className?: string;
977
+ }
978
+ /**
979
+ * Standard page-head layout for a backoffice route: title + AdminBadge +
980
+ * optional actions, then the page body.
981
+ */
982
+ declare function BackofficeLayout({ title, description, actions, badge, children, className, }: BackofficeLayoutProps): react.JSX.Element;
983
+
984
+ interface AppInfoLink {
985
+ label: string;
986
+ href: string;
987
+ /** Default: opens in a new tab when `href` is absolute. */
988
+ external?: boolean;
989
+ }
990
+ interface AppInfoSectionProps {
991
+ /** App display name (e.g. "schedule-manager", "🎪 Festplan"). */
992
+ name?: ReactNode;
993
+ /** Optional one-liner under the name. */
994
+ description?: ReactNode;
995
+ /** Optional logo / icon node to the left of the name. */
996
+ icon?: ReactNode;
997
+ /**
998
+ * Semver / release version (`package.json` `version` — e.g. "1.4.2").
999
+ * Distinct from the commit SHA, which goes through `version`.
1000
+ */
1001
+ appVersion?: string;
1002
+ /** Deployed commit SHA — forwarded to `<DeployInfo>`. */
1003
+ version?: string;
1004
+ /** Build timestamp (ISO 8601) — forwarded to `<DeployInfo>`. */
1005
+ builtAt?: string;
1006
+ /**
1007
+ * Extra link rows ("도움말", "이용약관", "개인정보처리방침"). External
1008
+ * links open in a new tab.
1009
+ */
1010
+ links?: AppInfoLink[];
1011
+ /**
1012
+ * Free-form rows under the standard fields. Use for app-specific
1013
+ * meta (plan name, quota, owner email, …).
1014
+ */
1015
+ children?: ReactNode;
1016
+ /** Section heading. Default: "앱 정보". Pass `null` to omit. */
1017
+ heading?: ReactNode | null;
1018
+ /** Extra class merged with `etu-app-info`. */
1019
+ className?: string;
1020
+ }
1021
+ declare function AppInfoSection({ name, description, icon, appVersion, version, builtAt, links, children, heading, className, }: AppInfoSectionProps): react.JSX.Element;
1022
+
1023
+ /**
1024
+ * Time-format helpers — the small piece every etamong-lab app reimplements.
1025
+ *
1026
+ * Two surfaces:
1027
+ *
1028
+ * - `formatRelTime(when, now?)` — "3 minutes ago" / "3분 전" via the
1029
+ * browser's `Intl.RelativeTimeFormat`. Locale comes from the
1030
+ * document by default; pass `locale` to force.
1031
+ * - `formatAbsTime(when, opts?)` — absolute formatting via
1032
+ * `Intl.DateTimeFormat`. Defaults to a KST (`Asia/Seoul`) Korean
1033
+ * rendering — the fleet's de-facto wall clock.
1034
+ *
1035
+ * - `<RelTime when />` — small React component that auto-refreshes the
1036
+ * relative label on a timer (every 30 s under a minute, every minute
1037
+ * under an hour, then every 10 minutes). Title attribute always shows
1038
+ * the absolute time so hovering reveals the exact timestamp.
1039
+ *
1040
+ * `when` accepts a `Date`, an ISO string, or an epoch milliseconds
1041
+ * number. Invalid inputs render to empty strings rather than throwing —
1042
+ * makes the helpers safe to use directly on partial data.
1043
+ */
1044
+ type TimeLike = Date | string | number;
1045
+ interface FormatRelTimeOptions {
1046
+ /** Locale tag (e.g. "ko", "en"). Default: browser default. */
1047
+ locale?: string | string[];
1048
+ /**
1049
+ * `Intl.RelativeTimeFormat` numeric mode. Default: `"auto"` (gives
1050
+ * "yesterday" / "어제" instead of "1 day ago").
1051
+ */
1052
+ numeric?: "auto" | "always";
1053
+ /** Reference time for "now". Default: `Date.now()`. */
1054
+ now?: TimeLike;
1055
+ }
1056
+ /**
1057
+ * Returns e.g. `"3분 전"` / `"in 2 hours"`. Empty string for invalid
1058
+ * inputs.
1059
+ */
1060
+ declare function formatRelTime(when: TimeLike, opts?: FormatRelTimeOptions): string;
1061
+ interface FormatAbsTimeOptions {
1062
+ /** Locale tag. Default: `"ko-KR"`. */
1063
+ locale?: string | string[];
1064
+ /** Time zone. Default: `"Asia/Seoul"` (KST — the fleet's wall clock). */
1065
+ timeZone?: string;
1066
+ /** `Intl.DateTimeFormat` style preset. Default: `"datetime"`. */
1067
+ style?: "date" | "time" | "datetime" | "datetime-seconds";
1068
+ /** Custom `Intl.DateTimeFormat` options — overrides `style`. */
1069
+ formatOptions?: Intl.DateTimeFormatOptions;
1070
+ /** Wrap the result so it's tagged with the timezone (e.g. `… KST`). */
1071
+ withZoneSuffix?: boolean;
1072
+ }
1073
+ /**
1074
+ * Returns e.g. `"2026. 06. 13. 12:34"` in KST. Empty string for invalid
1075
+ * inputs.
1076
+ */
1077
+ declare function formatAbsTime(when: TimeLike, opts?: FormatAbsTimeOptions): string;
1078
+ interface RelTimeProps {
1079
+ /** The timestamp. */
1080
+ when: TimeLike;
1081
+ /** Forwarded to `formatRelTime`. */
1082
+ locale?: string | string[];
1083
+ /** Forwarded to `formatRelTime`. */
1084
+ numeric?: "auto" | "always";
1085
+ /**
1086
+ * Absolute-time options for the `title` attribute. Defaults to KST
1087
+ * datetime with the zone suffix.
1088
+ */
1089
+ absoluteOptions?: FormatAbsTimeOptions;
1090
+ /** Render as a different tag. Default: `<time>`. */
1091
+ as?: "time" | "span";
1092
+ /** Extra class. */
1093
+ className?: string;
1094
+ }
1095
+ /**
1096
+ * Auto-refreshing relative-time label. Renders as a `<time>` element
1097
+ * with `dateTime` + a `title` showing the absolute time.
1098
+ */
1099
+ declare function RelTime({ when, locale, numeric, absoluteOptions, as, className, }: RelTimeProps): react.JSX.Element | null;
1100
+
1101
+ interface AvatarProps {
1102
+ /** Picture URL — typically `me.picture`. */
1103
+ src?: string;
1104
+ /** Initial / fallback when no picture is available — typically `me.preferred_username || me.email`. */
1105
+ fallback?: string;
1106
+ /** px size of the rendered circle. Default: 32. */
1107
+ size?: number;
1108
+ /** Extra class merged with `etu-avatar`. */
1109
+ className?: string;
1110
+ /** ARIA label — defaults to "프로필". */
1111
+ alt?: string;
1112
+ }
1113
+ /**
1114
+ * Round avatar — renders the picture if `src` is given, otherwise an
1115
+ * initial letter on a token-colored circle. Use stand-alone or inside
1116
+ * `<UserMenu>`.
1117
+ */
1118
+ declare function Avatar({ src, fallback, size, className, alt }: AvatarProps): react.JSX.Element;
1119
+ interface UserMenuProps<T extends BaseMe = BaseMe> {
1120
+ /** Current identity. `null` shows the signed-out affordance. */
1121
+ me: T | null | undefined;
1122
+ /** Avatar size. Default: 32. */
1123
+ avatarSize?: number;
1124
+ /**
1125
+ * URL of the "내 정보" page. Default: `/me`. Pass `null` to hide the row.
1126
+ */
1127
+ myInfoHref?: string | null;
1128
+ /** Override the "내 정보" label. */
1129
+ myInfoLabel?: string;
1130
+ /**
1131
+ * Logout handler. Default: `signOut()` (navigates to
1132
+ * `/oauth2/sign_out?rd=/`). Pass a custom handler for apps that have
1133
+ * their own logout POST (e.g. minccino's `/api/auth/logout`).
1134
+ */
1135
+ onSignOut?: () => void;
1136
+ /** Override the "로그아웃" label. */
1137
+ signOutLabel?: string;
1138
+ /**
1139
+ * Rendered in place of the avatar when `me` is `null`. Default: a
1140
+ * "로그인" link pointing at `signInUrl()`.
1141
+ */
1142
+ signedOutAction?: ReactNode;
1143
+ /**
1144
+ * Extra rows above 내 정보 / 로그아웃. Use for app-specific quick links
1145
+ * (e.g. "내 사이트", "결제"). Pass an array of `{ label, href? | onClick? }`.
1146
+ */
1147
+ extraItems?: UserMenuItem[];
1148
+ /** Extra class merged with `etu-user-menu`. */
1149
+ className?: string;
1150
+ /**
1151
+ * Render an admin badge inside the dropdown when `me.is_admin` is true.
1152
+ * Default: true.
1153
+ */
1154
+ showAdminBadge?: boolean;
1155
+ /**
1156
+ * Preferred placement of the dropdown relative to the trigger.
1157
+ * Default: `"bottom-right"`. Use `"top-right"` when the trigger sits at the
1158
+ * bottom of a sidebar foot (pages / festplan layouts) so the menu opens
1159
+ * *upward* instead of pointing at the viewport edge.
1160
+ *
1161
+ * The horizontal half controls the dropdown's right/left alignment; the
1162
+ * vertical half controls open-up vs open-down. The component auto-flips
1163
+ * either axis on open when the requested side doesn't fit — so a layout
1164
+ * that becomes a horizontal topbar on mobile won't strand the menu above
1165
+ * the viewport.
1166
+ */
1167
+ placement?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
1168
+ }
1169
+ interface UserMenuItem {
1170
+ label: ReactNode;
1171
+ href?: string;
1172
+ onClick?: () => void;
1173
+ /** Open `href` in a new tab. Default: false. */
1174
+ external?: boolean;
1175
+ }
1176
+ declare function UserMenu<T extends BaseMe = BaseMe>({ me, avatarSize, myInfoHref, myInfoLabel, onSignOut, signOutLabel, signedOutAction, extraItems, className, showAdminBadge, placement, }: UserMenuProps<T>): react.JSX.Element;
1177
+
1178
+ interface MobileTabBarItem {
1179
+ /** Stable key used for React reconciliation. */
1180
+ id: string;
1181
+ /** Short label shown under the icon. Keep ~5 chars to fit 5 tabs on a 360px viewport. */
1182
+ label: ReactNode;
1183
+ /** Icon node — typically a lucide-react `<Home size={24} />` or an inline SVG. */
1184
+ icon: ReactNode;
1185
+ /** Whether this tab is the current one. The caller computes this from its route. */
1186
+ active?: boolean;
1187
+ /** Click handler. Use this for SPA-style in-app navigation. */
1188
+ onClick?: () => void;
1189
+ /** Link target. Renders an `<a>` instead of a `<button>`. */
1190
+ href?: string;
1191
+ }
1192
+ interface MobileTabBarProps {
1193
+ items: MobileTabBarItem[];
1194
+ /** ARIA label for the nav landmark. Default: "주요 메뉴". */
1195
+ ariaLabel?: string;
1196
+ /** Extra class merged with `etu-mobile-tab-bar`. */
1197
+ className?: string;
1198
+ }
1199
+ declare function MobileTabBar({ items, ariaLabel, className }: MobileTabBarProps): react.JSX.Element;
1200
+
1201
+ interface NotificationBellItem {
1202
+ id: string;
1203
+ /** Rendered as a single row in the panel. */
1204
+ content: ReactNode;
1205
+ }
1206
+ interface NotificationBellProps {
1207
+ items: NotificationBellItem[];
1208
+ /**
1209
+ * Override the badge count. Defaults to `items.length`. Pass when the
1210
+ * server-side pending count is higher than what's currently rendered
1211
+ * (paginated list, partial fetch).
1212
+ */
1213
+ count?: number;
1214
+ /** Called when the panel opens — use to refresh `items`. */
1215
+ onOpen?: () => void;
1216
+ /** Aria label for the trigger button. Default: `"알림"`. */
1217
+ ariaLabel?: string;
1218
+ /** Title shown at the top of the panel. Default: `"알림"`. */
1219
+ title?: ReactNode;
1220
+ /** Empty-state body when `items` is empty. Default: `"새 알림이 없습니다."`. */
1221
+ emptyMessage?: ReactNode;
1222
+ /** Optional footer — typically a "View all" link or "Mark all read". */
1223
+ footer?: ReactNode;
1224
+ /** Custom trigger icon — defaults to a bell SVG. */
1225
+ icon?: ReactNode;
1226
+ /** Extra class merged with `etu-notif-bell`. */
1227
+ className?: string;
1228
+ /**
1229
+ * Preferred placement of the desktop dropdown. Default: `"bottom-right"`.
1230
+ * Auto-flips on open when the requested side doesn't fit, same contract
1231
+ * as `<UserMenu>`. Ignored on mobile (always bottom sheet).
1232
+ */
1233
+ placement?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
1234
+ }
1235
+ declare function NotificationBell({ items, count, onOpen, ariaLabel, title, emptyMessage, footer, icon, className, placement, }: NotificationBellProps): react.JSX.Element;
1236
+
1237
+ interface SidebarItem {
1238
+ /** Stable key used for React reconciliation. */
1239
+ id: string;
1240
+ /** Display label. */
1241
+ label: ReactNode;
1242
+ /** Icon node — typically a lucide-react icon. */
1243
+ icon?: ReactNode;
1244
+ /** Whether this item is the current route. Caller computes from its router. */
1245
+ active?: boolean;
1246
+ /** Click handler for SPA-style navigation. */
1247
+ onClick?: () => void;
1248
+ /** Link target. Renders an `<a>` instead of a `<button>`. */
1249
+ href?: string;
1250
+ }
1251
+ /**
1252
+ * Captioned secondary subsection — used by large apps whose `/more` content
1253
+ * grows past ~6 rows and benefits from concern-based grouping
1254
+ * (`OPERATE / INVENTORY / GOVERNANCE`, …). Each group renders as its own
1255
+ * `<nav>` with an optional caption header above the items. Within a section
1256
+ * items are still frequency-ordered.
1257
+ */
1258
+ interface SidebarSecondarySection {
1259
+ /** Stable key used for React reconciliation. Falls back to array index. */
1260
+ id?: string;
1261
+ /** Caption text shown above the section's items. Omit for a header-less group. */
1262
+ caption?: ReactNode;
1263
+ /** Items in this section — same row markup as the flat secondary list. */
1264
+ items: SidebarItem[];
1265
+ }
1266
+ interface SidebarProps {
1267
+ /** App display name shown in the header (e.g. "schedule-manager", "🎪 Festplan"). */
1268
+ appName?: ReactNode;
1269
+ /** Optional logo / icon node to the left of the name. */
1270
+ appIcon?: ReactNode;
1271
+ /** Optional element rendered under the app name (org switcher, plan badge, …). */
1272
+ appHeaderExtra?: ReactNode;
1273
+ /**
1274
+ * Primary destinations — mirror the same array that feeds
1275
+ * `<MobileTabBar items={primary.slice(0, 4).concat([moreTab])} />`.
1276
+ */
1277
+ primary: SidebarItem[];
1278
+ /**
1279
+ * Secondary destinations as a flat list — mirror the array that feeds the
1280
+ * `/more` page (Settings, Admin, …). Suitable for small apps. Pass an empty
1281
+ * array if the app has no secondary nav. When `secondarySections` is also
1282
+ * supplied, `secondarySections` wins and this prop is ignored (a dev-only
1283
+ * `console.warn` is emitted so consumers notice the override).
1284
+ */
1285
+ secondary?: SidebarItem[];
1286
+ /**
1287
+ * Secondary destinations grouped into captioned subsections — for large apps
1288
+ * whose `/more` grows past ~6 rows. Each group is concern-based
1289
+ * (`OPERATE / INVENTORY / GOVERNANCE`, …) and renders as a separate `<nav>`
1290
+ * with an optional caption header. Within a section items are still
1291
+ * frequency-ordered. Overrides `secondary` when both are supplied.
1292
+ * The same array shape drives the mobile `/more` drill-down rows; see
1293
+ * `planning/wiki/concepts/sidebar-composition.md` for the contract.
1294
+ */
1295
+ secondarySections?: SidebarSecondarySection[];
1296
+ /**
1297
+ * Caption above the flat secondary list. Default: "더보기". Pass `null` to
1298
+ * omit. Applies only when `secondary` is rendered — `secondarySections`
1299
+ * carry their own captions per group.
1300
+ */
1301
+ secondaryCaption?: ReactNode | null;
1302
+ /**
1303
+ * Footer node — identity + Logout + build info. The convention is to
1304
+ * render an inline `<AppInfoSection>`-style identity row plus a
1305
+ * `[Logout]` button; `<DeployInfo>` may go inline at the very bottom.
1306
+ * Pass `null` for an anonymous shell (login routes, public-only hosts).
1307
+ */
1308
+ footer?: ReactNode;
1309
+ /** ARIA label for the nav landmark. Default: "주 메뉴". */
1310
+ ariaLabel?: string;
1311
+ /** Extra class merged with `etu-sidebar`. */
1312
+ className?: string;
1313
+ /**
1314
+ * Behavior at the tablet tier (720–1023px). Default: `"rail"`.
1315
+ * - "rail" icon-only ~64px rail
1316
+ * - "drawer" hidden until `open` is true; mount `<SidebarToggle>` in
1317
+ * your app bar to flip it
1318
+ * - "full" v0.27 behavior — full 240px sidebar at all ≥720px widths
1319
+ * Has no effect at the mobile (<720) or desktop (≥1024) tier.
1320
+ */
1321
+ tabletMode?: "rail" | "drawer" | "full";
1322
+ /**
1323
+ * Drawer open state (drawer mode only). Controlled — pair with
1324
+ * `onOpenChange`. Auto-flips to `false` when the route changes (the
1325
+ * parent component is expected to call `onOpenChange(false)` after
1326
+ * navigation; or use the built-in `<SidebarToggle>` helper which wires
1327
+ * this up). Ignored unless `tabletMode === "drawer"`.
1328
+ */
1329
+ open?: boolean;
1330
+ onOpenChange?: (open: boolean) => void;
1331
+ }
1332
+ declare function Sidebar({ appName, appIcon, appHeaderExtra, primary, secondary, secondarySections, secondaryCaption, footer, ariaLabel, className, tabletMode, open, onOpenChange, }: SidebarProps): react.JSX.Element;
1333
+ interface SidebarToggleProps {
1334
+ /** Whether the drawer is currently open. */
1335
+ open: boolean;
1336
+ onOpenChange: (open: boolean) => void;
1337
+ /** ARIA label. Default: "메뉴 열기" / "메뉴 닫기" via `labelOpen`/`labelClose`. */
1338
+ labelOpen?: string;
1339
+ labelClose?: string;
1340
+ /** Extra class merged with `etu-sidebar-toggle`. */
1341
+ className?: string;
1342
+ }
1343
+ /**
1344
+ * Hamburger button that flips the `<Sidebar tabletMode="drawer">` open
1345
+ * state. Visible only at the tablet tier (≥720 and <1024). Mobile users
1346
+ * use `<MobileTabBar>`; desktop users see the full sidebar already.
1347
+ */
1348
+ declare function SidebarToggle({ open, onOpenChange, labelOpen, labelClose, className, }: SidebarToggleProps): react.JSX.Element;
1349
+ /**
1350
+ * State hook for the drawer mode. Persists open/closed in sessionStorage
1351
+ * (not localStorage — drawer state is per-session, not a preference) and
1352
+ * auto-closes when `routeKey` changes (pass your current path/hash).
1353
+ */
1354
+ declare function useSidebarDrawer(appKey: string, routeKey?: string): [boolean, (open: boolean) => void];
1355
+
1356
+ /**
1357
+ * React-free i18n primitives — safe to import from `helpers.ts`, server
1358
+ * runtimes, or the index.html-adjacent script that emits the no-flash
1359
+ * snippet. The React provider/hooks live in `i18n.tsx` and re-export
1360
+ * everything from here.
1361
+ */
1362
+ type Locale = "ko" | "en";
1363
+ declare const SUPPORTED_LOCALES: Locale[];
1364
+ type Messages = Record<string, string>;
1365
+ type MessageBundle = Record<Locale, Messages>;
1366
+ /** Read the active locale (saved choice → system → "en"). */
1367
+ declare function getLocale(appKey: string): Locale;
1368
+ /** Persist and apply a locale to <html lang>. */
1369
+ declare function setLocale(appKey: string, locale: Locale): void;
1370
+ /**
1371
+ * <head> snippet (no deps) that sets <html lang> from the saved choice,
1372
+ * falling back to system languages, then "en".
1373
+ */
1374
+ declare function noFlashLocaleScript(appKey: string): string;
1375
+ /** Render `{name}` placeholders. Unknown placeholders pass through. */
1376
+ declare function interpolate(template: string, vars?: Record<string, string | number>): string;
1377
+
1378
+ interface I18nContextValue {
1379
+ locale: Locale;
1380
+ setLocale: (next: Locale) => void;
1381
+ t: (key: string, vars?: Record<string, string | number>) => string;
1382
+ }
1383
+ interface I18nProviderProps {
1384
+ /** Per-app namespace key — same one you pass to `<ThemeProvider>`. */
1385
+ appKey: string;
1386
+ /** Localized messages keyed by locale. EN entries act as the fallback. */
1387
+ messages: MessageBundle;
1388
+ children: ReactNode;
1389
+ }
1390
+ declare function I18nProvider({ appKey, messages, children }: I18nProviderProps): react.JSX.Element;
1391
+ /** Returns the active `t(key, vars?)` translator + current locale + setter. */
1392
+ declare function useI18n(): I18nContextValue;
1393
+ /** Convenience: just the translator. Equivalent to `useI18n().t`. */
1394
+ declare function useT(): I18nContextValue["t"];
1395
+ /** Convenience: just `[locale, setLocale]`. */
1396
+ declare function useLocale(): [Locale, (next: Locale) => void];
1397
+
1398
+ /**
1399
+ * React-free viewport primitives — safe to import from `helpers.ts`.
1400
+ * React provider/hook lives in `viewport.tsx`.
1401
+ */
1402
+ type ViewportTier = "mobile" | "tablet" | "desktop";
1403
+ declare const TABLET_MIN = 720;
1404
+ declare const DESKTOP_MIN = 1024;
1405
+ /** SSR-safe — returns "desktop" on the server. */
1406
+ declare function getViewport(): ViewportTier;
1407
+ declare const noFlashViewportScript: string;
1408
+
1409
+ declare function ViewportProvider({ children }: {
1410
+ children: ReactNode;
1411
+ }): react.JSX.Element;
1412
+ declare function useViewport(): ViewportTier;
1413
+
1414
+ interface NavigationBarProps {
1415
+ title: string;
1416
+ back?: boolean | string | (() => void);
1417
+ /** Default: "뒤로". */
1418
+ backLabel?: string;
1419
+ leading?: ReactNode;
1420
+ trailing?: ReactNode;
1421
+ /** Default: true. When true, sticks to top + applies safe-area-inset-top. */
1422
+ sticky?: boolean;
1423
+ /**
1424
+ * Default: true. When false, the sticky bar omits `padding-top:
1425
+ * env(safe-area-inset-top)` — for apps that already have a global top
1426
+ * chrome bar (brand/avatar/bell row) above the per-page nav. Avoids
1427
+ * double safe-area stacking that pushed iOS-PWA page titles below the
1428
+ * visible viewport on notched devices.
1429
+ */
1430
+ safeAreaTop?: boolean;
1431
+ /** Default: false. When true, omits the bottom hairline border. */
1432
+ borderless?: boolean;
1433
+ /**
1434
+ * Default: true. Bar starts at 0.8 opacity and strengthens to 1 with a
1435
+ * heavier shadow after the page scrolls past 24px. Pure-CSS via the
1436
+ * `etu-navbar--scrolled` class toggled on a scroll listener.
1437
+ */
1438
+ fadeOnScroll?: boolean;
1439
+ /** ARIA label for the inner <nav>. Default: title. */
1440
+ ariaLabel?: string;
1441
+ /** Extra class merged onto `etu-navbar`. */
1442
+ className?: string;
1443
+ }
1444
+ declare function NavigationBar({ title, back, backLabel, leading, trailing, sticky, safeAreaTop, borderless, fadeOnScroll, ariaLabel, className, }: NavigationBarProps): react.JSX.Element;
1445
+
1446
+ /**
1447
+ * Tag the document with PWA / iOS-PWA classes and harden the iOS standalone
1448
+ * shell against the two recurring complaints:
1449
+ *
1450
+ * - Korean body text "shrinks" when the app is launched from the home screen
1451
+ * because iOS's text-size-adjust kicks in without the Safari toolbar. The
1452
+ * `@etamong-playground/ui/styles.css` reset locks this declaratively; this helper
1453
+ * re-applies the lock via an explicit inline style, defensively, for hosts
1454
+ * that mount their own stylesheet after ours.
1455
+ * - Tapping a `<input>` whose font-size is below 16px triggers a zoom that
1456
+ * never zooms back out. Opt-in: set `<html data-etu-lock-zoom>` and we set
1457
+ * `maximum-scale=1` on the viewport meta in iOS PWA mode.
1458
+ *
1459
+ * Call once from app bootstrap (before or after first paint, both fine — it's
1460
+ * idempotent). No React; safe from any client-side entry.
1461
+ */
1462
+ declare function installIOSPwaShell(): void;
1463
+
1464
+ /** Production legal hub host. Override via `manifestUrl` / `hubBaseUrl` props. */
1465
+ declare const LEGAL_HUB_BASE_URL = "https://legal.m.etamong.com";
1466
+ /** Default manifest endpoint. Override per-prop for tests / staging. */
1467
+ declare const LEGAL_MANIFEST_URL = "https://legal.m.etamong.com/api/public-manifest";
1468
+ /** Recognized L2 doc kinds. The hub may add more; unknown kinds render with a humanized label. */
1469
+ type LegalKind = "terms" | "privacy" | (string & {});
1470
+ interface LegalAvailability {
1471
+ /** Doc kinds the hub reports as published for this app, in fixed render order. */
1472
+ kinds: LegalKind[];
1473
+ /** "loaded" = fresh fetch; "stale" = cached over the soft TTL; "loading" = no value yet; "error" = no cached fallback either. */
1474
+ status: "loaded" | "stale" | "loading" | "error";
1475
+ /** Force a re-fetch (e.g. after the operator publishes a new doc). */
1476
+ refresh: () => void;
1477
+ }
1478
+ interface UseLegalAvailabilityOptions {
1479
+ /** Override the manifest URL — useful for tests or self-hosted hubs. */
1480
+ manifestUrl?: string;
1481
+ /** Disable the network fetch (e.g. SSR / Storybook). The hook returns `{kinds: [], status: "loading"}`. */
1482
+ disabled?: boolean;
1483
+ }
1484
+ declare function useLegalAvailability(appSlug: string, options?: UseLegalAvailabilityOptions): LegalAvailability;
1485
+ interface LegalRowProps {
1486
+ icon: ReactNode;
1487
+ label: ReactNode;
1488
+ /** `›` for in-app nav, `↗` for external. Default chosen from `external`. */
1489
+ trailing?: ReactNode;
1490
+ href: string;
1491
+ /** When set, called instead of the default link navigation. */
1492
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
1493
+ /** When true, opens the link in a new tab with `rel="noopener noreferrer"`. */
1494
+ external?: boolean;
1495
+ /**
1496
+ * When `false` (default), the row renders a top divider (matches "second-row-onwards"
1497
+ * inside a multi-row card). Set `true` for the first row of a card to suppress it.
1498
+ * `<LegalMenuItem>` and `<LegalPage>` set this automatically.
1499
+ */
1500
+ isFirst?: boolean;
1501
+ }
1502
+ /** A single row inside an `.etu-legal-card`. Use when you want to compose your own
1503
+ * card with a `<LegalMenuItem>` and additional rows (e.g. 문의하기 mailto). */
1504
+ declare function LegalRow({ icon, label, trailing, href, onClick, external, isFirst, }: LegalRowProps): react.JSX.Element;
1505
+ interface LegalMenuItemProps {
1506
+ /** Hub `serviceId` — the codename slug, e.g. `alert-ops`, `xatu`. */
1507
+ appSlug: string;
1508
+ /** Route the in-app `/more/legal` page lives at. Default `/more/legal`. */
1509
+ to?: string;
1510
+ /** SPA navigation handler. Called instead of full-page navigation. */
1511
+ onNavigate?: (to: string) => void;
1512
+ /** Override the leading emoji / icon. Pass `null` to omit. */
1513
+ icon?: ReactNode | null;
1514
+ /** Override the row label. Default: `법률 정보`. */
1515
+ label?: ReactNode;
1516
+ /**
1517
+ * Set `true` when this is the first row inside its enclosing
1518
+ * `.etu-legal-card` (suppresses the top divider). Default `false` — the row
1519
+ * draws a top border so it sits cleanly under sibling rows like 문의하기.
1520
+ */
1521
+ isFirst?: boolean;
1522
+ }
1523
+ /**
1524
+ * `법률 정보 ›` row. Renders only the row — the caller wraps it (and any
1525
+ * sibling rows like 문의하기) in an `.etu-legal-card` so the legal entry
1526
+ * shares the same visual card as adjacent contact/help rows (matches
1527
+ * planning concepts/mobile-more-page diagram).
1528
+ */
1529
+ declare function LegalMenuItem({ appSlug: _appSlug, to, onNavigate, icon, label, isFirst, }: LegalMenuItemProps): react.JSX.Element;
1530
+ interface LegalPageProps {
1531
+ /** Hub `serviceId` — the codename slug, e.g. `alert-ops`, `xatu`. */
1532
+ appSlug: string;
1533
+ /** Override the L1 identity row label. Default: `로그인 정책`. */
1534
+ identityLabel?: ReactNode;
1535
+ /** Override the legal hub base URL (e.g. for staging). */
1536
+ hubBaseUrl?: string;
1537
+ /** Override per-kind row labels (extends the defaults: terms → 이용약관, privacy → 개인정보처리방침). */
1538
+ kindLabels?: Record<string, ReactNode>;
1539
+ /** Override anchor for a specific (kind) — by default `<appSlug>-<kind>`. */
1540
+ anchorOverride?: Partial<Record<string, string>>;
1541
+ /** Forwarded to `useLegalAvailability`. */
1542
+ manifestUrl?: string;
1543
+ /** Empty-state line shown when the manifest reports zero L2 kinds. */
1544
+ emptyMessage?: ReactNode;
1545
+ /** Extra class merged with the wrapping card. */
1546
+ className?: string;
1547
+ }
1548
+ declare function LegalPage({ appSlug, identityLabel, hubBaseUrl, kindLabels, anchorOverride, manifestUrl, emptyMessage, className, }: LegalPageProps): react.JSX.Element;
1549
+
1550
+ /**
1551
+ * PolicyChangeBanner — a hub-driven amber banner that surfaces upcoming
1552
+ * legal-document changes for the current app. Fetches `/status.json` from
1553
+ * the legal hub, filters to this app's service ID, and renders a
1554
+ * dismissible full-width strip linking to the hub page.
1555
+ *
1556
+ * Renders null when nothing is pending, the hub is unreachable, or the
1557
+ * current pending set was already dismissed.
1558
+ */
1559
+ interface PolicyChangeBannerProps {
1560
+ /** Hub `serviceId` — the codename slug, e.g. `"schedule-manager"`. */
1561
+ appSlug: string;
1562
+ /** Display language. Default `"ko"`. */
1563
+ locale?: "ko" | "en";
1564
+ /** Override the legal hub origin. Default `LEGAL_HUB_BASE_URL`. */
1565
+ hubBaseUrl?: string;
1566
+ /**
1567
+ * Router-agnostic link renderer. Default: `<a target="_blank" rel="noopener noreferrer">`.
1568
+ * Pass your framework's `<Link>` component when the hub is same-origin.
1569
+ */
1570
+ renderLink?: (href: string, children: React.ReactNode) => React.ReactNode;
1571
+ /** Extra class merged with the outer banner element. */
1572
+ className?: string;
1573
+ }
1574
+ declare function PolicyChangeBanner({ appSlug, locale, hubBaseUrl, renderLink, className, }: PolicyChangeBannerProps): react.JSX.Element | null;
1575
+
1576
+ /**
1577
+ * `<DataTable>` — house-pattern tabular display per
1578
+ * `etamong-lab/planning` wiki concept `tabular-fit-and-nowrap.md`.
1579
+ *
1580
+ * At wide viewports renders a real `<table>`. Below the configured
1581
+ * breakpoint (default 720px), or whenever the carrier element is
1582
+ * narrower than that, the rows collapse to one stacked card each
1583
+ * (the column header becomes a label, the cell value the value).
1584
+ * Never `overflow-x: auto`. Every column stays visible at every
1585
+ * viewport.
1586
+ *
1587
+ * <DataTable
1588
+ * columns={[
1589
+ * { key: "name", label: "App", nowrap: true,
1590
+ * render: a => <a href={...}>{a.slug}</a> },
1591
+ * { key: "version", label: "Version", nowrap: true },
1592
+ * { key: "built", label: "Built", nowrap: true,
1593
+ * render: a => a.builtAt ?? "—" },
1594
+ * ]}
1595
+ * rows={apps}
1596
+ * rowKey={a => a.slug}
1597
+ * primaryColumn="name"
1598
+ * />
1599
+ *
1600
+ * `primaryColumn` (default = first column) is the row's identifying
1601
+ * field. In wide mode it's just the first cell; in card mode it's
1602
+ * the card header (rendered without the label).
1603
+ */
1604
+
1605
+ interface DataTableColumn<R> {
1606
+ /** Stable key — used for React keys and the optional render fallback. */
1607
+ key: string;
1608
+ /** Header label / card-row label. */
1609
+ label: ReactNode;
1610
+ /** Apply `white-space: nowrap` to this column's `<td>` and to the value side of the stacked card. Default false. */
1611
+ nowrap?: boolean;
1612
+ /** Render the cell for this row. Default: read `row[key]` if it's a primitive. */
1613
+ render?: (row: R) => ReactNode;
1614
+ /** Hide the column entirely (wide and narrow). Useful for conditional surfaces. */
1615
+ hidden?: boolean;
1616
+ /** Hide in narrow card mode only (e.g. action buttons that live in a separate row). */
1617
+ hiddenNarrow?: boolean;
1618
+ /** Hide in wide table mode only — rare; for fields that only make sense in the card view. */
1619
+ hiddenWide?: boolean;
1620
+ /** Optional `<th>` width hint (CSS value). */
1621
+ width?: string;
1622
+ /** Optional alignment. Default left. */
1623
+ align?: "left" | "right" | "center";
1624
+ /** Extra class merged onto both `<th>` and `<td>` for this column. */
1625
+ className?: string;
1626
+ }
1627
+ interface DataTableProps<R> {
1628
+ columns: DataTableColumn<R>[];
1629
+ rows: R[];
1630
+ rowKey: (row: R, index: number) => string | number;
1631
+ /** Column key whose value is the row's primary identifier (appears as the card header). Defaults to the first non-hidden column. */
1632
+ primaryColumn?: string;
1633
+ /** Optional below-card action row (buttons). Hidden in wide mode unless rendered as a column. */
1634
+ rowActions?: (row: R) => ReactNode;
1635
+ /** Shown when `rows` is empty. */
1636
+ emptyState?: ReactNode;
1637
+ /** Extra class merged onto the outer wrapper. */
1638
+ className?: string;
1639
+ /** Force a mode. Default: container-query driven (`auto`). */
1640
+ mode?: "auto" | "wide" | "cards";
1641
+ }
1642
+ declare function DataTable<R>(props: DataTableProps<R>): ReactNode;
1643
+
1644
+ interface DocsHubSection {
1645
+ /** Stable key used in the left nav + as the section anchor. */
1646
+ id: string;
1647
+ /** Sidebar label. */
1648
+ label: ReactNode;
1649
+ /** Optional one-line summary shown under the section heading. */
1650
+ summary?: ReactNode;
1651
+ /** Body content — apps supply their own JSX (or rendered markdown). */
1652
+ content: ReactNode;
1653
+ }
1654
+ interface DocsHubSkill {
1655
+ /** Skill slug — used as the download filename `<name>.md`. */
1656
+ name: string;
1657
+ /** One-line description (skill frontmatter `description:`). */
1658
+ description: string;
1659
+ /** The skill body in markdown. */
1660
+ body: string;
1661
+ /**
1662
+ * Stable public URL serving the same skill markdown bytes as the
1663
+ * download button. Convention: `https://<app>.m.etamong.com/skill.md`.
1664
+ * When set, DocsHub shows a `curl … -o ~/.claude/skills/<slug>/SKILL.md`
1665
+ * one-liner as the primary install path (download stays as fallback).
1666
+ * The endpoint must be unauthenticated and return `text/markdown`.
1667
+ * See wiki/concepts/docs-hub.md.
1668
+ */
1669
+ publicUrl?: string;
1670
+ /** Optional CTA label override. Default: "📥 Claude skill 받기". */
1671
+ buttonLabel?: ReactNode;
1672
+ /**
1673
+ * Override or hide the auto-appended "Claude skill 사용법" section.
1674
+ * - `undefined` (default): DocsHub appends a standard usage section
1675
+ * (Claude Code, Codex, plain LLM context).
1676
+ * - A custom `DocsHubSection`: use that instead.
1677
+ * - `null`: skip the auto-section entirely.
1678
+ */
1679
+ usageSection?: DocsHubSection | null;
1680
+ }
1681
+ interface DocsHubProps {
1682
+ /** App display name shown in the page head (left of the skill button). */
1683
+ appName?: ReactNode;
1684
+ /** Optional sub-heading under `appName`. */
1685
+ description?: ReactNode;
1686
+ /** Section list. At least one section. */
1687
+ sections: DocsHubSection[];
1688
+ /** Skill artifact downloaded by the top-right button. Omit to hide it. */
1689
+ skill?: DocsHubSkill;
1690
+ /** Controlled active section. Pair with `onSectionChange`. */
1691
+ sectionId?: string;
1692
+ /** Active-section callback (controlled mode). */
1693
+ onSectionChange?: (id: string) => void;
1694
+ /** Initial section id (uncontrolled). Default: first section. */
1695
+ defaultSectionId?: string;
1696
+ /** Extra class merged with `etu-docs-hub`. */
1697
+ className?: string;
1698
+ }
1699
+ declare function DocsHub({ appName, description, sections, skill, sectionId, onSectionChange, defaultSectionId, className, }: DocsHubProps): react.JSX.Element;
1700
+ /** Exported for apps that want to render or share the skill markdown without
1701
+ * triggering a download (e.g., a "copy to clipboard" affordance). */
1702
+ declare function buildSkillMarkdownText(skill: DocsHubSkill): string;
1703
+
1704
+ /**
1705
+ * Fleet-auth primitives — implements the route contract documented at
1706
+ * `planning/wiki/concepts/fleet-auth.md` (planning#252).
1707
+ *
1708
+ * GET /auth/login?rd=<path> OIDC authorization
1709
+ * GET /auth/callback token exchange + session cookie
1710
+ * GET /auth/logout clear session
1711
+ * GET /api/me { sub, email, name, groups }
1712
+ *
1713
+ * Apps still on the older `oauth2-proxy` paths (`/oauth2/start`,
1714
+ * `/oauth2/sign_out`) use `useMe()` / `signInUrl()` / `signOutUrl()`
1715
+ * from `./useMe` directly — those keep their legacy default. This
1716
+ * module is the forward path that fleet apps standardise on.
1717
+ */
1718
+
1719
+ declare const SHARE_CRAWLER_UA_SUBSTRINGS: readonly ["kakaotalk-scrap", "slackbot", "facebookexternalhit", "twitterbot", "discordbot", "linkedinbot", "telegrambot", "whatsapp", "line-poker"];
1720
+ /** Returns true if `ua` matches any advertised share-preview crawler. */
1721
+ declare function isShareCrawler(ua: string | null | undefined): boolean;
1722
+ /** Fleet-contract sign-in URL: `/auth/login?rd=<current or override>`. */
1723
+ declare function fleetLoginUrl(rd?: string): string;
1724
+ /** Fleet-contract sign-out URL: `/auth/logout?rd=<override or "/">`. */
1725
+ declare function fleetLogoutUrl(rd?: string): string;
1726
+ declare function fleetSignIn(rd?: string): void;
1727
+ declare function fleetSignOut(rd?: string): void;
1728
+ /**
1729
+ * Thin wrapper over `useMe()` with fleet defaults — `/api/me`, 401 treated
1730
+ * as anonymous. Returns the same shape plus `signIn`/`signOut` bound to
1731
+ * the fleet contract URLs.
1732
+ */
1733
+ interface UseIdentityResult<T extends BaseMe> extends UseMeResult<T> {
1734
+ signIn: (rd?: string) => void;
1735
+ signOut: (rd?: string) => void;
1736
+ }
1737
+ declare function useIdentity<T extends BaseMe = BaseMe>(opts?: UseMeOptions<T>): UseIdentityResult<T>;
1738
+ /**
1739
+ * Renders `children` only for authenticated users. Anonymous browser
1740
+ * navigation triggers a `window.location` redirect to the fleet login.
1741
+ * Share-preview crawlers (UA match) and SSR/Next get `children`
1742
+ * unconditionally so the static `<meta og:*>` block remains readable.
1743
+ */
1744
+ interface AuthGateProps {
1745
+ children: React.ReactNode;
1746
+ /** Override the post-login redirect target. Default = current location. */
1747
+ rd?: string;
1748
+ /** What to show while `/api/me` is in flight. Default = `null`. */
1749
+ loadingFallback?: React.ReactNode;
1750
+ /**
1751
+ * Pre-resolved User-Agent for SSR/Next environments. When supplied and
1752
+ * matching a known crawler, the gate is bypassed and `children` render
1753
+ * directly. In CSR `navigator.userAgent` is used.
1754
+ */
1755
+ userAgent?: string;
1756
+ /** When true, skip the redirect (render `null` instead). */
1757
+ disableRedirect?: boolean;
1758
+ }
1759
+ declare function AuthGate({ children, rd, loadingFallback, userAgent, disableRedirect, }: AuthGateProps): JSX.Element | null;
1760
+ interface LoginButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
1761
+ /** Override the post-login redirect target. */
1762
+ rd?: string;
1763
+ /** Label override. Default: "Sign in". */
1764
+ label?: React.ReactNode;
1765
+ }
1766
+ declare function LoginButton({ rd, label, style, ...rest }: LoginButtonProps): JSX.Element;
1767
+ interface LogoutButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
1768
+ rd?: string;
1769
+ label?: React.ReactNode;
1770
+ }
1771
+ declare function LogoutButton({ rd, label, style, ...rest }: LogoutButtonProps): JSX.Element;
1772
+ interface SessionBadgeProps {
1773
+ /** Override the identity. By default, `useIdentity()` is used. */
1774
+ me?: BaseMe | null;
1775
+ /** Click target. Default: do nothing; consumers usually navigate to /more. */
1776
+ onClick?: () => void;
1777
+ /** Class hook for app-side overrides. */
1778
+ className?: string;
1779
+ }
1780
+ declare function SessionBadge({ me: overrideMe, onClick, className, }: SessionBadgeProps): JSX.Element | null;
1781
+ /**
1782
+ * Listens for `etu:session-expired` events and mounts a single
1783
+ * dialog directing the user to sign in again. Apps trigger it by
1784
+ * dispatching the event from their XHR error handler when /api/* returns
1785
+ * 401 after the initial `/api/me` succeeded.
1786
+ */
1787
+ interface SessionExpiredDialogProps {
1788
+ title?: React.ReactNode;
1789
+ message?: React.ReactNode;
1790
+ signInLabel?: React.ReactNode;
1791
+ /** Optional class hook for the dialog root. */
1792
+ className?: string;
1793
+ }
1794
+ declare function SessionExpiredDialog({ title, message, signInLabel, className, }: SessionExpiredDialogProps): JSX.Element | null;
1795
+ /** Dispatch the `etu:session-expired` event. Call from your XHR layer. */
1796
+ declare function notifySessionExpired(): void;
1797
+ /** Dispatch the `etu:me-refresh` event so any mounted `useMe`/`useIdentity` re-fetches. */
1798
+ declare function refreshIdentity(): void;
1799
+
1800
+ export { AdminBadge, type AdminBadgeProps, type AdminCheckInput, AdminGate, type AdminGateProps, type AppInfoLink, AppInfoSection, type AppInfoSectionProps, AuthGate, type AuthGateProps, Avatar, type AvatarProps, BackButton, type BackButtonProps, BackofficeLayout, type BackofficeLayoutProps, type BaseMe, CODE_TO_KEY, COMMAND_PALETTE_OPEN_EVENT, type CommandItem, CommandPalette, type CommandPaletteLabels, type CommandPaletteProps, CommandPaletteTrigger, type CommandPaletteTriggerProps, type CommandSearchAction, type CommandSection, CopyButton, type CopyButtonProps, type CreateFetchOptions, DESKTOP_MIN, DataTable, type DataTableColumn, type DataTableProps, DeployInfo, type DeployInfoProps, DialogHost, DocsHub, type DocsHubProps, type DocsHubSection, type DocsHubSkill, EmptyState, type EmptyStateProps, ErrorPage, type ErrorPageProps, type FetchClient, type FormatAbsTimeOptions, type FormatRelTimeOptions, type GoToOptions, type GoToRoute, HttpError, type HttpErrorBody, I18nProvider, type I18nProviderProps, type InAppBackFallback, InstallBanner, type InstallBannerProps, LEGAL_HUB_BASE_URL, LEGAL_MANIFEST_URL, type LegalAvailability, type LegalKind, LegalMenuItem, type LegalMenuItemProps, LegalPage, type LegalPageProps, LegalRow, type LegalRowProps, type Locale, LoginButton, type LoginButtonProps, LogoutButton, type LogoutButtonProps, type MessageBundle, type Messages, MobileTabBar, type MobileTabBarItem, type MobileTabBarProps, NavigationBar, type NavigationBarProps, type NetworkFirstSwOptions, NotificationBell, type NotificationBellItem, type NotificationBellProps, OpenInBrowserButton, type OpenInBrowserButtonProps, PolicyChangeBanner, type PolicyChangeBannerProps, type RegisterServiceWorkerOptions, RelTime, type RelTimeProps, type RequestOptions, SHARE_CRAWLER_UA_SUBSTRINGS, SUPPORTED_LOCALES, type ServiceWorkerHandle, SessionBadge, type SessionBadgeProps, SessionExpiredDialog, type SessionExpiredDialogProps, Sidebar, type SidebarItem, type SidebarProps, type SidebarSecondarySection, SidebarToggle, type SidebarToggleProps, StatusBanner, type StatusBannerData, type StatusBannerProps, type Severity as StatusBannerSeverity, TABLET_MIN, type Theme, type TimeLike, type ToastItem, type ToastKind, Toaster, type UseClipboardOptions, type UseClipboardResult, type UseIdentityResult, type UseInAppBackOptions, type UseInAppBackResult, type UseLegalAvailabilityOptions, type UseMeOptions, type UseMeResult, type UseRouteStateOptions, type UseSessionStateOptions, type UseStatusBannerOptions, UserMenu, type UserMenuItem, type UserMenuProps, ViewportProvider, type ViewportTier, buildSkillMarkdownText, createFetch, crossLocaleKeywords, dismissToast, fleetLoginUrl, fleetLogoutUrl, fleetSignIn, fleetSignOut, formatAbsTime, formatRelTime, getLocale, getTheme, getViewport, installIOSPwaShell, interpolate, isAdminLike, isInputTarget, isShareCrawler, networkFirstSwSource, noFlashLocaleScript, noFlashThemeScript, noFlashViewportScript, notifySessionExpired, openCommandPalette, refreshIdentity, registerServiceWorker, runInAppBackFallback, setLocale, setTheme, shortcutKey, signIn, signInUrl, signOut, signOutUrl, toast, uiConfirm, uiPrompt, useClipboard, useGoToShortcuts, useI18n, useIdentity, useInAppBack, useInstallPrompt, useLegalAvailability, useLocale, useMe, useRouteState, useSessionState, useSidebarDrawer, useStatusBanner, useT, useViewport };