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
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).
- **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
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" |
"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
+1 -1
View File
@@ -113,7 +113,7 @@ function StartDialog() {
onChange={setLevel}
/>
</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>
<p>{t(`recorder.start.what.${level}`)}</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 { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// 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),
// alive to all three input paths.
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">
{card.thumb ? (
@@ -305,7 +306,7 @@ function Hero({
return (
<section
{...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
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
// 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
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. */}
+4 -3
View File
@@ -27,6 +27,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Textarea, Input } from "./ui/Input";
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
// 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 [kept, total] = counts[source];
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}
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">
@@ -657,7 +658,7 @@ function Attachments({
};
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>
{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 />)}
@@ -669,7 +670,7 @@ function Attachments({
{shots.map((shot, i) => (
<div
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
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 { getUnreadCount, subscribe } from "../lib/notifications";
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 { type LangCode } from "../i18n";
import { IconButton } from "./ui/IconButton";
import AvatarImg from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
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
// 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))
.map((p) => ({ page: p, ...META(p) }));
const rowBase =
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const { railLabels } = usePrefs();
const name = displayName(me);
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
const roleLabel = t(`nav.role.${roleKey(me)}`);
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p;
// On the active row the background is the accent colour, so a same-accent badge would be
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast.
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg";
const show = !!badge && badge > 0;
return (
// The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only.
<Tooltip key={p} hint={label} side="right" always openDelay={350}>
<Pressable
data-testid={`nav-${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>
);
};
const renderItem = ({ page: p, icon, label, badge }: NavItem) => (
<NavRailItem
key={p}
testId={`nav-${p}`}
icon={icon}
label={label}
shortLabel={t(moduleRailKey[p])}
showLabel={railLabels}
active={page === p}
badge={badge}
onClick={() => setPage(p)}
/>
);
return (
// Slim icon rail, 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.
<div className="relative shrink-0 w-14">
<nav
className={`${SURFACE.panel} absolute top-0 bottom-0 left-0 z-rail flex flex-col py-3 my-3 w-14 px-2`}
aria-label={t("nav.primary")}
>
// Slim rail (B7), flush against the left viewport edge (only top/bottom margin), never grows —
// labels on or off. The slot is exactly the rail's width so the filter tab/bar docks flush
// against its right edge.
<div className="relative shrink-0 w-15">
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
<div
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}
>
{userItems.map(renderItem)}
{systemItems.length > 0 && (
// 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.length > 0 && <NavRailDivider />}
{systemItems.map(renderItem)}
</div>
@@ -239,17 +219,15 @@ export default function NavSidebar({
<LogOut className="w-5 h-5" />
</IconButton>
</div>
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
<Pressable
data-testid="nav-settings"
onClick={() => setPage("settings")}
aria-label={t("header.account.settings")}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} justify-center px-0 is-current`}
>
<Settings className="w-4.5 h-4.5 shrink-0" />
</Pressable>
</Tooltip>
<NavRailItem
testId="nav-settings"
icon={Settings}
label={t("header.account.settings")}
shortLabel={t(moduleRailKey.settings)}
showLabel={railLabels}
active={page === "settings"}
onClick={() => setPage("settings")}
/>
<div>
<Pressable
@@ -257,7 +235,7 @@ export default function NavSidebar({
{...acctPop.getReferenceProps()}
title={`${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">
<AvatarImg
@@ -353,7 +331,7 @@ export default function NavSidebar({
</PopoverSurface>
</div>
</div>
</nav>
</NavRail>
</div>
);
}
+1 -1
View File
@@ -801,7 +801,7 @@ export default function PlayerModal({
)}
<PopoverSurface
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">
{t("player.description")}
+20 -4
View File
@@ -33,6 +33,7 @@ type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
hints: boolean;
railLabels: boolean;
notifications: NotifSettings;
};
@@ -50,6 +51,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
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
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
@@ -58,6 +61,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
const [perf, setPerf] = useState(seed.performanceMode);
const [hints, setHints] = useState(seed.hints);
const [railLabels, setRailLabels] = useState(seed.railLabels);
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
// 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).
@@ -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
// saved straight back (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
[theme, perf, hints, notif],
() => ({ theme, performanceMode: perf, hints, railLabels, notifications: notif }),
[theme, perf, hints, railLabels, notif],
);
useEffect(() => {
window.clearTimeout(saveTimer.current);
@@ -135,8 +139,20 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
}, []);
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>;
+13 -1
View File
@@ -41,6 +41,8 @@ export interface PrefsController {
setPerf: (v: boolean) => void;
hints: boolean;
setHints: (v: boolean) => void;
railLabels: boolean;
setRailLabels: (v: boolean) => void;
notif: NotifSettings;
setNotif: (n: NotifSettings) => void;
saveState: PrefsSaveState;
@@ -139,7 +141,7 @@ function SaveIndicator({ state }: { state: PrefsSaveState }) {
function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// 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
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
// 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} />
</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 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>. 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. */
<div className="rounded-xl border border-border bg-surface p-4">
<div className={`${SURFACE.card} p-4`}>
<audio
ref={setMedia}
controls
+1 -1
View File
@@ -888,7 +888,7 @@ function BlockedPanel({
onBack={onBack}
>
{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={approved === true} k="approval" />
</div>
+2
View File
@@ -13,6 +13,8 @@ export const PrefsContext = createContext<PrefsController>({
setPerf: () => {},
hints: true,
setHints: () => {},
railLabels: false,
setRailLabels: () => {},
notif: getNotifSettings(),
setNotif: () => {},
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 |
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` |
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
+17
View File
@@ -4,5 +4,22 @@
"admin": "Admin",
"user": "User",
"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.",
"showHints": "Show hints",
"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",
"startOnHome": "Start on Home",
"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",
"user": "Felhasználó",
"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.",
"showHints": "Tippek megjelenítése",
"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",
"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.",
+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/
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. */
.pill,
.is-current {
.pill {
color: var(--muted);
transition:
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`),
whose own border — including its lighter top-edge specular highlight — must survive. Inert unless the
site also adds a border-width utility. */
.pill:not(.glass-card),
.is-current {
.pill:not(.glass-card) {
border-color: var(--border);
}
.pill:hover,
.is-current:hover {
.pill:hover {
color: var(--fg);
}
.pill:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.is-current:hover {
background-color: var(--card);
}
/* 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"]) {
/* selected — SOLID accent fill (the dominant idiom). (The nav rail's "you are here" is ui/NavRail's
tint + edge bar since E28 S3d; its old `.is-current` recipe is gone.) */
.pill[aria-pressed="true"] {
background-color: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
+52 -6
View File
@@ -28,6 +28,10 @@ export type ModuleDef = {
id: Page;
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
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
// "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`.
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
// 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.
{ 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",
labelKey: "header.account.channels",
railKey: "nav.rail.channels",
titleKey: "header.channelManager",
icon: Tv,
},
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
{
id: "playlists",
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",
labelKey: "messages.navLabel",
railKey: "nav.rail.messages",
icon: MessageSquare,
hasBadge: true,
gate: notDemo,
@@ -72,14 +101,22 @@ const MODULES: readonly ModuleDef[] = [
{
id: "downloads",
labelKey: "downloads.navLabel",
railKey: "nav.rail.downloads",
icon: Download,
hasBadge: true,
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",
labelKey: "header.account.scheduler",
railKey: "nav.rail.scheduler",
titleKey: "header.scheduler",
icon: Activity,
system: true,
@@ -88,6 +125,7 @@ const MODULES: readonly ModuleDef[] = [
{
id: "config",
labelKey: "header.account.config",
railKey: "nav.rail.config",
titleKey: "header.configuration",
icon: SlidersHorizontal,
system: true,
@@ -96,6 +134,7 @@ const MODULES: readonly ModuleDef[] = [
{
id: "users",
labelKey: "header.account.users",
railKey: "nav.rail.users",
titleKey: "header.users",
icon: Users,
system: true,
@@ -104,6 +143,7 @@ const MODULES: readonly ModuleDef[] = [
{
id: "audit",
labelKey: "header.account.audit",
railKey: "nav.rail.audit",
titleKey: "audit.title",
icon: ScrollText,
system: true,
@@ -112,6 +152,7 @@ const MODULES: readonly ModuleDef[] = [
{
id: "logs",
labelKey: "header.account.logs",
railKey: "nav.rail.logs",
titleKey: "adminLogs.title",
icon: FileSearch,
system: true,
@@ -124,6 +165,7 @@ const MODULES: readonly ModuleDef[] = [
const SETTINGS_MODULE: ModuleDef = {
id: "settings",
labelKey: "header.account.settings",
railKey: "nav.rail.settings",
titleKey: "settings.title",
icon: Settings,
};
@@ -149,6 +191,10 @@ export const moduleLabelKey: Record<Page, string> = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]),
) 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.
export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id);