Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,138 @@
# Changelog

## 0.43.0

Sidebar structure pass (planning#1133) — the desktop collapse affordance, rail
parity, the identity footer, and where the notification bell/theme toggle live
all move to match the reference UI. Presentation + placement, not new state.

### Collapse control

- The rail-collapse chevrons button — previously a separate row floating
below the brand, reading as an afterthought — now lives in the sidebar
header row, pinned to the trailing edge and vertically aligned with the
app name/icon. Same `⌘/Ctrl+B` shortcut, same `useSidebarDrawer` behavior,
unchanged.
- Collapsed rail: 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.

### Rail parity

- Collapsed rail keeps the same item order/grouping/vertical rhythm as
expanded (unchanged from v0.37 — no code change needed, called out here
because it's now paired with the caption fix below).
- Section captions (`secondarySections` captions, `secondaryCaption`)
collapse to a subtle 1px divider instead of vanishing outright — without
it, a second-or-later `secondarySections` group (no border-top of its
own) lost all visual separation from the group above once collapsed.
- Quiet caption treatment: `--etu-fs-caption` / `--etu-text-subtle`
unchanged, but `font-weight` drops from `--etu-fw-semibold` to
`--etu-fw-medium` and the top padding/margin grows, so a caption reads as
a label, not a competing heading.

### Identity footer + UserMenu

- `<UserMenu variant="full">` — a full-width avatar + name + email trigger,
opening the same popover as the existing avatar circle. This is now the
canonical `<Sidebar footer>` control.
- `<UserMenu themeToggle={{ appKey }}>` — adds a light/dark row to the
popover, backed by `getTheme`/`setTheme`. The theme toggle's canonical
home now, not a loose footer icon.
- `<UserMenu badges={[{ label, tone? }]}>` — role/permission pills under
the name, independent of the existing `admin` pill (`showAdminBadge`);
reuses the shared `.etu-badge` classes.
- All three are new optional props — existing `<UserMenu>` usage (avatar
trigger, no badges, no theme row) is visually unchanged.

### Bell and theme placement

- `<NotificationBell variant="row">` — a full-width `.etu-sidebar-item` row
(icon + `label` + count), meant to be mounted via the new
`SidebarItem.render`. Reuses the same `.etu-sidebar-item*` classes
`<Sidebar>` itself uses, so it inherits rail-collapse (icon-only, badge →
dot) for free. The desktop popover now renders through a portal to
`<body>` (row variant only) so it isn't clipped by the sidebar's own
`overflow: auto` at any rail width, including the 64px collapsed column.
The existing standalone `variant="trigger"` (default) is unchanged.
- `SidebarItem.badge` — a trailing indicator (unread count, status dot) on
any plain nav row. Expanded: a pill after the label. Collapsed rail:
degrades to a small dot overlaid on the icon's corner via a pure CSS
swap (no JS branching on collapse state) instead of disappearing.
- `SidebarItem.render` — escape hatch that replaces a row's default
button/link markup entirely, for rows that need to own more than an
`onClick` (`NotificationBell`'s `"row"` variant is the reference
implementation).
- Convention: the bell is a nav row (desktop/rail) or
`<NavigationBar trailing>` (mobile, since the sidebar is hidden below
720px) — never the sidebar footer, and never paired with the theme
toggle. `NotificationBell` itself is unchanged/not deprecated, only the
footer-icon-cluster placement is retired; nothing in this package's own
showcase mounts it there.

### Behavioral notes for 0.43

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

- **Section captions are lighter.** `--etu-fw-semibold` → `--etu-fw-medium`
on `.etu-sidebar-caption` / `.etu-sidebar-section-caption`, with more space
above. Purely visual.
- **The rail toggle moved.** From a standalone 40×40 button centered in its
own row below the header, to the same 40×40 button inside the header row,
pinned to the trailing edge. Apps that pass `appIcon` and/or `appName`
alongside `tabletMode="rail"` will see this shift automatically — no prop
change needed.
- **The app icon/name hide on collapsed rail** (previously the icon stayed
visible, centered). The toggle is now the sole top-of-rail control while
collapsed.
- **`.etu-sidebar-header-name` truncates with an ellipsis** instead of
wrapping/overflowing when the app name is long enough to compete with the
now-inline toggle button.
- **Section captions collapse to nothing on a collapsed rail**, except a
second-or-later `secondarySections` group, which gets a subtle 1px divider
stand-in — its own section border is reset to 0 (see "Rail parity" above),
so without the stand-in it loses all separation from the group before it.
The first group and the single flat-`secondary` caption keep relying on
their section's own border-top instead, so they don't grow a second,
redundant line next to it.

### Review round fixes

- `SidebarItem.badge` is now folded into the row's `aria-label` (e.g.
`"알림 (3)"`) instead of being silently dropped — `aria-label` overrides all
descendant text per the accessible-name algorithm, so the visible badge
pill was never announced, collapsed or expanded. The pill itself is now
`aria-hidden` in that case to avoid double-counting.
- `<UserMenu>`'s dropdown now renders through a portal to `<body>` with
viewport-fixed coordinates, same as `<NotificationBell variant="row">`'s
popover — it was clipped by `<Sidebar>`'s `overflow-y: auto` when mounted
as the `variant="full"` footer control.
- Popover positioning (`<NotificationBell>` + `<UserMenu>`) is now a single
`useLayoutEffect` pass, shared via an internal `usePopoverPosition` hook —
side-flip and portal offset used to be two effects, the second reading a
stale placement from its own closure and painting the panel at the wrong
spot for one frame. Both axes are now clamped to the viewport (not just the
anchor edge), against the panel's real measured size (not a hardcoded
constant that had drifted from the CSS), and recompute on scroll too, not
just resize.
- `<NotificationBell variant="row">` no longer opens a full-screen mobile
sheet if it was left open when the viewport crosses below 720px —
`<Sidebar>` is CSS-hidden there, not unmounted, so the row instance used to
survive and strand an orphaned sheet, complete with a body-scroll lock the
user never asked for.
- `<NotificationBell variant="row">` now always has an accessible name,
including at 0 unread — it previously fell back to `undefined` instead of
the plain label, unlike the standalone trigger variant. It also carries the
same `title` tooltip the default `Item()` row markup does.
- Collapsed-rail section captions are now `aria-hidden` — `font-size: 0` /
`color: transparent` alone isn't reliable removal from the accessibility
tree. Safe because the group name is already exposed via the enclosing
`<nav aria-label>`.
- Dev-only `console.warn` when `SidebarItem.render` is combined with
`href`/`onClick`/`active`/`badge` — those fields are silently ignored;
mirrors the existing `secondary` + `secondarySections` warning.

## 0.42.1

Fix (planning#976) — `@playwright/test` and `msw` are no longer `peerDependencies`
Expand Down
178 changes: 164 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ safe go-to shortcuts, **toast + dialog** notification primitives, and the
`app-notifications`, `build-version-info`).

Published to GitHub Packages; consumed by all app
frontends. **Current: v0.42.** Releasing + consuming are documented at the bottom.
frontends. **Current: v0.43.** 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.
Expand Down Expand Up @@ -1357,6 +1357,45 @@ stand-alone `<Avatar>`:

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:

```tsx
<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 and theme 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 stays identity-only otherwise — don't drop unrelated icon
buttons (locale switch, command palette trigger, …) next to it; those
belong in `appHeaderExtra` or the app's own header/nav bar chrome.

## Sidebar + MobileTabBar (fleet nav shell)

`<Sidebar>` is the desktop nav shell; `<MobileTabBar>` is the mobile
Expand All @@ -1366,13 +1405,19 @@ one source of truth, two renderers. Both are CSS-hidden at the
opposite breakpoint, so mounting both unconditionally is correct.

```tsx
import { Sidebar, MobileTabBar, AppInfoSection, type SidebarItem } from "@etamong-playground/ui";
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") },
// Owns its own trigger + anchored popover, so it opts out of the plain
// onClick/active row shape via `render` — see "Bell and theme placement" below.
// `render` ignores every field but `id` — no `label` here, it'd mislead.
{ id: "notifications", render: () => (
<NotificationBell variant="row" label="알림" items={notifItems} />
) },
];

const secondary: SidebarItem[] = [
Expand All @@ -1388,16 +1433,19 @@ function Shell({ children }: { children: ReactNode }) {
primary={primary}
secondary={secondary}
footer={
<>
<AppInfoSection name={me?.name} description={me?.email} appVersion={pkg.version} version={SHA} builtAt={BUILT_AT} heading={null} />
<button className="etu-sidebar-item" onClick={signOut}>로그아웃</button>
</>
<UserMenu
me={me}
variant="full"
placement="top-right"
themeToggle={{ appKey: "schedule-manager" }}
onSignOut={signOut}
/>
}
/>
<main>{children}</main>
<MobileTabBar
items={[
...primary.slice(0, 4),
...primary.filter((it) => !it.render).slice(0, 4),
{ id: "more", label: "더보기", icon: <MoreHorizontal size={22} />, active: view === "more", onClick: () => go("more") },
]}
/>
Expand All @@ -1413,11 +1461,53 @@ Notes:
`/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`. Header dropdowns are the retired anti-pattern.
`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 and theme placement (v0.43)

A bell + theme icon pair sitting loose in the sidebar footer crowds it and
collides once the rail collapses to 64px. The fleet convention instead:

- **Notifications are a nav row, not a footer icon.** `<NotificationBell
variant="row">` (see the NotificationBell section below) mounts via
`SidebarItem.render` — reusing `.etu-sidebar-item*` classes gives it
rail-collapse parity for free (icon-only, unread count → a dot) with no
extra wiring.
- **On mobile** the sidebar is hidden below 720px, so the bell moves to
`<NavigationBar trailing>` instead — a single icon with room, colliding
with nothing.
- **Theme lives inside `<UserMenu themeToggle>`** (see "Full-width identity
footer" above), not as a loose footer icon either.
- Never pair the bell with the theme toggle in the same spot — that's
exactly the crowding this convention replaces.

### `SidebarItem.badge` — unread counts and status dots

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

```tsx
{ 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 `onClick` —
`NotificationBell`'s `"row"` variant is the reference implementation. 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
Expand Down Expand Up @@ -1463,12 +1553,13 @@ When both `secondary` and `secondarySections` are passed,

`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 under the app-icon
header 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.
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.

```tsx
<Sidebar
Expand All @@ -1484,6 +1575,58 @@ Clicking nav items does not collapse the sidebar.
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:

```tsx
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.43):** `variant="row"` (default `"trigger"`) renders as a
full-width `.etu-sidebar-item` row — icon + `label` + count — meant to be
mounted via `SidebarItem.render` on tablet/desktop:

```tsx
{ id: "notifications", render: () => (
<NotificationBell variant="row" label="알림" items={items} />
) }
```

It reuses the same `.etu-sidebar-item*` classes `<Sidebar>` itself uses, so
it inherits rail-collapse (icon-only, badge → dot) for free — no extra
wiring. The desktop popover renders through a portal to `<body>` so it isn't
clipped by the sidebar's own `overflow: auto`, at any rail width including
the 64px collapsed column.

On mobile the sidebar is hidden below 720px — mount the default
`variant="trigger"` in `<NavigationBar trailing>` instead (see above). Never
the sidebar footer (crowds the identity control, collides in rail mode) and
never paired with the theme toggle (moved into `<UserMenu themeToggle>`) —
see "Bell and theme placement" under Sidebar.

## NavigationBar + floating tab bar (iOS 26 Liquid Glass)

v0.23.0 adds `<NavigationBar>` — an iOS-style small-title bar — as the default
Expand Down Expand Up @@ -1511,6 +1654,13 @@ Props (see `NavigationBarProps`):
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 nav-row bell, see "Bell and theme placement" above) is hidden below 720px:

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

### Android Chrome / Samsung Internet compatibility floor

- Min hit area is **48px** (Material 3 floor — supersedes iOS 44pt).
Expand Down
Loading
Loading