Merge feature/e1-header-chrome: E1 header/account/appearance chrome (nav overlay, role tooltip, logout, opt-in glass tuner) into dev for v0.43.0

This commit is contained in:
2026-07-14 21:41:59 +02:00
12 changed files with 109 additions and 73 deletions
+1 -1
View File
@@ -1 +1 @@
0.42.0 0.43.0
+4 -3
View File
@@ -369,8 +369,9 @@ export default function App() {
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences so it follows the account across devices; a localStorage cache seeds the // user's preferences so it follows the account across devices; a localStorage cache seeds the
// initial render synchronously so nothing flashes open before the server prefs arrive. // initial render synchronously so nothing flashes open before the server prefs arrive.
// Default unpinned (slim + hover-expand overlay); accounts that explicitly pin keep their choice.
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() => const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.navCollapsed, false)) Boolean(readAccount(LS.navCollapsed, true))
); );
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() => const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, false)) Boolean(readAccount(LS.filterCollapsed, false))
@@ -984,8 +985,8 @@ export default function App() {
<ErrorDialog /> <ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */} {/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} /> <BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
{/* Local-only live appearance tuner (renders only on localhost; null everywhere else). */} {/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
<GlassTuner /> <GlassTuner enabled={theme.showTuner} />
</div> </div>
); );
} }
+7 -10
View File
@@ -1,16 +1,13 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
/** /**
* Local-only live appearance tuner. Mounted at the app root so it's reachable on every page; it * A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
* writes the glass CSS custom properties (index.css `:root`) inline on the document element for * writes the glass CSS custom properties (index.css `:root`) inline on the document element for
* instant, build-free feedback, and persists to localStorage. * instant, build-free feedback, and persists to localStorage.
* *
* Only active on localhost, so it never appears in a hosted build. A scratch tool for dialling in * Opt-in via Settings → Appearance (`theme.showTuner`, off by default) — a secondary "geek toy"
* the surface values — "Copy CSS" exports them to paste into index.css. * for dialling in the surface values. "Copy CSS" exports the values.
*/ */
const DEV_TUNER =
typeof window !== "undefined" && /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
const LS_KEY = "siftlode.devGlassTuner"; const LS_KEY = "siftlode.devGlassTuner";
type Slider = { type Slider = {
@@ -93,7 +90,7 @@ function loadSaved(): Saved {
} }
} }
export default function GlassTuner() { export default function GlassTuner({ enabled }: { enabled: boolean }) {
const initial = useMemo(loadSaved, []); const initial = useMemo(loadSaved, []);
const [vars, setVars] = useState<Record<string, number>>(() => { const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {}; const base: Record<string, number> = {};
@@ -216,7 +213,7 @@ export default function GlassTuner() {
persist({ open: v }); persist({ open: v });
} }
if (!DEV_TUNER) return null; if (!enabled) return null;
return ( return (
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}> <div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
@@ -225,7 +222,7 @@ export default function GlassTuner() {
onClick={() => persistOpen(true)} onClick={() => persistOpen(true)}
style={{ pointerEvents: "auto" }} style={{ pointerEvents: "auto" }}
className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg" className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg"
title="Open the glass tuner (dev only)" title="Open the glass tuner"
> >
<span style={{ writingMode: "vertical-rl" }}>◐ GLASS</span> <span style={{ writingMode: "vertical-rl" }}>◐ GLASS</span>
</button> </button>
@@ -237,7 +234,7 @@ export default function GlassTuner() {
> >
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60"> <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<span className="text-sm font-semibold text-fg">◐ Glass Tuner</span> <span className="text-sm font-semibold text-fg">◐ Glass Tuner</span>
<span className="text-[10px] uppercase tracking-wider text-muted">dev</span> <span className="text-[10px] uppercase tracking-wider text-muted">lab</span>
<button <button
onClick={() => persistOpen(false)} onClick={() => persistOpen(false)}
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1" className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
+4 -3
View File
@@ -103,9 +103,10 @@ export default function Header({
} }
return ( return (
// Fixed left = the left zone at its widest: nav card (12 margin + 208 + 12 margin = 232) + // Absolutely positioned within the (relative) content column, so its left edge tracks the
// filter sidebar (256) + 16 gap = 504. Constant, so it never shifts on collapse/module change. // actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
<div className="pointer-events-none fixed top-3 left-4 right-[10%] z-30 flex items-center gap-3 md:left-[504px]"> // slim and/or no filter sidebar is present — no hard-coded offset.
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-30 flex items-center gap-3">
<ModuleName <ModuleName
label={t(moduleLabelKey[page])} label={t(moduleLabelKey[page])}
labels={moduleLabels} labels={moduleLabels}
+60 -52
View File
@@ -6,8 +6,6 @@ import {
Activity, Activity,
BarChart3, BarChart3,
Bell, Bell,
ChevronLeft,
ChevronRight,
Clapperboard, Clapperboard,
Download, Download,
Home, Home,
@@ -15,6 +13,8 @@ import {
ListVideo, ListVideo,
LogOut, LogOut,
MessageSquare, MessageSquare,
Pin,
PinOff,
ScrollText, ScrollText,
Settings, Settings,
Shield, Shield,
@@ -62,6 +62,14 @@ export default function NavSidebar({
onGoToFullHistory: () => void; onGoToFullHistory: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Hover/focus expand: when unpinned, the rail sits slim (icons only) and expands to a labelled
// overlay on pointer hover or keyboard focus WITHOUT pushing content. Pinned keeps it expanded
// in-flow like before. `collapsed` (persisted per-account) now means "unpinned".
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const pinned = !collapsed;
const expanded = pinned || hovered || focused;
const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false); const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null); const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(null); const acctPanelRef = useRef<HTMLDivElement | null>(null);
@@ -213,21 +221,14 @@ export default function NavSidebar({
const rowBase = const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0]; const name = me.display_name ?? me.email.split("@")[0];
// Role shown next to the name (expanded) / as an avatar dot (collapsed). Demo isn't a real // Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
// role (it's a flag on a `user` row), so surface it first. // real role (it's a flag on a `user` row), so surface it first.
const roleKey: "admin" | "user" | "demo" = me.is_demo const roleKey: "admin" | "user" | "demo" = me.is_demo
? "demo" ? "demo"
: me.role === "admin" : me.role === "admin"
? "admin" ? "admin"
: "user"; : "user";
const roleChipCls = const roleLabel = t(`nav.role.${roleKey}`);
roleKey === "admin"
? "bg-accent/15 text-accent"
: roleKey === "demo"
? "bg-amber-500/20 text-amber-500"
: "bg-muted/15 text-muted";
const roleDotCls =
roleKey === "admin" ? "bg-accent" : roleKey === "demo" ? "bg-amber-500" : "bg-muted";
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => { const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p; const active = page === p;
@@ -239,9 +240,9 @@ export default function NavSidebar({
<button <button
key={p} key={p}
onClick={() => setPage(p)} onClick={() => setPage(p)}
title={collapsed ? label : undefined} title={slim ? label : undefined}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} relative ${ className={`${rowBase} ${slim ? "justify-center px-0" : ""} relative ${
active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card" active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
}`} }`}
> >
@@ -249,7 +250,7 @@ export default function NavSidebar({
<Icon className="w-[18px] h-[18px]" /> <Icon className="w-[18px] h-[18px]" />
{/* Collapsed rail: a numeric badge centred on a circle at the icon corner; the ring {/* Collapsed rail: 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. */} matches the row background so it reads cleanly whether the row is active or not. */}
{show && collapsed && ( {show && slim && (
<span <span
className={`absolute -top-2 -right-2 min-w-[16px] h-4 px-1 rounded-full text-[10px] font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${ className={`absolute -top-2 -right-2 min-w-[16px] h-4 px-1 rounded-full text-[10px] font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg" active ? "ring-accent" : "ring-bg"
@@ -259,8 +260,8 @@ export default function NavSidebar({
</span> </span>
)} )}
</span> </span>
{!collapsed && <span className="truncate">{label}</span>} {!slim && <span className="truncate">{label}</span>}
{show && !collapsed && ( {show && !slim && (
<span <span
className={`ml-auto min-w-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`} className={`ml-auto min-w-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`}
> >
@@ -272,14 +273,25 @@ export default function NavSidebar({
}; };
return ( return (
<nav // Layout slot: reserves only the slim footprint when unpinned, so the expanded rail (below)
className={`glass relative shrink-0 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${ // overlays content instead of pushing it. Pinned reserves the full width (in-flow).
collapsed ? "w-[56px] px-2" : "w-52 px-3" <div
}`} className={`relative shrink-0 transition-[width] ${pinned ? "w-[232px]" : "w-20"}`}
aria-label={t("nav.primary")} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
}}
> >
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}> <nav
{!collapsed && ( className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
slim ? "w-[56px] px-2" : "w-52 px-3"
}`}
aria-label={t("nav.primary")}
>
<div className={`flex items-center mb-3 ${slim ? "justify-center" : "justify-between"}`}>
{!slim && (
<button <button
onClick={() => setPage("feed")} onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition" className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
@@ -290,15 +302,11 @@ export default function NavSidebar({
)} )}
<button <button
onClick={onToggleCollapse} onClick={onToggleCollapse}
title={collapsed ? t("nav.expand") : t("nav.collapse")} title={pinned ? t("nav.unpin") : t("nav.pin")}
aria-label={collapsed ? t("nav.expand") : t("nav.collapse")} aria-label={pinned ? t("nav.unpin") : t("nav.pin")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition" className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
> >
{collapsed ? ( {pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button> </button>
</div> </div>
@@ -308,7 +316,7 @@ export default function NavSidebar({
isAdmin={me.role === "admin"} isAdmin={me.role === "admin"}
onGoToFullHistory={onGoToFullHistory} onGoToFullHistory={onGoToFullHistory}
variant="rail" variant="rail"
collapsed={collapsed} collapsed={slim}
/> />
</div> </div>
@@ -319,7 +327,7 @@ export default function NavSidebar({
> >
{userItems.map(renderItem)} {userItems.map(renderItem)}
{systemItems.length > 0 && {systemItems.length > 0 &&
(collapsed ? ( (slim ? (
// Collapsed rail: a short, thicker centred rule is the clearest "new section" cue // Collapsed rail: a short, thicker centred rule is the clearest "new section" cue
// when there's no room for a label. // when there's no room for a label.
<div className="my-2 mx-auto w-7 border-t-2 border-border" /> <div className="my-2 mx-auto w-7 border-t-2 border-border" />
@@ -337,7 +345,7 @@ export default function NavSidebar({
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1"> <div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div <div
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${ className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
collapsed ? "flex-col" : "flex-row" slim ? "flex-col" : "flex-row"
}`} }`}
> >
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} /> <LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
@@ -349,27 +357,35 @@ export default function NavSidebar({
> >
<Info className="w-5 h-5" /> <Info className="w-5 h-5" />
</button> </button>
<button
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-5 h-5" />
</button>
</div> </div>
<button <button
onClick={() => setPage("settings")} onClick={() => setPage("settings")}
title={collapsed ? t("header.account.settings") : undefined} title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined} aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${ className={`${rowBase} ${slim ? "justify-center px-0" : ""} ${
page === "settings" page === "settings"
? "bg-accent text-accent-fg" ? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card" : "text-muted hover:text-fg hover:bg-card"
}`} }`}
> >
<Settings className="w-[18px] h-[18px] shrink-0" /> <Settings className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{t("header.account.settings")}</span>} {!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button> </button>
<div className="relative"> <div className="relative">
<button <button
ref={acctBtnRef} ref={acctBtnRef}
onClick={toggleAccount} onClick={toggleAccount}
title={collapsed ? name : undefined} title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`} className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
> >
<span className="relative shrink-0"> <span className="relative shrink-0">
<AvatarImg <AvatarImg
@@ -377,19 +393,10 @@ export default function NavSidebar({
fallback={name} fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px]" className="w-[22px] h-[22px] rounded-full text-[10px]"
/> />
{collapsed && (
<span
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full ring-2 ring-bg ${roleDotCls}`}
title={t(`nav.role.${roleKey}`)}
/>
)}
</span> </span>
{!collapsed && <span className="truncate flex-1 text-left">{name}</span>} {!slim && (
{!collapsed && ( <span title={roleLabel} className="truncate flex-1 text-left">
<span {name}
className={`shrink-0 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${roleChipCls}`}
>
{t(`nav.role.${roleKey}`)}
</span> </span>
)} )}
</button> </button>
@@ -469,6 +476,7 @@ export default function NavSidebar({
)} )}
</div> </div>
</div> </div>
</nav> </nav>
</div>
); );
} }
+10
View File
@@ -205,6 +205,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.showTuner")}
hint={t("settings.appearance.showTunerHint")}
>
<Switch
label={t("settings.appearance.showTuner")}
checked={theme.showTuner}
onChange={(v) => setTheme({ ...theme, showTuner: v })}
/>
</SettingRow>
</Section> </Section>
<Section title={t("settings.appearance.textSize")}> <Section title={t("settings.appearance.textSize")}>
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"primary": "Primary navigation", "primary": "Primary navigation",
"collapse": "Collapse sidebar", "pin": "Pin sidebar open",
"expand": "Expand sidebar", "unpin": "Unpin sidebar",
"adminSection": "Admin", "adminSection": "Admin",
"role": { "role": {
"admin": "Admin", "admin": "Admin",
@@ -42,6 +42,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.",
"showTuner": "Show the glass tuner",
"showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.",
"textSize": "Text size" "textSize": "Text size"
}, },
"notifications": { "notifications": {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"primary": "Fő navigáció", "primary": "Fő navigáció",
"collapse": "Oldalsáv összecsukása", "pin": "Oldalsáv rögzítése nyitva",
"expand": "Oldalsáv kinyitása", "unpin": "Oldalsáv rögzítésének feloldása",
"adminSection": "Admin", "adminSection": "Admin",
"role": { "role": {
"admin": "Admin", "admin": "Admin",
@@ -42,6 +42,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.",
"showTuner": "Glass tuner megjelenítése",
"showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.",
"textSize": "Betűméret" "textSize": "Betűméret"
}, },
"notifications": { "notifications": {
+12
View File
@@ -14,6 +14,18 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.43.0",
date: "2026-07-14",
summary:
"A slimmer, smarter navigation sidebar, a couple of account tweaks, and an optional live appearance tuner.",
features: [
"The navigation sidebar now stays slim by default and slides open over the page when you hover it or tab into it — so it barely takes any room until you need it. Prefer it always open? Pin it with the button at the top of the rail.",
"The top bar reclaims the empty space on the left: it now hugs your content instead of leaving a fixed gap when the sidebar is slim.",
"A logout button now sits right next to the language and about buttons, and your role shows as a tooltip on your name (the separate badge is gone).",
"New optional glass tuner: a floating panel for live-tweaking the frosted-glass look. It's off by default — switch it on under Settings → Appearance if you like to fiddle.",
],
},
{ {
version: "0.42.0", version: "0.42.0",
date: "2026-07-13", date: "2026-07-13",
+3
View File
@@ -20,6 +20,8 @@ export interface ThemePrefs {
bgImage: boolean; bgImage: boolean;
/** How strongly the background image is faded out, 0–100% (higher = fainter). */ /** How strongly the background image is faded out, 0–100% (higher = fainter). */
bgFade: number; bgFade: number;
/** Show the floating glass/theme tuner (a secondary "geek toy"); off by default. */
showTuner: boolean;
} }
export const DEFAULT_THEME: ThemePrefs = { export const DEFAULT_THEME: ThemePrefs = {
@@ -28,6 +30,7 @@ export const DEFAULT_THEME: ThemePrefs = {
fontScale: 1.06, fontScale: 1.06,
bgImage: true, bgImage: true,
bgFade: 60, bgFade: 60,
showTuner: false,
}; };
export function applyTheme(t: ThemePrefs): void { export function applyTheme(t: ThemePrefs): void {