feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5)

This commit is contained in:
2026-09-25 00:47:46 +02:00
parent 6354454cc9
commit 7fa8f4ebde
20 changed files with 342 additions and 101 deletions
+4 -2
View File
@@ -187,10 +187,12 @@ Also in force:
dissolves as it slides under. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a dissolves as it slides under. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a
long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they
are the content) and not on buttons (it becomes a dated gloss). are the content) and not on buttons (it becomes a dated gloss).
- **Nav rail (B7):** a `NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a - **Nav rail (B7):** the `ui/NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a
3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add 3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
height, never width, so each module has a short rail label that fits, and the full name stays in the height, never width, so each module has a short rail label that fits, and the full name stays in the
tooltip. No wide padding around the icons. tooltip. No wide padding around the icons. The switch is Settings → Appearance → "Labels in the side
menu"; with the labels on, a row's accessible name is its visible short label (WCAG 2.5.3), and an
unread count is part of the name, not only drawn.
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" | - **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
"poster" | "list" | "table"`, built from the `Skeleton` primitive), so "poster" | "list" | "table"`, built from the `Skeleton` primitive), so
nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
+1 -1
View File
@@ -113,7 +113,7 @@ function StartDialog() {
onChange={setLevel} onChange={setLevel}
/> />
</div> </div>
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed"> <div className={`${SURFACE.card} px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed`}>
<div className="font-semibold">{t("recorder.start.whatTitle")}</div> <div className="font-semibold">{t("recorder.start.whatTitle")}</div>
<p>{t(`recorder.start.what.${level}`)}</p> <p>{t(`recorder.start.what.${level}`)}</p>
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p> <p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
+4 -3
View File
@@ -43,6 +43,7 @@ import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext"; import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// The Home surface (E24) — "Mit nézzek most?". // The Home surface (E24) — "Mit nézzek most?".
// //
@@ -257,7 +258,7 @@ function ShelfTile({
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards), // Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
// alive to all three input paths. // alive to all three input paths.
aria-disabled={!card.next} aria-disabled={!card.next}
className="block w-full rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent" className={`${SURFACE.card} glass-hover block w-full overflow-hidden transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent`}
> >
<span className="block aspect-video bg-card overflow-hidden"> <span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? ( {card.thumb ? (
@@ -305,7 +306,7 @@ function Hero({
return ( return (
<section <section
{...pinMenu.triggerProps(card)} {...pinMenu.triggerProps(card)}
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]" className={`${SURFACE.panel} mb-6 p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]`}
> >
<Pressable <Pressable
onClick={() => onPlay(card)} onClick={() => onPlay(card)}
@@ -743,7 +744,7 @@ export default function Home() {
) : ( ) : (
// Honest: nothing in the library fits what was asked. Offering something that does not fit // Honest: nothing in the library fits what was asked. Offering something that does not fit
// would make the one control on this page meaningless. // would make the one control on this page meaningless.
<section className="mb-6 rounded-xl border border-border bg-surface p-4"> <section className={`${SURFACE.panel} mb-6 p-4`}>
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises {/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
a re-run of something that failed. Nothing failed — the library simply has no answer a re-run of something that failed. Nothing failed — the library simply has no answer
this short, and the honest offer is to ask a different question. */} this short, and the honest offer is to ask a different question. */}
+4 -3
View File
@@ -27,6 +27,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import { Textarea, Input } from "./ui/Input"; import { Textarea, Input } from "./ui/Input";
import { Checkbox } from "./ui/NativeControls"; import { Checkbox } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the // The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs, // steps drafted from the session, the environment), what goes with it on the right (the two logs,
@@ -627,7 +628,7 @@ function Attachments({
const logRow = (source: Source, icon: React.ReactNode) => { const logRow = (source: Source, icon: React.ReactNode) => {
const [kept, total] = counts[source]; const [kept, total] = counts[source];
return ( return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2.5"> <div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5">
{icon} {icon}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm"> <span className="text-sm">
@@ -657,7 +658,7 @@ function Attachments({
}; };
return ( return (
<div className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4"> <div className={`${SURFACE.card} flex flex-col gap-3 min-w-0 p-3 md:p-4`}>
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div> <div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)} {logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)} {logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
@@ -669,7 +670,7 @@ function Attachments({
{shots.map((shot, i) => ( {shots.map((shot, i) => (
<div <div
key={shot.id} key={shot.id}
className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2" className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
> >
<img <img
src={api.debugScreenshotUrl(session.id, shot.id)} src={api.debugScreenshotUrl(session.id, shot.id)}
+41 -63
View File
@@ -11,17 +11,24 @@ import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications"; import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; import {
moduleDef,
moduleLabelKey,
moduleOrder,
moduleRailKey,
SYSTEM_PAGES,
} from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import AvatarImg from "./ui/Avatar"; import AvatarImg from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface } from "./ui/AnchoredPopover"; import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { NavRail, NavRailDivider, NavRailItem, RAIL_ROW } from "./ui/NavRail";
import { usePrefs } from "./prefsContext";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar // Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account // dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -157,65 +164,38 @@ export default function NavSidebar({
.filter((p) => sys.has(p)) .filter((p) => sys.has(p))
.map((p) => ({ page: p, ...META(p) })); .map((p) => ({ page: p, ...META(p) }));
const rowBase = const { railLabels } = usePrefs();
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = displayName(me); const name = displayName(me);
// Role is surfaced as a plain title tooltip on the account row (no visual badge). // Role is surfaced as a plain title tooltip on the account row (no visual badge).
const roleLabel = t(`nav.role.${roleKey(me)}`); const roleLabel = t(`nav.role.${roleKey(me)}`);
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => { const renderItem = ({ page: p, icon, label, badge }: NavItem) => (
const active = page === p; <NavRailItem
// On the active row the background is the accent colour, so a same-accent badge would be key={p}
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast. testId={`nav-${p}`}
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg"; icon={icon}
const show = !!badge && badge > 0; label={label}
return ( shortLabel={t(moduleRailKey[p])}
// The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only. showLabel={railLabels}
<Tooltip key={p} hint={label} side="right" always openDelay={350}> active={page === p}
<Pressable badge={badge}
data-testid={`nav-${p}`} onClick={() => setPage(p)}
onClick={() => setPage(p)} />
aria-label={label} );
aria-current={active ? "page" : undefined}
className={`${rowBase} justify-center px-0 relative is-current`}
>
<span className="relative shrink-0">
<Icon className="w-4.5 h-4.5" />
{/* A numeric badge centred on a circle at the icon corner; the ring matches the row
background so it reads cleanly whether the row is active or not. */}
{show && (
<span
className={`absolute -top-2 -right-2 min-w-4 h-4 px-1 rounded-full text-3xs font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg"
}`}
>
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
</Pressable>
</Tooltip>
);
};
return ( return (
// Slim icon rail, flush against the left viewport edge (only top/bottom margin), never grows. // Slim rail (B7), flush against the left viewport edge (only top/bottom margin), never grows —
// The slot is exactly the card's width so the filter tab/bar docks flush against its right edge. // labels on or off. The slot is exactly the rail's width so the filter tab/bar docks flush
<div className="relative shrink-0 w-14"> // against its right edge.
<nav <div className="relative shrink-0 w-15">
className={`${SURFACE.panel} absolute top-0 bottom-0 left-0 z-rail flex flex-col py-3 my-3 w-14 px-2`} <NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
aria-label={t("nav.primary")}
>
<div <div
ref={listFade.ref} ref={listFade.ref}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1" className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
style={listFade.style} style={listFade.style}
> >
{userItems.map(renderItem)} {userItems.map(renderItem)}
{systemItems.length > 0 && ( {systemItems.length > 0 && <NavRailDivider />}
// A short, thicker centred rule is the clearest "new section" cue on an icon-only rail.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
)}
{systemItems.map(renderItem)} {systemItems.map(renderItem)}
</div> </div>
@@ -239,17 +219,15 @@ export default function NavSidebar({
<LogOut className="w-5 h-5" /> <LogOut className="w-5 h-5" />
</IconButton> </IconButton>
</div> </div>
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}> <NavRailItem
<Pressable testId="nav-settings"
data-testid="nav-settings" icon={Settings}
onClick={() => setPage("settings")} label={t("header.account.settings")}
aria-label={t("header.account.settings")} shortLabel={t(moduleRailKey.settings)}
aria-current={page === "settings" ? "page" : undefined} showLabel={railLabels}
className={`${rowBase} justify-center px-0 is-current`} active={page === "settings"}
> onClick={() => setPage("settings")}
<Settings className="w-4.5 h-4.5 shrink-0" /> />
</Pressable>
</Tooltip>
<div> <div>
<Pressable <Pressable
@@ -257,7 +235,7 @@ export default function NavSidebar({
{...acctPop.getReferenceProps()} {...acctPop.getReferenceProps()}
title={`${name} · ${roleLabel}`} title={`${name} · ${roleLabel}`}
aria-label={`${name} · ${roleLabel}`} aria-label={`${name} · ${roleLabel}`}
className={`${rowBase} justify-center px-0 text-muted hover:text-fg hover:bg-card`} className={`${RAIL_ROW} h-9 text-muted state-layer hover:text-fg`}
> >
<span className="relative shrink-0"> <span className="relative shrink-0">
<AvatarImg <AvatarImg
@@ -353,7 +331,7 @@ export default function NavSidebar({
</PopoverSurface> </PopoverSurface>
</div> </div>
</div> </div>
</nav> </NavRail>
</div> </div>
); );
} }
+1 -1
View File
@@ -801,7 +801,7 @@ export default function PlayerModal({
)} )}
<PopoverSurface <PopoverSurface
popover={descPop} popover={descPop}
className="w-[min(35rem,calc(100vw-2rem))] bg-surface border border-border rounded-xl shadow-2xl p-4" className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
> >
<div className="text-xs uppercase tracking-wide text-muted mb-2"> <div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")} {t("player.description")}
+20 -4
View File
@@ -33,6 +33,7 @@ type EditablePrefs = {
theme: ThemePrefs; theme: ThemePrefs;
performanceMode: boolean; performanceMode: boolean;
hints: boolean; hints: boolean;
railLabels: boolean;
notifications: NotifSettings; notifications: NotifSettings;
}; };
@@ -50,6 +51,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
? prefs.performanceMode ? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1", : getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(), hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
// B7: off by default — the icon rail with name tooltips is the house look.
railLabels: prefs?.railLabels === true,
notifications: prefs?.notifications notifications: prefs?.notifications
? { ...getNotifSettings(), ...prefs.notifications } ? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(), : getNotifSettings(),
@@ -58,6 +61,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme); const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
const [perf, setPerf] = useState(seed.performanceMode); const [perf, setPerf] = useState(seed.performanceMode);
const [hints, setHints] = useState(seed.hints); const [hints, setHints] = useState(seed.hints);
const [railLabels, setRailLabels] = useState(seed.railLabels);
const [notif, setNotif] = useState<NotifSettings>(seed.notifications); const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
// The last server-known prefs: the seed, then each successful save. The auto-save effect compares // The last server-known prefs: the seed, then each successful save. The auto-save effect compares
// against it, so the seed itself is never echoed back as a save. A ref (drives no UI). // against it, so the seed itself is never echoed back as a save. A ref (drives no UI).
@@ -88,8 +92,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being // effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
// saved straight back (`/me` merges, so a full payload is fine). // saved straight back (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>( const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, performanceMode: perf, hints, notifications: notif }), () => ({ theme, performanceMode: perf, hints, railLabels, notifications: notif }),
[theme, perf, hints, notif], [theme, perf, hints, railLabels, notif],
); );
useEffect(() => { useEffect(() => {
window.clearTimeout(saveTimer.current); window.clearTimeout(saveTimer.current);
@@ -135,8 +139,20 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const value = useMemo<PrefsController>( const value = useMemo<PrefsController>(
() => ({ theme, setTheme, perf, setPerf, hints, setHints, notif, setNotif, saveState }), () => ({
[theme, setTheme, perf, hints, notif, saveState], theme,
setTheme,
perf,
setPerf,
hints,
setHints,
railLabels,
setRailLabels,
notif,
setNotif,
saveState,
}),
[theme, setTheme, perf, hints, railLabels, notif, saveState],
); );
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>; return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
+13 -1
View File
@@ -41,6 +41,8 @@ export interface PrefsController {
setPerf: (v: boolean) => void; setPerf: (v: boolean) => void;
hints: boolean; hints: boolean;
setHints: (v: boolean) => void; setHints: (v: boolean) => void;
railLabels: boolean;
setRailLabels: (v: boolean) => void;
notif: NotifSettings; notif: NotifSettings;
setNotif: (n: NotifSettings) => void; setNotif: (n: NotifSettings) => void;
saveState: PrefsSaveState; saveState: PrefsSaveState;
@@ -139,7 +141,7 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
function Appearance({ prefs }: { prefs: PrefsController }) { function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced). // Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs; const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs;
// The landing page (E24). Per-account localStorage, not a server preference like the rest of this // The landing page (E24). Per-account localStorage, not a server preference like the rest of this
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored // tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
// values come from navigationInit, which is what reads them back at boot. // values come from navigationInit, which is what reads them back at boot.
@@ -215,6 +217,16 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
> >
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} /> <Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
</SettingRow> </SettingRow>
<SettingRow
label={t("settings.appearance.railLabels")}
hint={t("settings.appearance.railLabelsHint")}
>
<Switch
label={t("settings.appearance.railLabels")}
checked={railLabels}
onChange={setRailLabels}
/>
</SettingRow>
</Section> </Section>
<Section title={t("settings.appearance.startup")}> <Section title={t("settings.appearance.startup")}>
+1 -1
View File
@@ -334,7 +334,7 @@ export default function WatchPage() {
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native /* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
<audio>. No poster exists for these, so present it as a deliberate padded bar on <audio>. No poster exists for these, so present it as a deliberate padded bar on
the surface (not the black video box) so it looks intentional without artwork. */ the surface (not the black video box) so it looks intentional without artwork. */
<div className="rounded-xl border border-border bg-surface p-4"> <div className={`${SURFACE.card} p-4`}>
<audio <audio
ref={setMedia} ref={setMedia}
controls controls
+1 -1
View File
@@ -888,7 +888,7 @@ function BlockedPanel({
onBack={onBack} onBack={onBack}
> >
{gated && ( {gated && (
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2"> <div className={`${SURFACE.card} px-3.5 py-3 flex flex-col gap-2`}>
<Gate done={emailVerified === true} k="email" /> <Gate done={emailVerified === true} k="email" />
<Gate done={approved === true} k="approval" /> <Gate done={approved === true} k="approval" />
</div> </div>
+2
View File
@@ -13,6 +13,8 @@ export const PrefsContext = createContext<PrefsController>({
setPerf: () => {}, setPerf: () => {},
hints: true, hints: true,
setHints: () => {}, setHints: () => {},
railLabels: false,
setRailLabels: () => {},
notif: getNotifSettings(), notif: getNotifSettings(),
setNotif: () => {}, setNotif: () => {},
saveState: "idle", saveState: "idle",
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
//
// E28 S3d (B7): the nav rail's row. Icon-only, its name is the module's full name (with an unread
// count, which was only ever drawn); with the labels on, the name is the short label the user SEES
// (WCAG 2.5.3), the full one staying in the tooltip. "You are here" is aria-current plus the tint.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { Bell } from "lucide-react";
import { NavRailItem } from "./NavRail";
vi.mock("../../lib/hints", () => ({ hintsEnabled: () => true, subscribeHints: () => () => {} }));
afterEach(cleanup);
describe("NavRailItem", () => {
it("icon-only: the full name, the count in it, and the current page marked", () => {
render(
<NavRailItem
icon={Bell}
label="Notifications"
shortLabel="Alerts"
showLabel={false}
active
badge={3}
onClick={() => {}}
/>,
);
const row = screen.getByRole("button", { name: "Notifications (3)" });
expect(row.getAttribute("aria-current")).toBe("page");
expect(row.className).toContain("bg-accent/15");
expect(row.textContent).not.toContain("Alerts");
});
it("labelled: the name is the visible short label", () => {
render(
<NavRailItem
icon={Bell}
label="Notifications"
shortLabel="Alerts"
showLabel
onClick={() => {}}
/>,
);
const row = screen.getByRole("button", { name: "Alerts" });
expect(row.getAttribute("aria-current")).toBeNull();
});
});
+106
View File
@@ -0,0 +1,106 @@
import clsx from "clsx";
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
import Tooltip from "./Tooltip";
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
// edge bar, calmer than a solid fill beside the page's own solid accents. Labels under the icons are
// a per-user setting (off by default); each module has a SHORT rail label that fits, and the full name
// is the tooltip.
/** The rail's column: the glass panel, the rail's width and its padding. */
export function NavRail({
label,
className,
children,
}: {
/** The landmark's accessible name ("Primary navigation"). */
label: string;
/** Placement only (position, height); the look and width are the primitive's. */
className?: string;
children: ReactNode;
}) {
return (
<nav
aria-label={label}
className={clsx(SURFACE.panel, "flex w-15 flex-col px-1 py-2.5", className)}
>
{children}
</nav>
);
}
/** A short, thick centred rule: the "new section" cue on an icon rail (the system modules). */
export function NavRailDivider() {
return <div className="mx-auto my-1.5 w-7 shrink-0 border-t-2 border-border" />;
}
/** The shape of one rail row, for a row that is not a page (the account button). */
export const RAIL_ROW =
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg transition";
export function NavRailItem({
icon: Icon,
label,
shortLabel,
showLabel,
active = false,
badge,
onClick,
testId,
}: {
icon: LucideIcon;
/** The full name: the tooltip, and the accessible name while the label is hidden. */
label: string;
/** What fits under the icon when labels are on. */
shortLabel: string;
showLabel: boolean;
active?: boolean;
/** An unread / in-progress count on the icon's corner (0 hides it). */
badge?: number;
onClick: () => void;
testId?: string;
}) {
const shown = !!badge && badge > 0;
return (
<Tooltip hint={label} side="right" always openDelay={350}>
<Pressable
data-testid={testId}
onClick={onClick}
// With the label visible the row's name IS that text (WCAG 2.5.3: the spoken name contains the
// visible one); the full name stays in the tooltip. Icon-only, the full name is the name.
// The count rides in the name too, so "Messages, 3" is heard and not only seen.
aria-label={showLabel ? undefined : shown ? `${label} (${badge})` : label}
aria-current={active ? "page" : undefined}
className={clsx(
RAIL_ROW,
showLabel ? "py-1.5" : "h-9",
active ? "bg-accent/15 text-accent" : "text-muted state-layer hover:text-fg",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute top-1.5 bottom-1.5 -left-1 w-0.75 rounded-r-full bg-accent"
/>
)}
<span className="relative shrink-0">
<Icon className="size-4.5" aria-hidden="true" />
{shown && (
<span className="absolute -top-2 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 text-3xs leading-none font-bold text-accent-fg ring-2 ring-bg">
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
{showLabel && (
<span className="max-w-full truncate text-3xs font-medium tracking-tight">
{shortLabel}
</span>
)}
</Pressable>
</Tooltip>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ domain-free.
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the | | actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` | | data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` | | page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` | | state & messaging | `QueryState`, `Banner`, `InlineNotice` |
+17
View File
@@ -4,5 +4,22 @@
"admin": "Admin", "admin": "Admin",
"user": "User", "user": "User",
"demo": "Demo" "demo": "Demo"
},
"rail": {
"home": "Home",
"feed": "Feed",
"channels": "Channels",
"playlists": "Playlists",
"plex": "Plex",
"notifications": "Alerts",
"messages": "Messages",
"downloads": "Downloads",
"stats": "Stats",
"scheduler": "Scheduler",
"config": "Config",
"users": "Users",
"audit": "Audit",
"logs": "Logs",
"settings": "Settings"
} }
} }
@@ -33,6 +33,8 @@
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.", "performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
"showHints": "Show hints", "showHints": "Show hints",
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.", "showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
"railLabels": "Labels in the side menu",
"railLabelsHint": "A short name under each icon in the left menu. The menu stays as narrow; it only grows taller.",
"startup": "Startup", "startup": "Startup",
"startOnHome": "Start on Home", "startOnHome": "Start on Home",
"startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.", "startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.",
+17
View File
@@ -4,5 +4,22 @@
"admin": "Admin", "admin": "Admin",
"user": "Felhasználó", "user": "Felhasználó",
"demo": "Demó" "demo": "Demó"
},
"rail": {
"home": "Kezdőlap",
"feed": "Feed",
"channels": "Csatornák",
"playlists": "Listák",
"plex": "Plex",
"notifications": "Értesítés",
"messages": "Üzenetek",
"downloads": "Letöltések",
"stats": "Statisztika",
"scheduler": "Ütemező",
"config": "Konfig",
"users": "Fiókok",
"audit": "Audit",
"logs": "Napló",
"settings": "Beállítások"
} }
} }
@@ -33,6 +33,8 @@
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.", "performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
"showHints": "Tippek megjelenítése", "showHints": "Tippek megjelenítése",
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.", "showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
"railLabels": "Feliratok az oldalsó menüben",
"railLabelsHint": "Rövid név minden ikon alatt a bal oldali menüben. A menü ugyanolyan keskeny marad, csak magasabb lesz.",
"startup": "Indulás", "startup": "Indulás",
"startOnHome": "Indítás a kezdőlapon", "startOnHome": "Indítás a kezdőlapon",
"startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.", "startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.",
+6 -14
View File
@@ -563,8 +563,7 @@ body {
in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/ in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/
border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add
data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */ data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */
.pill, .pill {
.is-current {
color: var(--muted); color: var(--muted);
transition: transition:
color 0.15s ease, color 0.15s ease,
@@ -576,25 +575,18 @@ body {
/* Resting border colour for the plain pills. NOT applied to a glass-card-resting pill (`.pill.glass-card`), /* Resting border colour for the plain pills. NOT applied to a glass-card-resting pill (`.pill.glass-card`),
whose own border — including its lighter top-edge specular highlight — must survive. Inert unless the whose own border — including its lighter top-edge specular highlight — must survive. Inert unless the
site also adds a border-width utility. */ site also adds a border-width utility. */
.pill:not(.glass-card), .pill:not(.glass-card) {
.is-current {
border-color: var(--border); border-color: var(--border);
} }
.pill:hover, .pill:hover {
.is-current:hover {
color: var(--fg); color: var(--fg);
} }
.pill:hover { .pill:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent); border-color: color-mix(in srgb, var(--accent) 55%, transparent);
} }
.is-current:hover { /* selected — SOLID accent fill (the dominant idiom). (The nav rail's "you are here" is ui/NavRail's
background-color: var(--card); tint + edge bar since E28 S3d; its old `.is-current` recipe is gone.) */
} .pill[aria-pressed="true"] {
/* selected / current — SOLID accent fill (the dominant idiom). The `:not([aria-current="false"])`
guard matters: `.is-current` is a shared primitive and a consumer using the common
`aria-current={x ? "true" : "false"}` idiom would otherwise paint a NON-current row as current. */
.pill[aria-pressed="true"],
.is-current[aria-current]:not([aria-current="false"]) {
background-color: var(--accent); background-color: var(--accent);
color: var(--accent-fg); color: var(--accent-fg);
border-color: var(--accent); border-color: var(--accent);
+52 -6
View File
@@ -28,6 +28,10 @@ export type ModuleDef = {
id: Page; id: Page;
// i18n key for the SHORT display name — the same label the nav rail and the header pill show. // i18n key for the SHORT display name — the same label the nav rail and the header pill show.
labelKey: string; labelKey: string;
// The SHORT name under the icon when the user turns the rail labels on (B7): the rail is 3.75rem
// wide with labels or without, so it has to fit there ("Alerts", "Listák"). The full `labelKey`
// stays the tooltip and the accessible name.
railKey: string;
// i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's // i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's
// "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`. // "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`.
titleKey?: string; titleKey?: string;
@@ -51,20 +55,45 @@ const MODULES: readonly ModuleDef[] = [
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the // Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
// nav order and the header stepper's sequence. The feed takes the YouTube mark its own toolbar // nav order and the header stepper's sequence. The feed takes the YouTube mark its own toolbar
// already uses, so the two are apart by shape and not just by position. // already uses, so the two are apart by shape and not just by position.
{ id: "home", labelKey: "home.navLabel", titleKey: "home.title", icon: Home }, {
{ id: "feed", labelKey: "header.account.feed", icon: Youtube }, id: "home",
labelKey: "home.navLabel",
railKey: "nav.rail.home",
titleKey: "home.title",
icon: Home,
},
{ id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: Youtube },
{ {
id: "channels", id: "channels",
labelKey: "header.account.channels", labelKey: "header.account.channels",
railKey: "nav.rail.channels",
titleKey: "header.channelManager", titleKey: "header.channelManager",
icon: Tv, icon: Tv,
}, },
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo }, {
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled }, id: "playlists",
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true }, labelKey: "header.account.playlists",
railKey: "nav.rail.playlists",
icon: ListVideo,
},
{
id: "plex",
labelKey: "plex.navLabel",
railKey: "nav.rail.plex",
icon: Clapperboard,
gate: (me) => me.plex_enabled,
},
{
id: "notifications",
labelKey: "inbox.navLabel",
railKey: "nav.rail.notifications",
icon: Bell,
hasBadge: true,
},
{ {
id: "messages", id: "messages",
labelKey: "messages.navLabel", labelKey: "messages.navLabel",
railKey: "nav.rail.messages",
icon: MessageSquare, icon: MessageSquare,
hasBadge: true, hasBadge: true,
gate: notDemo, gate: notDemo,
@@ -72,14 +101,22 @@ const MODULES: readonly ModuleDef[] = [
{ {
id: "downloads", id: "downloads",
labelKey: "downloads.navLabel", labelKey: "downloads.navLabel",
railKey: "nav.rail.downloads",
icon: Download, icon: Download,
hasBadge: true, hasBadge: true,
gate: notDemo, gate: notDemo,
}, },
{ id: "stats", labelKey: "header.account.stats", titleKey: "header.usageStats", icon: BarChart3 }, {
id: "stats",
labelKey: "header.account.stats",
railKey: "nav.rail.stats",
titleKey: "header.usageStats",
icon: BarChart3,
},
{ {
id: "scheduler", id: "scheduler",
labelKey: "header.account.scheduler", labelKey: "header.account.scheduler",
railKey: "nav.rail.scheduler",
titleKey: "header.scheduler", titleKey: "header.scheduler",
icon: Activity, icon: Activity,
system: true, system: true,
@@ -88,6 +125,7 @@ const MODULES: readonly ModuleDef[] = [
{ {
id: "config", id: "config",
labelKey: "header.account.config", labelKey: "header.account.config",
railKey: "nav.rail.config",
titleKey: "header.configuration", titleKey: "header.configuration",
icon: SlidersHorizontal, icon: SlidersHorizontal,
system: true, system: true,
@@ -96,6 +134,7 @@ const MODULES: readonly ModuleDef[] = [
{ {
id: "users", id: "users",
labelKey: "header.account.users", labelKey: "header.account.users",
railKey: "nav.rail.users",
titleKey: "header.users", titleKey: "header.users",
icon: Users, icon: Users,
system: true, system: true,
@@ -104,6 +143,7 @@ const MODULES: readonly ModuleDef[] = [
{ {
id: "audit", id: "audit",
labelKey: "header.account.audit", labelKey: "header.account.audit",
railKey: "nav.rail.audit",
titleKey: "audit.title", titleKey: "audit.title",
icon: ScrollText, icon: ScrollText,
system: true, system: true,
@@ -112,6 +152,7 @@ const MODULES: readonly ModuleDef[] = [
{ {
id: "logs", id: "logs",
labelKey: "header.account.logs", labelKey: "header.account.logs",
railKey: "nav.rail.logs",
titleKey: "adminLogs.title", titleKey: "adminLogs.title",
icon: FileSearch, icon: FileSearch,
system: true, system: true,
@@ -124,6 +165,7 @@ const MODULES: readonly ModuleDef[] = [
const SETTINGS_MODULE: ModuleDef = { const SETTINGS_MODULE: ModuleDef = {
id: "settings", id: "settings",
labelKey: "header.account.settings", labelKey: "header.account.settings",
railKey: "nav.rail.settings",
titleKey: "settings.title", titleKey: "settings.title",
icon: Settings, icon: Settings,
}; };
@@ -149,6 +191,10 @@ export const moduleLabelKey: Record<Page, string> = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]), [...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]),
) as Record<Page, string>; ) as Record<Page, string>;
export const moduleRailKey: Record<Page, string> = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.railKey]),
) as Record<Page, string>;
// Admin/system modules — rendered in their own section (below a divider) in the nav rail. // Admin/system modules — rendered in their own section (below a divider) in the nav rail.
export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id); export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id);