diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 90fa1955..53c6dd92 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -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
diff --git a/frontend/src/components/DebugRecorder.tsx b/frontend/src/components/DebugRecorder.tsx
index 68e97659..e7584c8c 100644
--- a/frontend/src/components/DebugRecorder.tsx
+++ b/frontend/src/components/DebugRecorder.tsx
@@ -113,7 +113,7 @@ function StartDialog() {
onChange={setLevel}
/>
-
+
{t("recorder.start.whatTitle")}
{t(`recorder.start.what.${level}`)}
{t("settings.diagnostics.consent.never")}
diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx
index e41701d0..9e03d2b2 100644
--- a/frontend/src/components/Home.tsx
+++ b/frontend/src/components/Home.tsx
@@ -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`}
>
{card.thumb ? (
@@ -305,7 +306,7 @@ function Hero({
return (
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.
-
+
{/* 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. */}
diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx
index 75906135..c5227e69 100644
--- a/frontend/src/components/IssueComposer.tsx
+++ b/frontend/src/components/IssueComposer.tsx
@@ -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 (
-
+
{icon}
@@ -657,7 +658,7 @@ function Attachments({
};
return (
-
+
{t("recorder.composer.attachments")}
{logRow("web",
)}
{logRow("server",
)}
@@ -669,7 +670,7 @@ function Attachments({
{shots.map((shot, i) => (
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.
-
- setPage(p)}
- aria-label={label}
- aria-current={active ? "page" : undefined}
- className={`${rowBase} justify-center px-0 relative is-current`}
- >
-
-
- {/* 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 && (
-
- {badge > 9 ? "9+" : badge}
-
- )}
-
-
-
- );
- };
+ const renderItem = ({ page: p, icon, label, badge }: NavItem) => (
+
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.
-
-
+ // 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.
+
+
{userItems.map(renderItem)}
- {systemItems.length > 0 && (
- // A short, thicker centred rule is the clearest "new section" cue on an icon-only rail.
-
- )}
+ {systemItems.length > 0 &&
}
{systemItems.map(renderItem)}
@@ -239,17 +219,15 @@ export default function NavSidebar({
-
- setPage("settings")}
- aria-label={t("header.account.settings")}
- aria-current={page === "settings" ? "page" : undefined}
- className={`${rowBase} justify-center px-0 is-current`}
- >
-
-
-
+ setPage("settings")}
+ />
-
+
);
}
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index a0eadf00..bb5657a4 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -801,7 +801,7 @@ export default function PlayerModal({
)}
{t("player.description")}
diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx
index 3d2878bd..839d2f9a 100644
--- a/frontend/src/components/PrefsProvider.tsx
+++ b/frontend/src/components/PrefsProvider.tsx
@@ -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
(seed.theme);
const [perf, setPerf] = useState(seed.performanceMode);
const [hints, setHints] = useState(seed.hints);
+ const [railLabels, setRailLabels] = useState(seed.railLabels);
const [notif, setNotif] = useState(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(
- () => ({ 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(
- () => ({ 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 {children} ;
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 771f4807..2b83de7a 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -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 }) {
>
+
+
+
diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx
index 8025259c..7a3209a5 100644
--- a/frontend/src/components/WatchPage.tsx
+++ b/frontend/src/components/WatchPage.tsx
@@ -334,7 +334,7 @@ export default function WatchPage() {
/* Audio-only asset (mp3): WebKit won't play it in a , so render a native
. 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. */
-
+
{gated && (
-
+
diff --git a/frontend/src/components/prefsContext.ts b/frontend/src/components/prefsContext.ts
index 29988413..4849aa05 100644
--- a/frontend/src/components/prefsContext.ts
+++ b/frontend/src/components/prefsContext.ts
@@ -13,6 +13,8 @@ export const PrefsContext = createContext
({
setPerf: () => {},
hints: true,
setHints: () => {},
+ railLabels: false,
+ setRailLabels: () => {},
notif: getNotifSettings(),
setNotif: () => {},
saveState: "idle",
diff --git a/frontend/src/components/ui/NavRail.dom.test.tsx b/frontend/src/components/ui/NavRail.dom.test.tsx
new file mode 100644
index 00000000..316c5af8
--- /dev/null
+++ b/frontend/src/components/ui/NavRail.dom.test.tsx
@@ -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(
+ {}}
+ />,
+ );
+ 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(
+ {}}
+ />,
+ );
+ const row = screen.getByRole("button", { name: "Alerts" });
+ expect(row.getAttribute("aria-current")).toBeNull();
+ });
+});
diff --git a/frontend/src/components/ui/NavRail.tsx b/frontend/src/components/ui/NavRail.tsx
new file mode 100644
index 00000000..5dec7b63
--- /dev/null
+++ b/frontend/src/components/ui/NavRail.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+/** A short, thick centred rule: the "new section" cue on an icon rail (the system modules). */
+export function NavRailDivider() {
+ return
;
+}
+
+/** 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 (
+
+
+ {active && (
+
+ )}
+
+
+ {shown && (
+
+ {badge > 9 ? "9+" : badge}
+
+ )}
+
+ {showLabel && (
+
+ {shortLabel}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md
index 52783597..dfa250c2 100644
--- a/frontend/src/components/ui/README.md
+++ b/frontend/src/components/ui/README.md
@@ -42,7 +42,7 @@ domain-free.
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw ` ` 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` |
diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json
index 819a6dac..6a0751f5 100644
--- a/frontend/src/i18n/locales/en/nav.json
+++ b/frontend/src/i18n/locales/en/nav.json
@@ -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"
}
}
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json
index dd1b4883..be79c54f 100644
--- a/frontend/src/i18n/locales/en/settings.json
+++ b/frontend/src/i18n/locales/en/settings.json
@@ -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.",
diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json
index efdc344f..620046e6 100644
--- a/frontend/src/i18n/locales/hu/nav.json
+++ b/frontend/src/i18n/locales/hu/nav.json
@@ -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"
}
}
diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json
index 1d27c829..54b215ce 100644
--- a/frontend/src/i18n/locales/hu/settings.json
+++ b/frontend/src/i18n/locales/hu/settings.json
@@ -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.",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e2493f7a..aa4ea31f 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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);
diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts
index 3c9e91bd..43aae549 100644
--- a/frontend/src/lib/modules.ts
+++ b/frontend/src/lib/modules.ts
@@ -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 = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]),
) as Record;
+export const moduleRailKey: Record = Object.fromEntries(
+ [...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.railKey]),
+) as Record;
+
// 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);