Skip to content

Repository files navigation

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 (Anthropic's agentic CLI). Each README documents the design rationale behind the library.

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.

@etamong-playground/ui

Shared frontend scaffold for a personal homelab app fleet. Ships the design-token contract (styles.css), the cmdk command palette + discoverable trigger, Korean-IME- safe go-to shortcuts, toast + dialog notification primitives, and the DeployInfo build-version badge. Conventions: see the concepts sections below (frontend-conventions, design-system, command-palette, app-notifications, build-version-info).

Published to GitHub Packages; consumed by all app frontends. Current: v0.48. Releasing + consuming are documented at the bottom.

Works in both house stacks — Next.js (React 19) and Vite + apiserver (React 18). React/ReactDOM are peer deps.

What's in the box

Export Kind What Mount
styles.css CSS Design tokens (--etu-* namespaced; light/dark) + all component styles Import once at the app root
CommandPalette React component The ⌘K palette: grouped sections, cross-locale keyword search, adminOnly filter, always-mounted search-actions row Once, globally, when authenticated
CommandPaletteTrigger React component Discoverable "Search… ⌘K" search-box button (so users find the palette); dispatches command-palette:open Sidebar / header
useGoToShortcuts React hook g-prefix two-key navigation, Korean-IME-safe (e.code fallback) Call once where the palette mounts
Toaster React component Renders the toast queue (bottom-center) Once at the app root
toast(msg, kind?) function Show a transient toast (kind: "ok" | "err" | "info"); returns id, dismissable Anywhere
DialogHost React component Renders the pending uiConfirm / uiPrompt Once at the app root
uiConfirm(opts) Promise Modal confirm; resolves boolean Replaces window.confirm
uiPrompt(opts) Promise Modal text prompt; resolves string | null Replaces window.prompt
DeployInfo React component "deployed <sha> · <rel time>" badge; renders null when no build env App-info section (settings / backoffice) — not a footer
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 />)
useInstallPrompt() React hook Lower-level — returns { canPrompt, promptInstall, isIOS, isStandalone } for apps that want to render their own UI Any client component
usePushPermission() React hook Web Push permission state machine — { state, supported, canPrompt, isBlocked, needsInstall, prompt() }. Permission + affordance only; subscription POST stays app-side Any client component
PushEnableRow React component One presentational push-permission row — used both inside <NotificationBell push>'s popover and standalone on a settings page <NotificationBell push> popover, settings page
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
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
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
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
useSessionState React hook Same shape as useRouteState but backed by sessionStorage, keyed per route Scroll offset, cmdk query, unsubmitted form draft
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
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
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" })
HttpError class Thrown for every non-2xx; carries status, ref, body — drop err.ref into <ErrorPage refCode={...}> try { … } catch (e) { if (e instanceof HttpError) … }
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
signInUrl / signOutUrl / signIn / signOut functions oauth2-proxy sign-in/out URL builders + navigation helpers Login buttons, post-auth redirects
EmptyState React component "Nothing here yet" card; title + optional description / action / icon, role="status" Empty lists, empty search results
CopyButton React component Token-styled copy button with success-state flip ("복사" → "복사됨"); fires a toast on success/error Secret reveal, token / slug / ref copy
useClipboard() React hook Lower-level — { copied, copy(value) }; clipboard API + legacy fallback When you want to render your own copy UI
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
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)
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
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
AdminBadge React component Small "관리자 전용" pill Inline next to the page title
BackofficeLayout React component Page-head with title + AdminBadge + actions slot + body Backoffice / admin-console route layout
isAdminLike(input) function The check behind AdminGate exposed as a pure function Imperative gates / route guards
AppInfoSection React component Canonical "앱 정보" card — name, description, app version, build (<DeployInfo>), links, free-form rows Settings / backoffice "About" route
PageContainer React component Responsive narrow / regular / wide content measure with mobile-safe gutters Once around a page body
PageHeader React component Restrained page title hierarchy with optional kicker, description, actions, and compact density First child of PageContainer
SettingsGroup / SettingsRow React components Canonical divided settings rows with a separate danger-zone tone Settings routes; compose with AppInfoSection
formatRelTime(when) function "3분 전" / "in 2 hours" via Intl.RelativeTimeFormat; locale from the document Lists, activity feeds, anywhere "ago" reads right
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
RelTime React component Auto-refreshing relative-time label (<time dateTime> with absolute time as title) Anywhere formatRelTime would otherwise need re-renders
UserMenu React component Avatar trigger + dropdown with display name, "내 정보" link, "로그아웃". Renders "로그인" when me is null Once in the app header (desktop + mobile)
Avatar React component Round profile picture; falls back to an initial letter on a token-colored circle Stand-alone in lists, comments, etc.
crossLocaleKeywords(dicts, getter) function Build a cmdk keywords string that matches in ko AND en Inline when defining items
openCommandPalette() function Dispatches the open event from anywhere Custom triggers
useGoToShortcuts / setTheme / getTheme / noFlashThemeScript helpers Theme set/get + the <head> no-flash snippet for the [data-theme] dark convention At/before first paint

Helper-only entry (no React, safe for build-time / non-React runtimes): import { … } from "@etamong-playground/ui/helpers" — re-exports crossLocaleKeywords, shortcutKey, noFlashThemeScript, getTheme/setTheme, openCommandPalette, COMMAND_PALETTE_OPEN_EVENT, CODE_TO_KEY.

Test-only entry: import { … } from "@etamong-playground/ui/testing" — viewport-fit assertions, MSW handlers for the fleet's standard /me + /healthz + httperr shapes, and a Playwright fleetTest fixture (ko-KR + Asia/Seoul + canonical viewport). msw and @playwright/test are not declared as peer dependencies — they're not needed to consume the main entry, and declaring them (even as optional peers) drags them into some tools' production dependency reports. Install whichever one you actually import as your own devDependency; this entry is tree-shaken per-import so an app that only uses the MSW helpers never needs Playwright installed, and vice versa.

RUM entry: import { initRum, pushApiError } from "@etamong-playground/ui/rum" — the fleet real-user-monitoring init (Grafana Faro under the hood): web vitals, unhandled errors, session tracking, page-lifecycle breadcrumbs, and ref-code correlation with the server error log (wire pushApiError as createFetch's onError). @grafana/faro-web-sdk is a declared optional peer dependency — deliberately, unlike the testing entry's msw/@playwright/test (dropped from peers in planning#976 because dev-only test deps counted as production deps in some consumers' license reports): faro-web-sdk is a genuine production runtime dependency for RUM adopters, so keeping it a declared optional peer gives adopters pnpm's "you forgot to install it" warning while non-adopters install nothing. The entry no-ops outside a browser and never throws out of app boot. Call initRum({ app, version, apiKey }) once at app entry; the apiKey arrives at build time (see the fleet RUM docs for provisioning).

Where to mount the hosts (Next vs Vite)

<Toaster />, <DialogHost />, and <CommandPalette /> use React state and event listeners — they need to live in a client component.

  • Vite — just drop them in main.tsx/App.tsx (the whole app is client).

  • Next.js — server layouts can't render them directly. Make a tiny client wrapper and render that in app/layout.tsx:

    // components/notifications.tsx
    "use client";
    import { Toaster, DialogHost } from "@etamong-playground/ui";
    export function Notifications() { return (<><Toaster /><DialogHost /></>); }

    Then <Notifications /> in the server-rendered root layout.

The CommandPaletteTrigger and useGoToShortcuts likewise need a client boundary (they listen for keydown / dispatch events).

Install

Consumers resolve @etamong-playground/* from the GitHub Packages registry. In the app's .npmrc:

@etamong-playground:registry=https://npm.pkg.github.com/
pnpm add @etamong-playground/ui

Design tokens

Import once at the app root:

import "@etamong-playground/ui/styles.css";

The stylesheet only paints the library's own components — the page itself is the app's job. Forgetting to wire the shell renders dark-mode text invisible on the browser's default white body (ui#21). Opt in with body.etu-page instead of hand-rolling the same rule:

<body className="etu-page">

That's the whole thing — it's equivalent to:

body.etu-page {
  margin: 0;
  background: var(--etu-bg);
  color: var(--etu-text);
  font-family: var(--etu-font);
}

It's a scoped body.etu-page class, never a bare body {} rule — the library stays safe to import into any app, including shadcn/Tailwind apps that already own their own body styling. Skip the class if your app paints its own page background.

Every component is styled from namespaced --etu-* tokens (light defaults on :root, dark under either [data-theme="dark"] (Vite convention) or the .dark class (Next/shadcn convention)) — deliberately prefixed so this file is safe to import into any app, including shadcn/Tailwind apps that already own --accent/--border/--ring.

The token system (v0.42 overhaul — planning#1081)

Semantic tokens, grouped:

Group Tokens
Neutrals --etu-bg, --etu-surface, --etu-surface-2, --etu-surface-3, --etu-border, --etu-border-strong, --etu-text, --etu-text-muted, --etu-text-subtle
Accent --etu-accent, --etu-accent-strong, --etu-accent-text, --etu-on-accent, --etu-accent-soft
Status --etu-ok, --etu-warn, --etu-err (+ --etu-danger alias), each with a -soft tint
Focus --etu-ring — the focus-visible outline color
Radius --etu-r-sm (8px), --etu-r (12px), --etu-r-lg (16px), --etu-r-full (999px)
Type scale --etu-fs-caption--etu-fs-3xl, --etu-lh / --etu-lh-tight, --etu-fw-medium / -semibold / -bold
Spacing --etu-space-1 (4px) … --etu-space-8 (64px)
Page-width --etu-page-w-narrow (520px), --etu-page-w (680px), --etu-page-w-wide (1080px) — backs .etu-page-col
Motion --etu-t-fast (120ms), --etu-t (160ms), --etu-ease
Elevation --etu-shadow-sm, --etu-shadow

The top rungs of the type and spacing scales (--etu-fs-3xl, --etu-space-8) round out the scale for app-facing use (page titles, section gutters) — no library component consumes them directly.

Color-mix cascade: --etu-accent-strong, --etu-accent-text, --etu-accent-soft, and the --etu-ok/-warn/-err-soft tints all ship a solid default (an explicit hex per theme). On engines that support color-mix(), an @supports (color: color-mix(in oklab, red 50%, transparent)) block re-derives every one of those tokens as color-mix(in oklab, <base> X%, <etu-bg|black|white>) over the semantic base — so overriding just the base cascades into every derived tint on capable engines:

:root, .dark {
  --etu-accent: var(--primary);
  --etu-surface: var(--popover);
  --etu-border: var(--border);
  --etu-text: var(--popover-foreground);
}

— and every derived tint (--etu-accent-soft, --etu-accent-strong, …) follows automatically wherever color-mix() is supported. On engines without it (old WebViews, kiosk Safari), the tints stay pinned at the solid default palette rather than going invalid-and-transparent. Pin a -soft variable directly instead if you want an exact tint regardless of engine.

--etu-ring is always solidvar(--etu-accent), never color-mix(). Focus outlines need reliable ≥3:1 contrast (WCAG 1.4.11), so the ring is deliberately excluded from the @supports gate and can never silently vanish on an engine without color-mix() support.

Elevation discipline: in-page surfaces (cards, panels) use --etu-border / --etu-border-strong, never a shadow — dark-mode borders are white-alpha so they read correctly on whatever surface sits beneath. --etu-shadow-sm / --etu-shadow are reserved for true overlays: the command palette, dialogs, menus.

Radix-steps mapping: the neutral ramp follows the Radix Colors scale steps — bg=step 1, surface=step 2, surface-2=step 3, surface-3=steps 4/5 (hover/raised), text-subtle=step 10, text-muted=step 11, text=step 12. Dark values are Radix slateDark. Useful when picking an in-between shade that isn't already a named token.

Utility classes built on the scale: .etu-h1 / .etu-h2 / .etu-h3 (headings), .etu-caption (muted small text), .etu-tnum (tabular numerals — see "Typography" below), .etu-page-col (+ --narrow / --wide modifiers, a centered reading-width column), .etu-badge (soft-tint status/label pill), .etu-input (+ --sm, standalone text field).

.etu-input: the standalone text field (promoted from meloetta). Carries border/background/focus-ring styling only — width and layout (flex, max-width) stay with the caller. --sm is the compact modifier for dense toolbars.

<input className="etu-input" placeholder="Setlist name" />
<input className="etu-input etu-input--sm" />

.etu-badge: a pill for inline status/label text — pairs a -soft background with the matching solid text color.

<span className="etu-badge">default</span>
<span className="etu-badge etu-badge--accent">accent</span>
<span className="etu-badge etu-badge--ok">ok</span>
<span className="etu-badge etu-badge--warn">warn</span>
<span className="etu-badge etu-badge--err">err</span>

Bare .etu-badge (no modifier) uses --etu-surface-2 / --etu-text-muted — a neutral pill for non-status labels. The four modifiers (--accent / --ok / --warn / --err) each pair the matching -soft background token with its solid text token.

For apps using the [data-theme] dark-mode convention, set the theme before first paint to avoid a flash:

import { noFlashThemeScript } from "@etamong-playground/ui/helpers";
// Next: <script dangerouslySetInnerHTML={{ __html: noFlashThemeScript("myapp") }} />
// Vite: inline the same string in index.html <head>.

getTheme("myapp") / setTheme("myapp", "dark") read and toggle it.

Behavioral notes for 0.42

Visible changes an app might notice after bumping to 0.42, without any code change on the app's side:

  • body.etu-page now sets line-height: 1.55 (--etu-lh), not the browser default (~1.2). Apps opted into .etu-page get slightly taller line boxes throughout.
  • Status / policy banner ambers moved to the --etu-warn token. Any component that previously hardcoded an amber/orange now reads --etu-warn / --etu-warn-soft, so overriding --etu-warn re-themes them consistently.
  • Focus rings are solid. --etu-ring is new in 0.42 and pins to var(--etu-accent) unconditionally, never a color-mix() tint (see "Color-mix cascade" above) — outlines stay at reliable ≥3:1 contrast and never disappear on engines without color-mix().
  • Radius bumped on a few overlay/card surfaces. The dialog and ErrorPage card move --etu-r-lg from 12px → 16px; the UserMenu dropdown and NotificationBell popover move --etu-r from 9.6px → 12px. Purely visual — no markup/class changes.
  • Navbar frosted/scrolled state is now @supports-gated. The translucent background: color-mix(...) + backdrop-filter blur on .etu-navbar--scrolled only apply when the engine supports both backdrop-filter (or its -webkit- prefix) and color-mix(). Elsewhere it falls back to a solid var(--etu-surface) background — previously a partial-support engine could apply the translucent background without the blur and let scrolled content bleed through.
  • Status/policy banners now consume the shared soft tints. StatusBanner (degraded/maintenance/outage) and the install/policy banner family read --etu-warn-soft / --etu-accent-soft / --etu-err-soft instead of a private inline color-mix(). The percentages differ slightly from the old ad hoc mixes (10–16% depending on token/theme, see "Color-mix cascade" above) but track the same base tokens, so an app overriding --etu-warn/--etu-accent/--etu-err now re-themes the banners too.

Typography

--etu-font leads with "Pretendard Variable" but does not bundle it — the library stays safe to import into any app without forcing a webfont download on apps that already ship their own. Apps that want the etamong look load Pretendard themselves, before the ui styles import so it wins the cascade:

Vite apps:

pnpm add pretendard
// main.tsx — BEFORE the ui styles import
import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
import "@etamong-playground/ui/styles.css";

Next.js apps — use next/font/local with the variable woff2 shipped inside the pretendard npm package, and wire its CSS variable into --etu-font:

// fonts.ts
import localFont from "next/font/local";

export const pretendard = localFont({
  src: "../node_modules/pretendard/dist/web/variable/woff2/PretendardVariable.woff2",
  variable: "--font-pretendard",
  display: "swap",
});
:root {
  --etu-font: var(--font-pretendard), "Pretendard Variable", -apple-system, …;
}

pretendard is an optional peer dependency (>=1.3.9) — declaring it in your app keeps the resolved version aligned with what the library was built against, but nothing breaks if you skip it; you just fall through to system fonts.

Fonts: the showcase (this is a public repo) bundles a Pretendard Variable subset woff2 via the pretendard npm package. Pretendard is licensed under the SIL Open Font License 1.1; the full license text ships at showcase/public/PRETENDARD-OFL.txt (and is served at /PRETENDARD-OFL.txt in the deployed showcase).

Korean tracking correction: Hangul webfont spacing runs wide at default tracking, so the stylesheet applies -0.011em letter-spacing, scoped to :lang(ko) so Latin/numeral-heavy EN UIs stay at 0. It applies in two independent ways, both driven off the document's lang, not each other:

  1. Opt-in on the page shellbody.etu-page:lang(ko). Only fires for apps that opted into body.etu-page (see "Design tokens" above).
  2. Always-on for library components:lang(ko) scoped to the command palette, toast, dialog, install banner, error page, navbar, sidebar, mobile tab bar, backoffice, UserMenu dropdown, NotificationBell popover, and DocsHub. These get the correction whenever the document is :lang(ko), whether or not the app uses body.etu-page.

Set <html lang="ko"> (or a per-element lang="ko") for either path to apply.

Data numerals: use .etu-tnum (font-variant-numeric: tabular-nums) on any data-bearing numeral — timers, fares, counters, table cells — so live updates don't shift the surrounding layout width.

Command palette

Mount once, globally, when authenticated:

import { CommandPalette, crossLocaleKeywords } from "@etamong-playground/ui";
import { Home, Calendar } from "lucide-react";

const dicts = [ko, en];
const sections = [
  {
    id: "pages",
    heading: t.palette.pages,
    items: [
      { id: "home", label: t.nav.home, icon: <Home size={16} />, href: "/",
        keywords: crossLocaleKeywords(dicts, (d) => d.nav.home) },
      { id: "schedules", label: t.nav.schedules, icon: <Calendar size={16} />,
        href: "/schedules",
        keywords: crossLocaleKeywords(dicts, (d) => d.nav.schedules) },
    ],
  },
];

<CommandPalette sections={sections} isAdmin={isAdmin}
  onNavigate={(href) => router.push(href)}
  labels={{ placeholder: t.palette.placeholder, noResults: t.palette.noResults }} />

Opens on ⌘K / Ctrl+K, on / (unless typing), and on the command-palette:open DOM event (openCommandPalette()). adminOnly items are hidden unless isAdmin. Search filters on keywords — build them with crossLocaleKeywords so ko/en both match. Icons are your nodes; the package pins no icon library.

Entities sections (load real content)

A nav-only palette returns "No results" when a user searches for their own site/plan/vault by name. Load the user's real objects (sites, plans, schedules, vaults) and add them as a data-driven section — each linking to its detail route. This is part of the convention, not optional, for any app with a list of named objects (see concepts/command-palette).

const sections = useMemo(() => {
  const out: CommandSection[] = [navSection];
  if (sites.length) {
    out.push({
      id: "sites",
      heading: t.nav.sites,
      items: sites.map((s) => ({
        id: "site:" + s.slug, label: s.name, sublabel: s.slug,
        keywords: `${s.name} ${s.slug}`,
        onSelect: () => router.push(`/sites/${s.slug}`),
      })),
    });
  }
  return out;
}, [sites, t]);

Search actions (catch-all "search for …" row)

searchActions is a bottom always-mounted group that receives the live query — so an unmatched search still leads somewhere (a search/list route carrying the text):

const searchActions: CommandSearchAction[] = [
  { id: "search-all", label: t.palette.searchEverything,
    run: (q) => router.push(`/search?q=${encodeURIComponent(q)}`) },
];
<CommandPalette sections={sections} searchActions={searchActions}  />

CommandPaletteTrigger

A token-styled "Search… ⌘K" search-box button — drop it in the sidebar or header so users discover the palette. Clicking it dispatches command-palette:open, no prop-drilling needed:

import { CommandPaletteTrigger } from "@etamong-playground/ui";

<CommandPaletteTrigger label={t.palette.search} />

Shows a magnifier + the localized label + ⌘K / Ctrl+K (auto-detected by platform). The discoverable trigger is the difference between users finding the palette vs. not — every multi-surface app should ship it.

Go-to shortcuts

Two-key navigation (g then a letter), Korean-IME-safe:

import { useGoToShortcuts } from "@etamong-playground/ui";

const pending = useGoToShortcuts(
  [{ key: "h", href: "/" }, { key: "s", href: "/schedules" },
   { key: "m", href: "/admin/members", adminOnly: true }],
  (href) => router.push(href),
  { isAdmin },
);
// render `pending` ("g" | null) as a small indicator

Build

pnpm install
pnpm build      # tsup → dist (esm + cjs + d.ts), styles.css copied verbatim
pnpm typecheck

CI runs pnpm typecheck + pnpm build on every pull request.

Showcase

A live component showcase is rebuilt and redeployed automatically on every push to main:

https://ui-showcase.custom-site.m.etamong.com/

The showcase dogfoods the library — the shell itself uses Sidebar, MobileTabBar, NavigationBar, CommandPalette, theme, and i18n from this package — so it doubles as a real integration test alongside the unit tests.

Run locally:

pnpm install
pnpm showcase:dev   # Vite dev server with HMR, aliased to library source

Build the showcase:

pnpm showcase:build   # tsc + vite build → showcase/dist/

The Vite config aliases @etamong-playground/ui to ../src/index.ts so the showcase always reflects the working tree — no separate library build needed.

Auto-deploy: the hosting platform watches this repo's main, builds the showcase in a sandboxed job, and publishes the result automatically — no deploy tokens or CI secrets in this repo. CI still builds the showcase on every PR to catch compile breakage early.

E2E tests: a Playwright suite in e2e/ drives the showcase to verify component behaviour end-to-end (palette overlay dismiss, dialog locale defaults, toast, theme persistence, mobile layout). It uses fleetTest from src/testing-playwright so the same fixture conventions apply here as in consuming apps. Run with pnpm e2e; CI runs it as a parallel e2e job.

Notifications

Mount the hosts once at the app root (in Next, behind a "use client" wrapper):

import { Toaster, DialogHost, toast, uiConfirm, uiPrompt, dismissToast } from "@etamong-playground/ui";

// app root (client boundary in Next):
//   <Toaster /> <DialogHost />

// Transient feedback — returns an id so you can dismiss early.
const id = toast("저장됐어요", "ok", 3000);     // kind: "ok" | "err" | "info"
dismissToast(id);

// Modal confirm — resolves boolean. `danger` styles the confirm red.
if (await uiConfirm({
  title: "삭제할까요?",
  body: "되돌릴 수 없어요.",
  confirmLabel: "삭제", cancelLabel: "취소", danger: true,
})) { /* … */ }

// Modal text prompt — resolves string | null (null on cancel).
const name = await uiPrompt({
  title: "이름",
  placeholder: "내 일정",
  defaultValue: "내 일정",
  confirmLabel: "만들기",
});

uiConfirm / uiPrompt are promise-based — drop-in replacements for window.confirm / window.prompt. An app with its own local (title, opts) helpers can keep them as thin adapters that delegate to these (see festplan's uiConfirm(title, opts) adapter).

<Toaster /> and <DialogHost /> are singleton hosts — mount each exactly once at the root. The functions (toast, uiConfirm, uiPrompt) talk to the mounted host via a module-level pub/sub, so call them from anywhere.

DeployInfo (build-version badge)

import { DeployInfo } from "@etamong-playground/ui";

// In an "앱 정보 / App info" section of /settings (preferred) or the backoffice:
<section>
  <h2>{t.settings.appInfo}</h2>
  <DeployInfo
    version={import.meta.env.VITE_BUILD_SHA}    // Vite
    builtAt={import.meta.env.VITE_BUILD_TIME}
    label={t.settings.deployedAt}
  />
</section>
// Next: process.env.NEXT_PUBLIC_BUILD_SHA / _BUILD_TIME

Shows deployed <sha> · <relative time> (absolute timestamp in the tooltip); renders null when neither value is set, so it's safe to mount unconditionally — local dev shows nothing.

Placementsettings → 앱 정보 if the app has a settings page; otherwise the backoffice / console. Apps with neither (a small dashboard-only app like minccino) get a small /about page linked from the account area. Not a global footer — that was the first pass, reworked per user feedback. For labeled rows (버전 / 배포 시각) instead of the compact badge, see the in-app implementations (res-train /settings, festplan #/settings, pages admin Access view). Baking the build env in CI: see concepts/build-version-info.

InstallBanner (PWA install)

Mobile-only dismissable banner that does the right thing per platform:

import { InstallBanner } from "@etamong-playground/ui";

// Once near the app root (same boundary as <Toaster />):
<InstallBanner
  label={t.install.hint}              // "홈 화면에 추가하면 더 빠르게!"
  iosHint={t.install.iosHint}         // "공유 → 홈 화면에 추가"
  installLabel={t.install.cta}        // "설치"
  storageKey="myapp-install-banner"   // per-app, to avoid clashes
/>
  • Chrome / Android — captures beforeinstallprompt, shows an install button that fires the real native prompt.
  • iOS Safari — no programmatic install. Shows a short "Share → Add to Home Screen" hint instead.
  • Already installed (display-mode: standalone) — renders nothing.
  • Dismiss + cooldown — clicking the close button hides the banner for 3 days; gives up after 3 dismissals. Override with cooldownMs / maxDismiss.
  • Hidden on min-width: 768px. For desktop, drop a small button using useInstallPrompt() instead.

This is the concepts/spa-navigation-state rule's PWA-install requirement packaged once — apps drop the component in, no per-app beforeinstallprompt / iOS-detection boilerplate to maintain.

// Lower-level hook if you want to render your own UI:
const { canPrompt, promptInstall, isIOS, isStandalone } = useInstallPrompt();

Push permission (usePushPermission + PushEnableRow)

The package owns push permission + affordance only. Each app has its own /api/push/... endpoints, so the actual registration.pushManager.subscribe(...)

  • subscription POST stays app-side, wired through a callback. No VAPID keys or endpoints are baked into the package.
import { NotificationBell, PushEnableRow, usePushPermission } from "@etamong-playground/ui";

const push = usePushPermission();
// { state, supported, canPrompt, isBlocked, needsInstall, prompt() }

async function onEnabled() {
  const reg = await navigator.serviceWorker.ready;
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: myVapidPublicKeyBytes,
  });
  await api.post("/push/subscribe", sub.toJSON());
}

// 1. Bell popover (primary) — the user already opened the bell, so intent
//    is already demonstrated. No cold interruption.
<NotificationBell items={items} push={{ permission: push, onEnabled }} />

// 2. Settings page (the permanent, explicit control):
<PushEnableRow permission={push} onEnabled={onEnabled} showGrantedConfirmation />

// 3. Contextual moment (highest conversion) — call prompt() directly at a
//    point of intent, e.g. right after a booking is created:
<button onClick={async () => { if ((await push.prompt()) === "granted") onEnabled(); }}>
  결과를 알림으로 받으시겠어요?
</button>

No banner. The design doc for this (planning#1140) explicitly rules out a third full-width strip — the package already ships StatusBanner and the install/policy banner family, and stacking a third eats the mobile viewport and reads as nagging. The ask lives in the bell popover, an app-triggered contextual moment, and the settings row.

States, handled correctly:

  • "unsupported" — no Notification / PushManager / service-worker (older WebViews, kiosk browsers). PushEnableRow renders null — never a dead button.
  • "needs-install" — iOS Safari only allows web push once the PWA is installed to the Home Screen. Detected via the same isIOS/isStandalone signals useInstallPrompt already computes (usePushPermission calls that hook internally, so the two can't drift). Shows the install path (text-only, same reasoning as <InstallBanner>'s iOS branch having no button), never a permission button that would silently fail.
  • "denied" — browsers never re-prompt after a denial. prompt() becomes a no-op (returns "denied" without calling the native API), and PushEnableRow shows a re-enable explanation instead of a button. Never loop a prompt the platform will refuse.
  • "default" — the enable affordance: title, body, and a CTA button that calls prompt() on click (must run from a user gesture — prompt() is safe to call directly from an onClick).
  • "granted"PushEnableRow renders null by default, or a quiet one-line confirmation with showGrantedConfirmation.

<NotificationBell push> discovery. When push is set and permission is "default", the trigger also carries a quiet hollow-ring "setup dot" (.etu-notif-bell-setup-dot, or the row-variant's --setup badge/dot modifiers) — visually distinct from the filled unread badge, and suppressed whenever there's a real unread count to show instead. One nudge, not a recurring nag: it disappears the moment the user decides, either way.

All copy is overridable via labels (same Korean-default / prop-override pattern as every other component here):

<PushEnableRow
  permission={push}
  labels={{
    enableTitle: "새 소식을 알림으로 받아보세요",
    enableCta: "허용",
  }}
/>

StatusBanner (service-admin declared-incident strip)

Sticky top-of-app strip that surfaces operator-declared incidents and downtime windows. Polls the same-origin /.well-known/maintenance.json endpoint served on every routed host — no app-side wiring, no API client to maintain.

import { StatusBanner } from "@etamong-playground/ui";

// Once at the app root, alongside <Toaster /> / <InstallBanner />:
<StatusBanner />
  • Renders only for degraded / maintenance. outage incidents take the origin offline and serve a 503 maintenance page directly — the banner has nothing to do in that case.
  • Language is auto-picked from document.documentElement.lang (ko → Korean, else English). Override with lang="ko" | "en".
  • Session-dismissable per (severity, updated_at): closing the banner hides it for the session, but a fresh incident (different severity or updated_at) reappears. Pass dismissible={false} to disable.
  • ETA + message rendered when the operator set them; falls back to the other-language copy if one side is empty.
  • Renders null while loading / on endpoint error / when not enabled — safe to mount unconditionally.
  • Polls every 60s by default (the endpoint sends cache-control: public, max-age=30, so 60s guarantees a fresh value between polls). Pauses while document.hidden, immediately re-fetches on visibility return.

The endpoint contract is documented in the status-hub admin app's README under "Status-hub contracts".

// Lower-level hook if you want to render your own UI (header pill,
// notifications page entry, etc.):
const status = useStatusBanner();
if (status?.enabled && status.severity !== "outage") {
  // status: { enabled, severity, message_ko, message_en, eta_iso,
  //           retry_after_seconds, tags, updated_at }
}

ErrorPage

Friendly full-page error surface. Pairs with the httperr ref pattern (see concepts/user-facing-error-messages): show the clean message + the 8-hex reference code, never the raw error / stack trace / repo path.

import { ErrorPage } from "@etamong-playground/ui";

// Next.js error.tsx (per-route error boundary):
"use client";
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <ErrorPage
      title="문제가 발생했어요"
      description="잠시 후 다시 시도해 주세요."
      refCode={error.digest}            // or whatever ref your backend returns
      onRetry={reset}
      onHome={() => location.assign("/")}
    />
  );
}
// Vite + React Router 404 / catch-all:
<Route path="*" element={
  <ErrorPage
    title="페이지를 찾을 수 없어요"
    description="주소를 다시 확인해 주세요."
    onHome={() => navigate("/")}
  />
} />

Props:

  • title, description — Korean defaults; override per-route.
  • refCode — the 8-hex ref from your backend (httperr produces this). Shown discreetly under the actions so the user can quote it.
  • onRetry, onHome — optional handlers. Render their buttons only when set.
  • labels — override the retry / home / refLabel strings (defaults are Korean).
  • icon — replace the default circle-alert glyph with your own node.

The component is token-styled (--etu-*), so it inherits the app's dark/light theme automatically. It contains no repo links, no file paths, no stack traces — by design (concepts/no-repo-exposure).

useRouteState / useSessionState

Two hooks for the "F5 keeps me on this view, with the same tab/filter/sort selected" half of the SPA navigation contract (concepts/spa-navigation-state). Router-agnostic — they read and write window.history directly, so they work with hash routers, path routers, and apps without a router lib at all.

import { useRouteState, useSessionState } from "@etamong-playground/ui";

// URL-backed: ends up in the query string (?tab=members), restores on
// refresh, syncs with browser back/forward.
const [tab, setTab] = useRouteState<"overview" | "deploys" | "members">("tab", "overview");

// Pretty URL — pass plain string codecs so the value isn't JSON-quoted:
const [tab2, setTab2] = useRouteState("tab", "overview", {
  serialize: (v) => v,
  deserialize: (raw) => raw as typeof tab,
});

// sessionStorage-backed: never enters the URL, scoped per route by default.
const [draft, setDraft] = useSessionState("draft", "");
const [scroll, setScroll] = useSessionState("scrollY", 0);

Both hooks have the same [value, setValue] shape as useState, including the functional updater form (setTab(prev => prev === "a" ? "b" : "a")).

Options:

  • serialize / deserialize — defaults to JSON.stringify / JSON.parse, so booleans, numbers, and arrays round-trip without extra work. Override for cleaner URLs.
  • replace (useRouteState only) — defaults to true, so noisy state like search-as-you-type doesn't pile up in the back history. Set replace: false when each change should be a back-button stop.
  • scope (useSessionState only) — overrides the default per-route scope (pathname + hash). Pass a static string for state that should span routes.

The hooks listen on popstate and hashchange, plus a private etu:route-state event they fire after their own writes — so multiple components reading the same key stay in sync.

SSR-safe: on the server they return the initial value; the URL/session read happens on mount in an effect.

useInAppBack / BackButton

The other half of the SPA navigation contract: the back button — both the browser's and your in-UI one — should stay inside the app.

useInAppBack tracks an in-app history stack by writing a marker into history.state on every in-app navigation. canGoBack is true when at least one in-app entry sits behind the current one; goBack() calls history.back() when true, otherwise it runs the fallback so cold-entry users (someone landed on a deep link from outside) still go somewhere sensible.

The canonical one-liner (v0.27.0+) — <BackButton> mounts the hook internally, so apps that don't need the hook's values elsewhere just drop in:

import { BackButton } from "@etamong-playground/ui";

// Hash/path-routed apps — string fallback (pushState + popstate)
<BackButton fallback="/more" />

// Next.js / React Router — pass the router action as a callback
<BackButton fallback={() => router.push("/more")} />

Mount the hook explicitly when you need the values somewhere besides the button (e.g. swipe gesture, keyboard shortcut, custom layout):

import { useInAppBack, BackButton } from "@etamong-playground/ui";

function App() {
  const back = useInAppBack({ fallback: "/more" });

  function openSite(slug: string) {
    back.push(`#/sites/${slug}`);   // grows the in-app stack
  }
  function changeTab(tab: string) {
    back.replace(`#/sites/foo/${tab}`);  // does NOT grow the stack
  }

  return (
    <>
      <BackButton {...back} />
      {/* …rest of the app */}
    </>
  );
}

Notes:

  • The hook is router-agnostic — it reads and writes window.history directly. Wire it through your router's push/replace or use the hook's own push/replace helpers.
  • Pairs cleanly with useRouteState, which uses replaceState. URL- synced in-page state (tab, filter) doesn't grow the back stack.
  • The first mount marks the current entry as in-app at depth 0, so any later push() has a baseline to count from. Browser back across a push restores the marker; a hard reload starts fresh from 0 (correct: the page IS the entry point).
  • <BackButton> renders when there's an in-app entry behind us OR when fallback/onClick is set OR alwaysShow is on. Default label: "뒤로"; override via label.
  • The onExit option on useInAppBack (from v0.8.0) is @deprecated in favour of fallback. It still works — calls go through the same code path — but the JSDoc nudges new code toward fallback.

createFetch / HttpError

A small fetch wrapper that bakes in the house conventions: the httperr JSON shape ({error, ref}), sign-in redirect on 401, JSON in / JSON out by default.

import { createFetch, HttpError } from "@etamong-playground/ui";

export const api = createFetch({ baseUrl: "/api" });

// Then anywhere in the app:
const me = await api.get<{ email: string; is_admin: boolean }>("/me");
const created = await api.post<Site>("/sites", { name: "blog", visibility: "public" });
const list = await api.get<Site[]>("/sites", { query: { q: "blog" } });

On a non-2xx response, the wrapper throws an HttpError that carries the server's ref code. Drop it into <ErrorPage>:

try {
  await api.post("/sites", payload);
} catch (e) {
  if (e instanceof HttpError) {
    return <ErrorPage description={e.message} refCode={e.ref} onRetry={retry} />;
  }
  throw e;
}

Options:

  • baseUrl — prepended to relative paths.
  • onAuthError — called on 401. Default: redirects to /oauth2/start?rd=<current url> (the oauth2-proxy sign-in flow). Pass () => {} to disable.
  • onError — fires for every non-2xx after the error is built but before it's thrown. Use for telemetry / global toast; doesn't suppress the throw.
  • headers — static object or factory. Common case: an Authorization header for non-browser callers (CLI / cron).
  • fetchImpl — override the global fetch (tests / SSR).

Per-call options on every method: query (object → query string), headers, signal (AbortController), raw: true (return the raw Response without JSON parsing — for downloads / streaming).

The wrapper:

  • sets Accept: application/json and credentials: "same-origin" by default (works with cookie-based browser sessions);
  • serializes plain-object bodies to JSON and sets Content-Type: application/json; passes FormData / Blob / strings through untouched;
  • handles 204 / empty responses (resolves undefined);
  • returns Response directly when raw: true.

useMe + sign-in / sign-out helpers

Apps in the fleet sit behind oauth2-proxy and expose a small /me endpoint with the authenticated identity. This hook + the URL helpers cover the repeated wiring.

import { useMe, signIn, signOut, type BaseMe } from "@etamong-playground/ui";

// App-specific shape — extends BaseMe ({ email, preferred_username?,
// is_admin?, roles? }).
interface Me extends BaseMe {
  can_create_apps?: boolean;
}

function Header() {
  const { me, loading, error } = useMe<Me>();
  if (loading) return null;
  if (error || !me) return <button onClick={() => signIn()}>로그인</button>;
  return (
    <div>
      {me.preferred_username ?? me.email}
      {me.is_admin && <span className="badge">관리자</span>}
      <button onClick={() => signOut("/")}>로그아웃</button>
    </div>
  );
}

Options:

  • endpoint — default /api/me. Ignored when fetcher is set.
  • fetcher — pass () => api.get<Me>("/me") when the app's API base path differs from the default, or to inherit createFetch's error handling.
  • treat401AsAnonymous — default true. 401 from the default fetcher resolves to me: null, error: null. Set false if your app considers an unauthenticated user a hard error. Ignored with a custom fetcher.

refresh() fires an etu:me-refresh event so multiple useMe consumers re-fetch together (e.g. after a token-add flow flips can_create_apps).

The URL helpers follow the oauth2-proxy convention:

  • signInUrl(rd?)/oauth2/start?rd=<encoded> (default rd = current URL).
  • signOutUrl(rd?)/oauth2/sign_out?rd=<encoded> (default rd = /).
  • signIn(rd?) / signOut(rd?) navigate the browser to those URLs.

EmptyState

The "nothing here yet" card. Every list / grid view has one; this is the single one to use.

import { EmptyState } from "@etamong-playground/ui";

<EmptyState
  title="아직 사이트가 없어요"
  description="새 사이트를 만들어 시작해 보세요."
  action={<button className="cta" onClick={onNew}>새 사이트</button>}
/>

// Compact variant for sidebar / inline use:
<EmptyState compact title="결과 없음" description="검색어를 바꿔 보세요." />

Props:

  • title — required headline.
  • description — optional one-line description (ReactNode).
  • action — optional CTA / footnote node.
  • icon — replace the default cube glyph; pass null to omit.
  • compact — smaller padding + smaller type.

Marked role="status" for screen readers.

CopyButton + useClipboard

For the secret-reveal / token-copy / slug-copy / ref-copy moments. Pairs with the package's toast() for the "복사됨" confirmation, and falls back to a hidden <textarea> + document.execCommand("copy") when navigator.clipboard isn't available (non-https / older mobile).

import { CopyButton, useClipboard } from "@etamong-playground/ui";

// Standard text button:
<CopyButton value={token} />

// Icon-only, sitting next to a value display:
<code>{slug}</code> <CopyButton value={slug} iconOnly />

// Custom UI — useClipboard returns the state machine:
function MyButton({ value }) {
  const { copied, copy } = useClipboard();
  return (
    <button onClick={() => copy(value)}>
      {copied ? "✓ 복사됨" : "복사"}
    </button>
  );
}

<CopyButton> props:

  • value — required string to copy.
  • label / successLabel — default "복사" / "복사됨".
  • iconOnly — render just the icon (default: icon + label).
  • icon — override the default copy/check glyph; pass null to omit.
  • ariaLabel — used when iconOnly; defaults to label.
  • resetMs — how long the copied state lingers. Default 1500.
  • toastOnSuccess / toastOnError — toast text; pass null to suppress.

Service worker (registration + online-first SW recipe)

Two pieces for the planning concepts/pwa-service-worker rule: a registration helper for the app, and a generator for the SW file itself. The preset is biased toward online users see the latest build — the cache is only the offline safety net.

registerServiceWorker

import { registerServiceWorker } from "@etamong-playground/ui";

const sw = registerServiceWorker("/sw.js", {
  // Default behavior: toast says "새 버전이 준비됐어요. 새로고침할까요?"
  // and the next nav uses the new SW. autoReloadOnUpdate: true skips the
  // prompt and reloads as soon as the new SW is ready.
});

What the helper does on top of navigator.serviceWorker.register:

  • Calls registration.update() aggressively — on load, on visibilitychange → visible, and on a 2-minute interval — so a long-lived installed tab catches a new deploy without a manual reload.
  • Listens for controllerchange and reloads the page once when the new SW takes over. onActivate fires right before the reload so the app can persist transient state.
  • When a new SW finishes installing and is waiting, shows the "새 버전" toast. The waiting SW is activated when the user navigates / reloads, or you can call sw.applyUpdate() to do it programmatically.

Returns a handle: { registration, hasUpdate, applyUpdate, checkForUpdate, unregister }.

networkFirstSwSource

Generates the SW file itself. Use it from a build step so the version is the build SHA:

// build.mjs
import { networkFirstSwSource } from "@etamong-playground/ui";
import { writeFile } from "node:fs/promises";

const sha = process.env.BUILD_SHA ?? Date.now().toString(36);
await writeFile(
  "public/sw.js",
  networkFirstSwSource({
    version: sha,
    networkTimeoutMs: 3000,
    passThroughPrefixes: ["/oauth2/", "/sse/"],
  }),
);

What the recipe does:

  • Never intercepts non-GET, cross-origin requests, or any URL whose path starts with /api/ or one of passThroughPrefixes. Auth and live state always hit the network.
  • Navigations (request.mode === "navigate"): network-first with networkTimeoutMs (default 3s). If the network wins, the response is cached + returned. If the network times out (offline / flaky), the cached copy is served.
  • Same-origin GET assets: same network-first strategy — fresh wins online, cache covers offline.
  • Caches are versioned (etu-nav-<version> / etu-asset-<version>); on activate everything else is deleted, then clients.claim().
  • skipWaiting() on install + a SKIP_WAITING message handler so sw.applyUpdate() can force the takeover.

When not to use the preset:

  • The shortener single-segment-route family (/{code} reaches the apiserver, not the SPA) — keep the bespoke navigateFallbackAllowlist recipe in concepts/pwa-service-worker.
  • Push-only SWs (schedule-manager) — no caching at all.
  • Scoped stale-while-revalidate of specific safe-read endpoints (minccino) — narrower than this preset; keep the hand-rolled regex.

PWA cache strategy (fleet rule)

The user-visible symptom of getting this wrong: "even after deploy, the app keeps showing the old screen until I force-reload". The cure is online users always see the latest deploy; the cache is only the offline fallback, keyed by a build identifier so every deploy rolls the cache forward.

Two things every app must do:

  1. Use networkFirstSwSource() (or document why workbox precaching is required — and if so, gate the workbox revision/cacheNames per build as well).
  2. Pass a per-build version — never a hardcoded constant. The cache is versioned by etu-nav-<version> / etu-asset-<version>; on activate the SW deletes every cache that doesn't match. If version never changes, the cache never rolls over and offline-cached HTML/JS stays sticky.

A small Vite snippet that injects the git SHA at build time:

// vite.config.ts
import { execSync } from "node:child_process";
const sha = (() => {
  try { return execSync("git rev-parse --short HEAD").toString().trim(); }
  catch { return Date.now().toString(36); }
})();

export default defineConfig({
  define: { "import.meta.env.VITE_BUILD_SHA": JSON.stringify(sha) },
  // …
});

Then in a build hook:

import { networkFirstSwSource } from "@etamong-playground/ui";
await writeFile(
  "public/sw.js",
  networkFirstSwSource({ version: process.env.VITE_BUILD_SHA ?? sha }),
);

Apps using vite-plugin-pwa (festplan) get workbox autoUpdate by default, which is correct in theory but historically has shipped with hardcoded precache revisions that don't roll over per build. Verify the workbox config either (a) injects the SHA into the precache manifest, or (b) move the app to networkFirstSwSource(). Either is acceptable; both must be per-build versioned.

The canonical strategy is described in the concepts/pwa-cache-and-ios-shell design document.

iOS PWA shell (installIOSPwaShell)

The complaint pattern: install an etamong app to the iPhone home screen, launch it from there, and Korean body text looks "broken" / shrunk vs. Safari. iOS's automatic text-size-adjust kicks in in standalone mode because the Safari toolbar reservation is gone.

The styles.css reset already locks -webkit-text-size-adjust: 100% on html. installIOSPwaShell() is the runtime belt-and-braces — call it once from your app bootstrap:

import { installIOSPwaShell } from "@etamong-playground/ui";
installIOSPwaShell();

What it does:

  • Detects standalone (navigator.standalone === true OR matchMedia('(display-mode: standalone)').matches).
  • Adds html.etu-pwa-standalone; if also iOS, adds html.etu-ios-pwa.
  • Re-asserts the text-size-adjust lock via an inline style on <html> (defense against a late-mounted stylesheet that clobbers the reset).
  • Opt-in: if your <html> has data-etu-lock-zoom, also appends maximum-scale=1 to the viewport meta — kills the input-focus auto-zoom. Off by default because it also blocks accessibility zoom.

Apps that already follow concepts/ios-pwa-safe-area (viewport viewport-fit=cover, apple-mobile-web-app-* metas, safe-area padding on fixed bars) keep doing that — this helper is additive, just guarantees the font lock holds.

Backoffice scaffold (AdminGate + AdminBadge + BackofficeLayout)

The admin-gate + 관리자 전용 badge + page-head layout every backoffice route in the fleet re-implements. Pairs with useMe<T> — pass the me straight through.

import { useMe, AdminGate, BackofficeLayout } from "@etamong-playground/ui";

interface Me extends BaseMe { can_create_apps?: boolean }

function Console() {
  const { me } = useMe<Me>();
  return (
    <AdminGate
      me={me}
      emails={["admin@example.com", "ops@example.com"]}
      predicate={(m) => m.can_create_apps === true}
      fallback={<div>권한이 없어요.</div>}
    >
      <BackofficeLayout
        title="앱 콘솔"
        description="앱 생성·배포·롤백을 관리합니다."
        actions={<button onClick={onNewApp}>새 앱</button>}
      >
        <AppList />
      </BackofficeLayout>
    </AdminGate>
  );
}

The gate is a logical OR across these signals:

  • me.is_admin === true (always counts; no config needed).
  • emails — case-insensitive allowlist; useful for the LLM prompt-audit consoles where the admin set isn't expressed in the IdP.
  • roles — if me.roles intersects this set.
  • predicate(me) — app-specific flag (can_create_apps, etc.).

If you need the boolean without the wrapping component:

import { isAdminLike } from "@etamong-playground/ui";
if (isAdminLike({ me, emails: ADMIN_EMAILS })) router.push("/admin");

<BackofficeLayout> renders the <AdminBadge> next to the title by default. Pass badge={null} to hide it, or badge={<CustomBadge />} to override.

<AdminBadge> composes the shared badge classes — etu-badge etu-badge--accent etu-admin-badge — rather than a private style block, so it picks up any app-level .etu-badge overrides automatically.

AppInfoSection (canonical "앱 정보" card)

The standing placement rule: app version and release time belong in /settings or the backoffice "About" route, not in a page footer. This component is the canonical layout — wraps <DeployInfo> so apps stop hand-rolling that placement.

import { AppInfoSection } from "@etamong-playground/ui";

<AppInfoSection
  name="schedule-manager"
  description="회의실 예약 관리 시스템"
  icon={<img src="/icon.svg" alt="" />}
  appVersion="1.4.2"
  version={BUILD_SHA}
  builtAt={BUILT_AT}
  links={[
    { label: "도움말", href: "/help" },
    { label: "이용약관", href: "/terms" },
    { label: "개인정보처리방침", href: "/privacy" },
  ]}
>
  {/* Free-form rows after the standard ones */}
  <div className="etu-app-info-row">
    <dt>Plan</dt>
    <dd>Pro</dd>
  </div>
</AppInfoSection>

Props:

  • name / description / icon — identity block (top of the card).
  • appVersion — the semver shown as the "버전" row (typically your package.json version).
  • version / builtAt — forwarded to <DeployInfo> for the "빌드" row (shows deployed <sha7> · <rel time>).
  • links — link row at the bottom; external URLs open in a new tab.
  • children — free-form rows inside the <dl> — wrap each in a <div className="etu-app-info-row"> for consistent two-column layout.
  • heading — default "앱 정보". Pass null to omit.

Both the identity block and the <dl> rows are conditional: if you only pass version/builtAt, the card collapses to just the build row.

Page composition and settings

PageContainer owns responsive content measure and mobile-safe gutters. PageHeader owns a restrained title hierarchy without adding a card or tinted surface. Use density="compact" for settings and utility screens. Product content such as feed cards, charts, and campaign imagery stays app-owned.

import {
  AppInfoSection,
  PageContainer,
  PageHeader,
  SettingsGroup,
  SettingsRow,
} from "@etamong-playground/ui";

<PageContainer measure="narrow">
  <PageHeader
    density="compact"
    kicker="개인 설정"
    title="설정"
    description="계정과 앱 정보를 관리합니다."
  />

  <SettingsGroup heading="환경">
    <SettingsRow
      label="언어"
      description="이 기기에서 사용할 표시 언어"
      action={(accessibility) => <LanguagePicker {...accessibility} />}
    />
  </SettingsGroup>

  <AppInfoSection appVersion={pkg.version} version={SHA} builtAt={BUILT_AT} />

  <SettingsGroup heading="계정" tone="danger">
    <SettingsRow
      label="계정 삭제"
      description="요청 후 30일 동안 취소할 수 있습니다."
      action={(accessibility) => <DeleteAccountButton {...accessibility} />}
    />
  </SettingsGroup>
</PageContainer>

Measures are narrow (40rem), regular (56rem, default), and wide (72rem). PageContainer renders <main> by default; pass as="section" or as="div" only when composing inside an existing main landmark. PageHeader renders an h1; use headingLevel={2} only when the composition is embedded below an existing page title. SettingsGroup requires a non-empty string heading and renders an h2; set headingLevel={3} or {4} when it is nested beneath another section. The danger tone keeps destructive settings visibly separate from ordinary preferences.

SettingsRow.action is a render function. Spread its accessibility props onto the select, checkbox, button, or other control so the row label and description become its accessible name and help text.

Theme selection is opt-in at the page level. A light-only app omits the theme control and pins its theme at bootstrap. Offering dark mode means reviewing an intentional dark composition at phone and desktop sizes; token inversion alone does not satisfy that design requirement.

Typography variables (--etu-fs-display, --etu-fs-page-title, --etu-fs-section-title, --etu-fs-body, --etu-fs-metadata) and matching .etu-type-* classes are available for app-owned content that must align with the same hierarchy.

Time helpers (formatRelTime / formatAbsTime / RelTime)

The relative-time + KST-absolute formatting that used to live inside <DeployInfo>, pulled out and shared. Apps stop reinventing "3분 전" / KST conversion.

import { formatRelTime, formatAbsTime, RelTime } from "@etamong-playground/ui";

formatRelTime("2026-06-13T03:29:00Z");
// → "3분 전" (when locale defaults to ko)

formatAbsTime("2026-06-13T03:29:00Z");
// → "2026. 06. 13. 12:29" (KST default)

formatAbsTime(Date.now(), { withZoneSuffix: true });
// → "2026. 06. 13. 12:34 KST"

// Self-refreshing relative label — `<time dateTime>` with the absolute
// time in the `title` so hover reveals the exact KST timestamp.
<RelTime when={item.createdAt} />

formatRelTime options:

  • locale — defaults to the document/browser default. Pass "ko" / "en" to force.
  • numeric — default "auto" (gives "어제" / "yesterday" instead of "1 day ago").
  • now — reference time for "now"; defaults to Date.now().

formatAbsTime options:

  • timeZone — default "Asia/Seoul".
  • locale — default "ko-KR".
  • style — preset ("date", "time", "datetime", "datetime-seconds"); ignored when formatOptions is set.
  • formatOptions — raw Intl.DateTimeFormatOptions for full control.
  • withZoneSuffix — appends KST (or the literal zone string for non-default zones).

<RelTime> refresh cadence is "auto": every 15 s under a minute, every minute under an hour, every 10 minutes beyond. The title (absolute time) stays in sync because it's derived in the same render.

Invalid timestamps (bad ISO, undefined) render to empty strings — safe to use directly on partial data.

UserMenu + Avatar

The fleet-wide profile-picture + "내 정보" link + 로그아웃 surface every app's header should expose so users have one consistent place to find themselves. Composes with useMe<T> (v0.10).

import { useMe, UserMenu } from "@etamong-playground/ui";

interface Me extends BaseMe { /* picture / preferred_username / is_admin … */ }

function Header() {
  const { me } = useMe<Me>();
  return (
    <header className="app-header">
      {/* …logo, nav… */}
      <UserMenu me={me} myInfoHref="/me" />
    </header>
  );
}

The trigger is the avatar. Click → dropdown with:

  • Display name (me.name ?? me.preferred_username ?? me.email).
  • Email line (when distinct from the display name).
  • admin pill when me.is_admin (suppress via showAdminBadge={false}).
  • Optional extraItems rows above the standard ones.
  • The "내 정보" link (set myInfoHref={null} to hide).
  • The "로그아웃" button.

Escape and click-outside close it.

Anonymous (me == null) renders a "로그인" link pointing at signInUrl(); pass signedOutAction to override.

Logout default is signOut("/") (oauth2-proxy /oauth2/sign_out?rd=/). Apps with their own logout endpoint pass onSignOut:

<UserMenu
  me={me}
  onSignOut={() => {
    void fetch("/api/auth/logout", { method: "POST" });
    window.location.href = "/";
  }}
/>

Apps wanting just the avatar (lists, comments, prompts) can use the stand-alone <Avatar>:

<Avatar src={comment.author.picture} fallback={comment.author.email} size={24} />

Pictures that fail to load fall back to the initial letter automatically.

Full-width identity footer (v0.43)

variant="full" swaps the avatar-circle trigger for a full-width row — avatar + name + email stacked — opening the same popover. This is the canonical <Sidebar footer> control:

<Sidebar
  primary={primary}
  footer={
    <UserMenu
      me={me}
      variant="full"
      placement="top-right"   // opens upward from the sidebar footer
      themeToggle={{ appKey: "myapp" }}
      badges={[{ label: "Owner", tone: "accent" }]}
    />
  }
/>
  • themeToggle={{ appKey }} adds a light/dark row to the popover, backed by getTheme/setTheme (same appKey as noFlashThemeScript). This is the theme toggle's canonical home now — see "Bell placement" under Sidebar below for why it moved out of the footer as a loose icon. Adopting it means removing any pre-existing theme toggle the app already has — two controls racing the same localStorage key fight each other. Concrete hazard: porygon/webui has its own useTheme() on the same "<appKey>-theme" key whose React state initializes once on mount (useState(() => localStorage.getItem(...))) with no storage event listener — it won't notice setTheme() being called from this popover, so the two toggles show contradictory state until the next full reload.
  • badges — role/permission pills ({ label, tone? }, tone matches .etu-badge--*) rendered under the name, independent of the built-in admin pill (showAdminBadge).
  • The footer itself stays identity-only — don't drop unrelated icon buttons (locale switch, command palette trigger, …) into it directly; those belong in appHeaderExtra or the app's own header/nav bar chrome. The one sanctioned exception is the notification bell, which sits beside this control via the separate <Sidebar footerAccessory> prop — see "Bell placement" below — not inlined into footer itself.

Sidebar + MobileTabBar (fleet nav shell)

<Sidebar> is the desktop nav shell; <MobileTabBar> is the mobile bottom bar. Together they implement the fleet sidebar-composition and mobile-more-page contracts. Drive both from the same primary and secondary arrays — one source of truth, two renderers. Both are CSS-hidden at the opposite breakpoint, so mounting both unconditionally is correct.

import { Sidebar, MobileTabBar, NotificationBell, UserMenu, type SidebarItem } from "@etamong-playground/ui";
import { Home, Calendar, Users, Settings, ShieldCheck, MoreHorizontal } from "lucide-react";

const primary: SidebarItem[] = [
  { id: "home",     label: "홈",     icon: <Home size={18} />,     active: view === "home",     onClick: () => go("home") },
  { id: "schedule", label: "일정",   icon: <Calendar size={18} />, active: view === "schedule", onClick: () => go("schedule") },
  { id: "members",  label: "구성원", icon: <Users size={18} />,    active: view === "members",  onClick: () => go("members") },
];

const secondary: SidebarItem[] = [
  { id: "settings", label: "설정",  icon: <Settings size={18} />,    active: view === "settings", onClick: () => go("settings") },
  { id: "admin",    label: "Admin", icon: <ShieldCheck size={18} />, active: view === "admin",    onClick: () => go("admin"),    /* gate caller-side: only push when me?.is_admin */ },
];

function Shell({ children }: { children: ReactNode }) {
  return (
    <div className="etu-app-shell">
      <Sidebar
        appName="schedule-manager"
        primary={primary}
        secondary={secondary}
        footer={
          <UserMenu
            me={me}
            variant="full"
            placement="top-right"
            themeToggle={{ appKey: "schedule-manager" }}
            onSignOut={signOut}
          />
        }
        // Beside identity, not a nav row — see "Bell placement" below.
        footerAccessory={
          <NotificationBell variant="footer" ariaLabel="알림" items={notifItems} />
        }
      />
      <main>{children}</main>
      <MobileTabBar
        items={[
          ...primary.slice(0, 4),
          { id: "more", label: "더보기", icon: <MoreHorizontal size={22} />, active: view === "more", onClick: () => go("more") },
        ]}
      />
    </div>
  );
}

Notes:

  • Settings + Logout always live on /more on mobile. The mobile tab bar shows up to 4 primary destinations + 더보기; the operator taps 더보기 → /more to find Settings, Logout, Admin, etc. Never put Settings on a tab; never show a header-dropdown <UserMenu> on mobile.
  • No userMenu prop on <Sidebar>. Identity + Logout live in footer — the canonical shape (v0.43) is a single <UserMenu variant="full">, see "Full-width identity footer" above. Header dropdowns are the retired anti-pattern.
  • Active state is caller-computed. Both components are router-agnostic and never read the URL.
  • CSS variable --etu-sidebar-w overrides the 240px default width.

Bell placement (v0.48, planning#1133 §6 correction)

Beside identity, never the nav list. v0.43 tried the bell as a nav row (<NotificationBell variant="row"> via SidebarItem.render); that read as a destination sitting next to Dashboard/Docs, which it isn't — our bell opens a popover of recent items and routes elsewhere, an ephemeral stream rather than a triageable object with its own URL-worthy state. GitHub, Vercel, Figma and Slack all place their bell beside identity, never among nav destinations, and that's the corrected fleet convention:

<Sidebar
  footer={<UserMenu variant="full" me={me} themeToggle={{ appKey: "myapp" }} />}
  footerAccessory={<NotificationBell variant="footer" items={notifItems} />}
/>
  • Expanded: footerAccessory renders as a trailing icon button next to the identity trigger, in one row — not stacked, not full-width.
  • Collapsed rail (64px): it becomes its own icon-only rail item, stacked directly above the avatar, badge intact — the same pill as the standalone trigger, unchanged at either width. This is deliberately different from SidebarItem.badge's own dot-degradation (see "SidebarItem.badge" above): that rule exists because a plain nav-row badge is unbounded text a caller supplies and could overflow a 64px column. The bell's own badge is bounded to "99+" by the component itself, so it always fits the ~40px trigger and never needs to degrade — nothing here is incidental.
  • On mobile the sidebar is hidden below 720px, so the bell moves to <NavigationBar trailing> instead — unchanged from before.
  • Theme lives inside <UserMenu themeToggle> (see "Full-width identity footer" above) — never a loose footer icon, and never paired with the bell in the same spot. The rejected shape here was specifically a cluster of loose icons (bell + theme side by side, colliding at rail width), not the bell's adjacency to identity.

When a nav row is still correct. The test: notifications earn a nav-list row only when the surface is a first-class triageable object — URL-worthy state a user returns to and manages (filter, snooze, mark-done, assigned-to-me), with real dwell time (Linear's/Notion's Inbox qualify). An ephemeral glance-and-click-through stream doesn't, ours included. variant="row" (v0.43) is deprecated but not removed — it still works for existing consumers; see the NotificationBell section below for its (unchanged) mechanics.

SidebarItem.badge — unread counts and status dots

Any plain nav row (not just the bell) can carry a trailing indicator:

{ id: "inbox", label: "받은편지함", icon: <Inbox size={18} />, badge: unread || undefined, onClick: () => go("inbox") }

Expanded: renders as a pill after the label. Collapsed rail: degrades to a small dot overlaid on the icon's corner (pure CSS swap — no JS branching on collapse state) instead of disappearing, since staying visible collapsed is the point of an unread indicator. Requires icon — an icon-less item has nowhere to anchor the dot.

SidebarItem.render — custom rows

Escape hatch for a row that needs to own more than an onClickNotificationBell's deprecated "row" variant (see "Bell placement" above) is the reference implementation, still valid for rows that genuinely are nav destinations. When render is set, every other field except id is ignored; reuse the .etu-sidebar-item* classes in the returned markup to inherit rail-collapse behavior (icon-only, label hidden, badge → dot) automatically.

Captioned secondary subsections (large apps)

Once an app's secondary list grows past ~6 rows, swap the flat secondary prop for secondarySections — captioned, concern-based groups (OPERATE / INVENTORY / GOVERNANCE, …) that render as separate <nav> blocks. The same array drives the mobile /more drill-down rows. See the sidebar-composition "Ordering and grouping" rule for guidance.

import { Sidebar, type SidebarSecondarySection } from "@etamong-playground/ui";

const secondarySections: SidebarSecondarySection[] = [
  {
    id: "operate",
    caption: "OPERATE",
    items: [
      { id: "audit", label: "Audit", onClick: () => go("audit") },
      { id: "schedule", label: "Schedule", onClick: () => go("schedule") },
    ],
  },
  {
    id: "governance",
    caption: "GOVERNANCE",
    items: [
      { id: "legal", label: "Legal", onClick: () => go("legal") },
      { id: "settings", label: "Settings", onClick: () => go("settings") },
    ],
  },
];

<Sidebar
  appName="service-admin"
  primary={primary}
  secondarySections={secondarySections}
  footer={footer}
/>;

When both secondary and secondarySections are passed, secondarySections wins (and a dev-only console.warn fires).

Rail inline collapse (v0.37.0)

tabletMode="rail" is an inline-collapsible sidebar: collapsed it is a 64px icon-only column, expanded it is the normal in-flow 240px sidebar pushing content — no overlay, no scrim (the v0.35.0 overlay expansion is replaced). A chevrons button pinned to the header's trailing edge — vertically aligned with the app name/icon row (v0.43; previously a separate row floating below the brand) — flips the state at both the tablet and desktop tiers, and so does the ⌘/Ctrl+B keyboard shortcut (v0.38.0; VS Code / shadcn convention — IME-safe, ignored inside text inputs where ⌘B means bold). The default follows the tier — tablet starts collapsed, desktop starts expanded — and re-derives when the viewport crosses 1024px. Clicking nav items does not collapse the sidebar.

<Sidebar
  tabletMode="rail"
  railExpandLabel="메뉴 펼치기"   // default; override for i18n
  railCollapseLabel="메뉴 접기"   // default; override for i18n
  primary={primary}
  footer={footer}
/>

aria-label is set on every item whose label is a string (including while collapsed, where the label span is hidden). A native title tooltip is added while collapsed so icon-only items are self-describing on hover.

Collapsed rail (v0.43): the app name/icon hide and the toggle becomes the sole visible header control — the top item of the rail, so re-expanding stays discoverable without hovering. Nav rows keep their expanded order/grouping (icon-only, no reflow); section captions (secondarySections captions and secondaryCaption) collapse to a subtle 1px divider rather than disappearing outright — without it, a second-or-later secondarySections group has no border of its own and loses all separation from the group above once collapsed.

NotificationBell

Fleet-wide notification surface — bell icon + unread badge. Click opens a popover dropdown on desktop/tablet (anchored to the trigger, same placement contract as <UserMenu>) and a bottom sheet on mobile (backdrop + slide-up + safe-area inset + body-scroll lock). Content-agnostic — pass an items array with rendered content nodes and any inline actions:

import { NotificationBell } from "@etamong-playground/ui";

<NotificationBell
  items={items}                 // [{ id, content }]
  onOpen={() => refetchItems()} // refresh on open
  footer={<a href="/notifications">모두 보기</a>}
/>

Replaces per-app "inbox" tabs/routes: incoming notifications (access requests, deploy completions, mentions) belong on a global bell, not the primary nav.

Placement (v0.48, planning#1133 §6 correction): variant="footer" mounts via <Sidebar footerAccessory>, beside identity — the canonical placement on tablet/desktop. Visually identical to the default variant="trigger" icon button, but portals its popover like "row" does, since the footer sits inside <Sidebar>'s own overflow: auto region:

<Sidebar
  footer={<UserMenu variant="full" me={me} .../>}
  footerAccessory={<NotificationBell variant="footer" items={items} />}
/>

See "Bell placement" under Sidebar above for the full placement rule, the expanded/collapsed layout, and the test for when a notification surface earns a nav row instead (rare — ours doesn't).

On mobile the sidebar is hidden below 720px — mount the default variant="trigger" in <NavigationBar trailing> instead (see above). Never paired with the theme toggle (lives in <UserMenu themeToggle>).

variant="row" (v0.43) is deprecated, not removed. It still mounts via SidebarItem.render as a full-width .etu-sidebar-item row — icon + label

  • count — reusing the same .etu-sidebar-item* classes <Sidebar> itself uses for rail-collapse (icon-only, badge → dot) parity, and its popover still portals to <body> the same way. Existing consumers keep working unchanged; new integrations should use variant="footer" above instead.
// Deprecated — kept working, not the placement for new integrations
{ id: "notifications", render: () => (
    <NotificationBell variant="row" label="알림" items={items} />
) }

Push-permission affordance (v0.44, opt-in): pass push to show <PushEnableRow> at the top of the popover/sheet when permission is "default", plus a quiet setup dot on the trigger. See "Push permission" below — omitting push leaves NotificationBell exactly as before.

const push = usePushPermission();

<NotificationBell
  items={items}
  push={{ permission: push, onEnabled: () => subscribeAppSide() }}
/>

NavigationBar + floating tab bar (iOS 26 Liquid Glass)

v0.23.0 adds <NavigationBar> — an iOS-style small-title bar — as the default per-page chrome across the fleet, and refreshes <MobileTabBar> to a floating pill with the iOS 26 Liquid Glass material (translucent backdrop, depth-aware border). The global avatar top bar is retired; profile access lives on /more

  • the desktop sidebar footer.
import { NavigationBar } from "@etamong-playground/ui";

<NavigationBar
  title="일정 상세"
  back={() => go("schedule")}
  trailing={<button className="etu-icon-btn" onClick={openMenu}></button>}
/>

Props (see NavigationBarProps):

  • back: () => void | string (push history + popstate) | true (calls history.back()) | falsy (no back affordance).
  • sticky (default true) — sticks to top + applies env(safe-area-inset-top).
  • fadeOnScroll (default true) — bar starts at 0.92 opacity and gains a shadow after the page scrolls past 24px.
  • borderless — drop the hairline border (for full-bleed transparent shells).

trailing is also the mobile home for <NotificationBell> (v0.43) — the sidebar (and its footer bell, see "Bell placement" above) is hidden below 720px:

<NavigationBar title={title} trailing={<NotificationBell items={items} />} />

Android Chrome / Samsung Internet compatibility floor

  • Min hit area is 48px (Material 3 floor — supersedes iOS 44pt).
  • Every backdrop-filter is paired with an @supports not solid---etu-surface fallback, and color-mix() rules degrade to the underlying token.
  • Glyphs are generic Unicode (, , , +) — no SF Symbols.
  • Android system-back fires popstate naturally; back: true consuming history.back() works the same on both platforms.

.etu-glass utility

Both .etu-navbar and .etu-mtb (.etu-mobile-tab-bar) opt into the .etu-glass material. Apply it to any other surface (sheet, popover, dock) to match the fleet's iOS-26 visual.

Releasing

The package publishes from CI on a version tag — no manual pnpm publish.

# 1. bump the version on a branch → PR → merge to main
#    (edit "version" in package.json, e.g. 0.4.0 → 0.5.0)
# 2. tag the merged commit and push the tag:
git checkout main && git pull
git tag v0.5.0           # the tag MUST equal package.json's version
git push origin v0.5.0

The tag pipeline verifies vX.Y.Z matches package.json, builds, and publishes to GitHub Packages. Versioning is semver, but 0.x: cut minor bumps (0.4 → 0.5) for new components/exports and patch (0.4.0 → 0.4.1) for fixes. Re-tagging an already-published version fails loudly (npm won't overwrite).

Consuming in an app

Three things are needed (or the app's CI 404s / fails to resolve the package):

  1. App .npmrc — GitHub Packages registry: @etamong-playground:registry=https://npm.pkg.github.com/
  2. package.json"@etamong-playground/ui": "^0.5.0".
  3. Dockerfile deps stageARG NPM_TOKEN + before install: RUN echo "//npm.pkg.github.com/:_authToken=${NPM_TOKEN}" >> .npmrc; the build job passes the token via --build-arg NPM_TOKEN. (CI check jobs need the same _authToken line in before_script.)

Picking up a new release: bump the pin in package.json and refresh the lockfile, then commit both — the app CIs use --frozen-lockfile, so the lockfile must move for the new version to install:

pnpm add @etamong-playground/ui@^0.5.0   # updates package.json + pnpm-lock.yaml
git add package.json pnpm-lock.yaml && git commit -m "bump @etamong-playground/ui to 0.5.0"

Note ^0.x is narrow: ^0.4.0 = >=0.4.0 <0.5.0, so a minor bump needs the pin changed in every consumer (a patch stays in range but still needs the lockfile updated).

Acknowledgements

Uses cmdk (MIT) for the command palette.

License

MIT — see LICENSE.

About

Shared React UI toolkit — design tokens, cmdk command palette, responsive viewport, theming, PWA shell.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages