Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
47 changes: 47 additions & 0 deletions web/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
288 changes: 288 additions & 0 deletions web/src/ui/SidebarSelection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import { useLayoutEffect, useRef, type RefCallback } from "react";

export type SidebarSelectionKey = string | symbol;
export type SidebarSelectionController = {
readonly rootRef: RefCallback<HTMLElement>;
rowRef: (key: SidebarSelectionKey) => RefCallback<HTMLButtonElement>;
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<SidebarSelectionController, SelectionOwner>();

class SelectionOwner {
generation = 0;
registration = 0;
latched = false;
root: HTMLElement | null = null;
layer: HTMLElement | null = null;
rows = new Map<SidebarSelectionKey, Set<HTMLButtonElement>>();
callbacks = new Map<SidebarSelectionKey, RefCallback<HTMLButtonElement>>();
previous: Position | null = null;
input: Input | null = null;
observer: ResizeObserver | null = null;
observedRow: HTMLElement | null = null;
observedRoot: HTMLElement | null = null;
rootRef: RefCallback<HTMLElement> = () => {};
layerRef: RefCallback<HTMLElement> = () => {};
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<HTMLButtonElement> {
const existing = this.callbacks.get(key);
if (existing) return existing;
const generation = this.generation;
const callback: RefCallback<HTMLButtonElement> = (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<SelectionOwner | null>(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 (
<i
ref={owner?.layerRef}
className="sidebar-selection-highlight"
aria-hidden="true"
hidden
/>
);
}
Loading
Loading