From 38be2f43f1f23dc1769b824eeecc1bf734431d45 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Fri, 15 May 2026 23:13:31 +1200 Subject: [PATCH] Persistent sidebar, nav & flat theme overhaul Introduce a persistent left sidebar, update navigation to support regions, and convert the UI to a flat/no-blur visual style. Key changes: - index.html: add a persistent sidebar markup with nav items, user area and main app layout. - src/core/nav.js: improve candidate scoring to use element bounding centers and overlap penalty; include extra focus roots; add region metadata handling, helpers for resolving default content/sidebar indices, and special-case movements between sidebar and content; respect contract.useNebulaNavigation flag. - src/main.js: manage sidebar visibility for full-screen flows (lock/onboarding), update sidebar active state on view changes, include sidebar as an extraFocusRoot when appropriate, and handle sidebar item activation on accept to navigate views. - src/styles/*: major visual/theme updates to remove blur/glow effects (Zero-Blur policy), add flat sidebar styles, update components to solid borders and simpler focus styling, adjust theme variables (palette, spacing, durations), and redesign the home view layout and controls. Rationale: provide a persistent, keyboard/controller-friendly sidebar for quick navigation while simplifying visuals to a flat, performance-friendly theme and refining focus/navigation behavior across sidebar and content regions. --- src/core/nav.js | 109 ++++++- src/index.html | 49 +++- src/main.js | 56 +++- src/styles/base.css | 382 ++++++++++++++----------- src/styles/components.css | 171 ++++++----- src/styles/theme.css | 77 +++-- src/views/home/home.css | 582 +++++++++++++++++++++++++++++++++----- src/views/home/home.js | 330 +++++++++++++++------ 8 files changed, 1302 insertions(+), 454 deletions(-) diff --git a/src/core/nav.js b/src/core/nav.js index 331a5bb..429bade 100644 --- a/src/core/nav.js +++ b/src/core/nav.js @@ -9,17 +9,30 @@ const getRect = (element) => { }; const scoreCandidate = (source, target, direction) => { - const horizontal = target.col - source.col; - const vertical = target.row - source.row; + const sourceRect = getRect(source.element); + const targetRect = getRect(target.element); + const sourceCenterX = sourceRect.x + sourceRect.width / 2; + const sourceCenterY = sourceRect.y + sourceRect.height / 2; + const targetCenterX = targetRect.x + targetRect.width / 2; + const targetCenterY = targetRect.y + targetRect.height / 2; + const horizontal = targetCenterX - sourceCenterX; + const vertical = targetCenterY - sourceCenterY; - if (direction === "up" && vertical >= 0) return Number.POSITIVE_INFINITY; - if (direction === "down" && vertical <= 0) return Number.POSITIVE_INFINITY; - if (direction === "left" && horizontal >= 0) return Number.POSITIVE_INFINITY; - if (direction === "right" && horizontal <= 0) return Number.POSITIVE_INFINITY; + if (direction === "up" && vertical >= -1) return Number.POSITIVE_INFINITY; + if (direction === "down" && vertical <= 1) return Number.POSITIVE_INFINITY; + if (direction === "left" && horizontal >= -1) return Number.POSITIVE_INFINITY; + if (direction === "right" && horizontal <= 1) return Number.POSITIVE_INFINITY; const primary = direction === "up" || direction === "down" ? Math.abs(vertical) : Math.abs(horizontal); const secondary = direction === "up" || direction === "down" ? Math.abs(horizontal) : Math.abs(vertical); - return primary * 100 + secondary; + const sourceStart = direction === "up" || direction === "down" ? sourceRect.x : sourceRect.y; + const sourceEnd = sourceStart + (direction === "up" || direction === "down" ? sourceRect.width : sourceRect.height); + const targetStart = direction === "up" || direction === "down" ? targetRect.x : targetRect.y; + const targetEnd = targetStart + (direction === "up" || direction === "down" ? targetRect.width : targetRect.height); + const overlap = Math.max(0, Math.min(sourceEnd, targetEnd) - Math.max(sourceStart, targetStart)); + const overlapPenalty = overlap > 0 ? -40 : 0; + + return primary * 10 + secondary + overlapPenalty; }; export const createNavigationManager = () => { @@ -75,7 +88,10 @@ export const createNavigationManager = () => { return; } - const nodes = Array.from(contract.focusRoot.querySelectorAll("[data-focusable='true']")); + const roots = [contract.focusRoot, ...(contract.extraFocusRoots ?? [])].filter(Boolean); + const nodes = Array.from( + new Set(roots.flatMap((root) => Array.from(root.querySelectorAll("[data-focusable='true']")))), + ); focusables = nodes .map((element, index) => { @@ -86,6 +102,7 @@ export const createNavigationManager = () => { row: Number(element.dataset.row ?? 0), col: Number(element.dataset.col ?? 0), key: element.dataset.focusKey ?? String(index), + region: element.dataset.navRegion ?? "content", }; }) .sort((left, right) => { @@ -111,7 +128,56 @@ export const createNavigationManager = () => { return 0; }; + const resolveDefaultContentFocus = () => { + if (!focusables.length) { + return -1; + } + + if (contract?.defaultFocus) { + const defaultIndex = focusables.findIndex((focusable) => focusable.element === contract.defaultFocus); + if (defaultIndex >= 0 && focusables[defaultIndex]?.region !== "sidebar") { + return defaultIndex; + } + } + + return focusables.findIndex((focusable) => focusable.region !== "sidebar"); + }; + + const findSidebarIndex = () => { + const activeIndex = focusables.findIndex( + (focusable) => focusable.region === "sidebar" && focusable.element.classList.contains("is-active"), + ); + if (activeIndex >= 0) { + return activeIndex; + } + + return focusables.findIndex((focusable) => focusable.region === "sidebar"); + }; + + const findNextSidebarIndex = (direction) => { + const source = focusables[focusedIndex]; + if (!source || source.region !== "sidebar") { + return null; + } + + const sidebarItems = focusables + .map((item, index) => ({ item, index })) + .filter(({ item }) => item.region === "sidebar") + .sort((left, right) => left.item.row - right.item.row); + const currentSidebarIndex = sidebarItems.findIndex(({ index }) => index === focusedIndex); + if (currentSidebarIndex < 0) { + return null; + } + + const nextSidebarIndex = direction === "up" ? currentSidebarIndex - 1 : currentSidebarIndex + 1; + return sidebarItems[nextSidebarIndex]?.index ?? null; + }; + const moveWithNebula = (direction) => { + if (contract?.useNebulaNavigation === false) { + return null; + } + const picker = contract?.nebulaNavigation?.pickBestCandidate; if (typeof picker !== "function") { return null; @@ -149,13 +215,38 @@ export const createNavigationManager = () => { return; } + const source = focusables[focusedIndex]; + + if (direction === "left" && source?.region !== "sidebar") { + const sidebarIndex = findSidebarIndex(); + if (sidebarIndex >= 0) { + applyFocus(sidebarIndex); + } + return; + } + + if (direction === "right" && source?.region === "sidebar") { + const contentIndex = resolveDefaultContentFocus(); + if (contentIndex >= 0) { + applyFocus(contentIndex); + } + return; + } + + if ((direction === "up" || direction === "down") && source?.region === "sidebar") { + const nextSidebarIndex = findNextSidebarIndex(direction); + if (nextSidebarIndex !== null) { + applyFocus(nextSidebarIndex); + } + return; + } + const nebulaIndex = moveWithNebula(direction); if (nebulaIndex !== null) { applyFocus(nebulaIndex); return; } - const source = focusables[focusedIndex]; let bestIndex = focusedIndex; let bestScore = Number.POSITIVE_INFINITY; diff --git a/src/index.html b/src/index.html index 774f1e9..c6a094f 100644 --- a/src/index.html +++ b/src/index.html @@ -24,13 +24,54 @@
-