From 7fa8f4ebde78a25bb2a3a187502d90bee1f03a4e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 25 Sep 2026 00:47:46 +0200 Subject: [PATCH] feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5) --- docs/DESIGN.md | 6 +- frontend/src/components/DebugRecorder.tsx | 2 +- frontend/src/components/Home.tsx | 7 +- frontend/src/components/IssueComposer.tsx | 7 +- frontend/src/components/NavSidebar.tsx | 104 +++++++---------- frontend/src/components/PlayerModal.tsx | 2 +- frontend/src/components/PrefsProvider.tsx | 24 +++- frontend/src/components/SettingsPanel.tsx | 14 ++- frontend/src/components/WatchPage.tsx | 2 +- frontend/src/components/Welcome.tsx | 2 +- frontend/src/components/prefsContext.ts | 2 + .../src/components/ui/NavRail.dom.test.tsx | 47 ++++++++ frontend/src/components/ui/NavRail.tsx | 106 ++++++++++++++++++ frontend/src/components/ui/README.md | 2 +- frontend/src/i18n/locales/en/nav.json | 17 +++ frontend/src/i18n/locales/en/settings.json | 2 + frontend/src/i18n/locales/hu/nav.json | 17 +++ frontend/src/i18n/locales/hu/settings.json | 2 + frontend/src/index.css | 20 +--- frontend/src/lib/modules.ts | 58 +++++++++- 20 files changed, 342 insertions(+), 101 deletions(-) create mode 100644 frontend/src/components/ui/NavRail.dom.test.tsx create mode 100644 frontend/src/components/ui/NavRail.tsx 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. -
- +
); } 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