From ac7f11976040f3975215d77a254d48c04a3b151b Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Thu, 12 Nov 2026 01:50:09 +1100 Subject: [PATCH] feat(notes): match sidebar selection geometry and motion --- docs/architecture.md | 22 ++ web/src/styles/app.css | 47 +++ web/src/ui/SidebarSelection.tsx | 288 ++++++++++++++++++ web/src/ui/Workspace.tsx | 112 ++++++- .../integration/sidebar-selection-browser.mjs | 176 +++++++++++ web/tests/ui/sidebar-selection.test.tsx | 254 +++++++++++++++ 6 files changed, 893 insertions(+), 6 deletions(-) create mode 100644 web/src/ui/SidebarSelection.tsx create mode 100644 web/tests/integration/sidebar-selection-browser.mjs create mode 100644 web/tests/ui/sidebar-selection.test.tsx diff --git a/docs/architecture.md b/docs/architecture.md index a393094..c31caf5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -268,3 +268,25 @@ is deduplicated into the library, then navigation guards are checked again befor opening it. If new work blocks navigation, the created note remains in the library with a truthful notice; it is not automatically opened later. Account, store, unmount and observed epoch invalidations retire result/focus tickets. + + +## Sidebar selection presentation + +Notes sidebar rows use 29px boxes with 1px gaps. A single pointer-inert, +aria-hidden highlight follows the actual admitted route row, using checked +untransformed layout offsets rather than a row index or the pressed button's +scaled rectangle. Same-layout selection moves 200ms; label color changes 150ms. +Reduced motion jumps the highlight while retaining the label-color fade. +Geometry/order/width/scroll/show changes settle immediately. + +Workspace registers concrete buttons with content-free route keys or opaque +catalog IDs/current-only symbols. The controller retains DOM handles and scalar +geometry only. Owner invalidation synchronously hides and latches the controller, +clears handles/previous geometry and retires callbacks before projection clearing. +Only a successful current-owner library/list read admits a fresh ref generation; +a generic rerender cannot revive the old target. Ordinary same-scope reads do not +reset movement. ResizeObserver initial delivery does not cancel an unchanged move. +No route admission depends on motion completion and no heading/label snapshot is +cloned. Current-heading opacity remains a separately owned qualification gap. +Design revision f610ced1da443b04699137333c1309925092fffa: Notes `layout-desktop` +and Motion `sidenav` (Notes/Motion blobs unchanged from 95bd47e). diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 51287a1..dad07ff 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1073,3 +1073,50 @@ dialog p { text-overflow: ellipsis; white-space: nowrap; } + +/* Notes706a8d07 geometry; Motionaed675f3 #sidenav. */ +.sidebar { + position: relative; + isolation: isolate; + gap: 1px; +} +.sidebar > nav { + display: flex; + flex-direction: column; + gap: 1px; +} +.sidebar .nav { + position: relative; + z-index: 1; + height: 29px; + min-height: 29px; + flex-shrink: 0; + margin-bottom: 0; + transition: background-color 150ms cubic-bezier(.4,0,.2,1), box-shadow 150ms cubic-bezier(.4,0,.2,1), transform 150ms cubic-bezier(.4,0,.2,1), color 150ms cubic-bezier(.4,0,.2,1); +} +.sidebar .search { margin-bottom: 4px; } +.sidebar .tag-swatch { width: 8px; height: 8px; border-radius: 3px; margin-right: 0; } +.sidebar footer { display: flex; flex-direction: column; gap: 6px; padding-top: 0; flex-shrink: 0; } +.sidebar footer p { margin: 0; } +.sidebar-selection-highlight { + position: absolute; + top: 0; + left: 0; + z-index: 0; + border-radius: 8px; + background: var(--sel); + pointer-events: none; + transition: none; +} +.sidebar-selection-highlight[data-animate="true"] { transition: transform 200ms cubic-bezier(.2,.8,.2,1); } +.sidebar[data-sidebar-selection-ready="true"] .nav.active { background: transparent; } +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .sidebar-selection-highlight { transition: none !important; } + :root:not([data-motion="on"]) .sidebar .nav { + transition: color 150ms cubic-bezier(.4,0,.2,1), background-color 150ms cubic-bezier(.4,0,.2,1), box-shadow 150ms cubic-bezier(.4,0,.2,1) !important; + } +} +:root[data-motion="reduced"] .sidebar-selection-highlight { transition: none !important; } +:root[data-motion="reduced"] .sidebar .nav { + transition: color 150ms cubic-bezier(.4,0,.2,1), background-color 150ms cubic-bezier(.4,0,.2,1), box-shadow 150ms cubic-bezier(.4,0,.2,1) !important; +} diff --git a/web/src/ui/SidebarSelection.tsx b/web/src/ui/SidebarSelection.tsx new file mode 100644 index 0000000..da87aa5 --- /dev/null +++ b/web/src/ui/SidebarSelection.tsx @@ -0,0 +1,288 @@ +import { useLayoutEffect, useRef, type RefCallback } from "react"; + +export type SidebarSelectionKey = string | symbol; +export type SidebarSelectionController = { + readonly rootRef: RefCallback; + rowRef: (key: SidebarSelectionKey) => RefCallback; + invalidate: () => void; + admit: () => void; +}; +type Input = { + controller: SidebarSelectionController; + activeKey: SidebarSelectionKey | null; + layoutVersion: number | string; + visible: boolean; + privacyRevision: number; +}; +type Box = { x: number; y: number; width: number; height: number }; +type Position = Box & { + key: SidebarSelectionKey; + node: HTMLElement; + layout: Input["layoutVersion"]; + privacy: number; + registration: number; + rootWidth: number; + rootHeight: number; +}; +const sameBox = (a: Box, b: Box) => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +const owners = new WeakMap(); + +class SelectionOwner { + generation = 0; + registration = 0; + latched = false; + root: HTMLElement | null = null; + layer: HTMLElement | null = null; + rows = new Map>(); + callbacks = new Map>(); + previous: Position | null = null; + input: Input | null = null; + observer: ResizeObserver | null = null; + observedRow: HTMLElement | null = null; + observedRoot: HTMLElement | null = null; + rootRef: RefCallback = () => {}; + layerRef: RefCallback = () => {}; + controller: SidebarSelectionController; + + constructor() { + const owner = this; + this.controller = { + get rootRef() { + return owner.rootRef; + }, + rowRef: (key) => owner.rowRef(key), + invalidate: () => owner.invalidate(), + admit: () => { + if (owner.latched) { + owner.latched = false; + owner.rotate(); + } + }, + }; + owners.set(this.controller, this); + this.rotate(); + } + rotate() { + const generation = ++this.generation; + this.callbacks.clear(); + this.rootRef = (node) => { + if (generation !== this.generation || this.latched) return; + this.root = node; + this.registration++; + if (!node) this.pause(); + else this.measure(); + }; + this.layerRef = (node) => { + if (generation !== this.generation || this.latched) return; + this.layer = node; + if (node) node.hidden = true; + }; + } + rowRef(key: SidebarSelectionKey): RefCallback { + const existing = this.callbacks.get(key); + if (existing) return existing; + const generation = this.generation; + const callback: RefCallback = (node) => { + if (generation !== this.generation || this.latched) return; + if (!node) { + this.rows.delete(key); + this.registration++; + return; + } + let nodes = this.rows.get(key); + if (!nodes) this.rows.set(key, (nodes = new Set())); + const registered = nodes; + registered.add(node); + this.registration++; + return () => { + if (generation !== this.generation) return; + registered.delete(node); + if (!registered.size) this.rows.delete(key); + this.registration++; + if (this.previous?.node === node) this.hide(); + }; + }; + this.callbacks.set(key, callback); + return callback; + } + hide() { + this.root?.removeAttribute("data-sidebar-selection-ready"); + if (this.layer) { + this.layer.hidden = true; + this.layer.dataset.animate = "false"; + this.layer.style.transition = "none"; + this.layer.style.transform = "none"; + } + this.previous = null; + } + pause() { + this.hide(); + this.observer?.disconnect(); + this.observer = null; + this.observedRoot = this.observedRow = null; + } + invalidate() { + this.pause(); + this.latched = true; + this.root = this.layer = null; + for (const nodes of this.rows.values()) nodes.clear(); + this.rows.clear(); + this.input = null; + this.rotate(); + } + box(node: HTMLElement): Box | null { + const root = this.root; + if ( + !root || + !root.isConnected || + !node.isConnected || + !root.contains(node) || + node.offsetParent !== root + ) + return null; + const box = { + x: node.offsetLeft, + y: node.offsetTop, + width: node.offsetWidth, + height: node.offsetHeight, + }; + if ( + box.width <= 0 || + box.height <= 0 || + !Object.values(box).every(Number.isFinite) + ) + return null; + return box; + } + measure(layoutChange = false) { + const input = this.input, + root = this.root, + layer = this.layer; + if ( + this.latched || + !input?.visible || + !root || + !layer || + input.activeKey === null + ) { + this.hide(); + return; + } + const nodes = this.rows.get(input.activeKey); + const node = nodes?.size === 1 ? nodes.values().next().value : null; + const box = node ? this.box(node) : null; + if (!node || !box) { + this.hide(); + return; + } + const old = this.previous; + const oldBox = old && this.box(old.node); + const stable = + old && + oldBox && + sameBox(old, oldBox) && + old.layout === input.layoutVersion && + old.privacy === input.privacyRevision && + old.registration === this.registration && + old.rootWidth === root.clientWidth && + old.rootHeight === root.clientHeight; + const changed = old && old.key !== input.activeKey; + const animate = !!(stable && changed && !layoutChange); + // A resize/scroll/DOM reflow settles even an already running transition. + if (layoutChange || !stable) { + layer.style.transition = "none"; + layer.style.transform = `translate(${box.x}px, ${box.y}px)`; + void layer.offsetWidth; + } + if (changed || layoutChange || !stable) { + layer.dataset.animate = String(animate); + layer.style.transition = ""; + layer.style.width = `${box.width}px`; + layer.style.height = `${box.height}px`; + layer.style.transform = `translate(${box.x}px, ${box.y}px)`; + } + layer.hidden = false; + root.dataset.sidebarSelectionReady = "true"; + this.previous = { + ...box, + key: input.activeKey, + node, + layout: input.layoutVersion, + privacy: input.privacyRevision, + registration: this.registration, + rootWidth: root.clientWidth, + rootHeight: root.clientHeight, + }; + this.observe(node); + } + observe(node: HTMLElement) { + const root = this.root; + if (!root || (this.observedRoot === root && this.observedRow === node)) + return; + this.observer?.disconnect(); + this.observedRoot = root; + this.observedRow = node; + if (typeof ResizeObserver === "undefined") return; + const generation = this.generation; + this.observer = new ResizeObserver(() => { + if (generation !== this.generation || this.latched) return; + const previous = this.previous; + const box = previous && this.box(previous.node); + if ( + !previous || + !box || + !sameBox(previous, box) || + previous.rootWidth !== root.clientWidth || + previous.rootHeight !== root.clientHeight + ) + this.measure(true); + }); + this.observer.observe(root); + this.observer.observe(node); + } +} + +export function useSidebarSelection(): SidebarSelectionController { + const owner = useRef(null); + if (!owner.current) owner.current = new SelectionOwner(); + useLayoutEffect(() => () => owner.current?.invalidate(), []); + return owner.current.controller; +} + +export function SidebarSelection(props: Input) { + const owner = owners.get(props.controller); + useLayoutEffect(() => { + if (!owner) return; + owner.input = props; + owner.measure(); + }); + useLayoutEffect(() => { + if (!owner) return; + const onScroll = (event: Event) => { + const root = owner.root, + target = event.target; + if ( + root && + (target === document || + target === window || + (target instanceof HTMLElement && + (target === root || target.contains(root)))) + ) + owner.measure(true); + }; + window.addEventListener("scroll", onScroll, true); + return () => { + window.removeEventListener("scroll", onScroll, true); + owner.pause(); + }; + }, [owner]); + return ( +