From 1bae605bef10ae80ddd48b447f19597b9d14951a Mon Sep 17 00:00:00 2001 From: Luan Nguyen Date: Thu, 12 Nov 2026 01:17:53 +1100 Subject: [PATCH] feat(navigation): add the sidebar account header --- docs/architecture.md | 19 ++ web/src/styles/app.css | 44 +++- web/src/ui/Workspace.tsx | 124 ++++++++-- web/tests/integration/beta.spec.ts | 4 +- .../integration/desktop-layout-browser.mjs | 4 +- .../integration/incoming-note-link-layout.mjs | 2 +- web/tests/integration/library-browser.mjs | 2 +- .../integration/note-link-entry-browser.mjs | 4 +- web/tests/integration/note-links-browser.mjs | 6 +- .../notes-source-motion-browser.mjs | 2 +- .../integration/sidebar-account-browser.mjs | 197 +++++++++++++++ .../integration/sidebar-resize-browser.mjs | 2 +- .../integration/study-source-browser.mjs | 2 +- .../study-source-reopen-browser.mjs | 2 +- web/tests/ui/library.test.tsx | 1 + web/tests/ui/sidebar-account.test.tsx | 225 ++++++++++++++++++ 16 files changed, 599 insertions(+), 41 deletions(-) create mode 100644 web/tests/integration/sidebar-account-browser.mjs create mode 100644 web/tests/ui/sidebar-account.test.tsx diff --git a/docs/architecture.md b/docs/architecture.md index 83cb8e9..a393094 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -249,3 +249,22 @@ the window. Back/Forward history and the account-avatar sidebar header remain separate work; no decorative history controls are added. Reference: design revision 95bd47e899600c0f038a7d4622a40cfc61d063e2, Components `window-title-row` and Notes `layout-desktop`/`layout-narrow`. + + +## Sidebar account header + +The sidebar shows the actual account email beside a 24px initial avatar, followed +by the existing New note action. The initial uses the first Unicode grapheme of +the email; it is decorative and the full email remains accessible when visually +ellipsized. There is no profile photo, account menu or invented display name. +The single 29px row and 28px create button follow Notes `layout-desktop` from +design revision f610ced1da443b04699137333c1309925092fffa (Notes blob unchanged +from 95bd47e). Avatar C1 is #3b82f6 in Light and #60a5fa in Dark/Dim. Beta and +Local only remain explicit in the sidebar footer. + +Every New note entry point shares one synchronous in-flight gate and tracked +write. Failure preserves the current route. A successful current-owner result +is deduplicated into the library, then navigation guards are checked again before +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. diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 523d2d5..51287a1 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -175,21 +175,49 @@ h3 { padding: 20px 10px 10px; min-width: 0; } -.identity { - padding: 0 8px 22px; +.sidebar-account { display: flex; - flex-direction: column; - gap: 4px; + align-items: center; + gap: 10px; + height: 29px; + padding: 0 8px; + margin-bottom: 4px; + flex-shrink: 0; + color: var(--t2); +} +.sidebar-account-avatar { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + flex: none; + border-radius: 50%; + background: #3b82f6; + color: #0b0b0b; + font-size: 10.5px; + font-weight: 500; } -.identity b { - font-size: 15px; +:root:is([data-theme="dark"], [data-theme="dim"]) .sidebar-account-avatar { + background: #60a5fa; } -.identity span { +.sidebar-account-email { + flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sidebar-account-create { + display: inline-grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + border-radius: 8px; color: var(--t3); - font-size: 12px; + background: transparent; } small { font-size: 11px; diff --git a/web/src/ui/Workspace.tsx b/web/src/ui/Workspace.tsx index c815571..7a70db8 100644 --- a/web/src/ui/Workspace.tsx +++ b/web/src/ui/Workspace.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect, useLayoutEffect, + useMemo, useRef, useState, } from "react"; @@ -124,6 +125,29 @@ export function Workspace({ session: AccountSession; onSignOut: () => Promise; }) { + const [creatingNote, setCreatingNote] = useState(false); + const createGeneration = useRef(0); + const createInFlight = useRef(null); + const createFocusTimer = useRef | null>(null); + const accountInitial = useMemo(() => { + const first = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + .segment(session.email.trim()) + [Symbol.iterator]() + .next().value; + return first?.segment.toLocaleUpperCase() || "?"; + }, [session.email]); + function retireCreate() { + createGeneration.current++; + createInFlight.current = null; + if (createFocusTimer.current !== null) + clearTimeout(createFocusTimer.current); + createFocusTimer.current = null; + setCreatingNote(false); + } + useLayoutEffect( + () => () => retireCreate(), + [store, sourceOwner, session.accountId, session.deviceId], + ); const [sourceTarget, setSourceTarget] = useState( null, ), @@ -167,6 +191,7 @@ export function Workspace({ null, ); const links = useNoteLinkOwner(sourceOwner, (reason) => { + retireCreate(); try { onNoteLinkScopeRevoked?.(); } catch { @@ -256,6 +281,7 @@ export function Workspace({ setSourceUnavailable(null); } const sourceOwners = useStudySourceOwners(sourceOwner, session, () => { + retireCreate(); revokeSearchScope(); sourceRequest.current++; sourceWorking.current = false; @@ -300,6 +326,7 @@ export function Workspace({ if (result.mode === "ready") { const nextEpoch = result.snapshot.token.epoch; if (adoptedEpoch.current && adoptedEpoch.current !== nextEpoch) { + retireCreate(); selectedSnapshot.current = undefined; setSelected(null); setHistoryNote(null); @@ -319,6 +346,8 @@ export function Workspace({ } const [notes, setNotes] = useState([]); const [selected, setSelected] = useState(null); + const selectedForCreateFocus = useRef(selected); + selectedForCreateFocus.current = selected; const [module, setModuleState] = useState("notes"); const [searchActive, setSearchActive] = useState(false), [searchQuery, setSearchQuery] = useState(""), @@ -1276,19 +1305,65 @@ export function Workspace({ setDrawer(false); } async function create() { - if (readOnly || !canNavigate()) return; - clearSourceRoute(); + if ( + createInFlight.current !== null || + readOnlyRef.current || + !canNavigate() + ) + return; + const ticket = ++createGeneration.current; + createInFlight.current = ticket; + setCreatingNote(true); + const current = () => + createGeneration.current === ticket && createInFlight.current === ticket; try { - const note = await store.create({ - title: "", - document: { type: "doc", content: [{ type: "paragraph" }] }, - tags: [], - }); - setNotes((v) => [note, ...v]); + const note = await trackOperation(() => + store.create({ + title: "", + document: { type: "doc", content: [{ type: "paragraph" }] }, + tags: [], + }), + ); + if (!current()) return; + setNotes((values) => [ + note, + ...values.filter((value) => value.id !== note.id), + ]); + if (readOnlyRef.current || !canNavigate()) { + const value = readOnlyRef.current + ? "Note created. Editing is currently unavailable." + : checkClose() === "draft" + ? "Note created. Finish or discard your unsaved changes before opening it." + : "Note created. Wait for the current work to finish before opening it."; + navigationGuardNotice.current = value; + setNotice(value); + return; + } + clearSourceRoute(); + setModule("notes"); + setTrash(false); + setTag(""); + setPinnedOnly(false); + setLinkedOnly(false); + setQuery(""); + setDrawer(false); setSelected(note.id); - setTimeout(() => document.getElementById("note-title")?.focus(), 0); + createFocusTimer.current = setTimeout(() => { + createFocusTimer.current = null; + if ( + createGeneration.current === ticket && + !readOnlyRef.current && + selectedForCreateFocus.current === note.id + ) + document.getElementById("note-title")?.focus(); + }, 0); } catch (e) { - setError(`Couldn’t create note. ${message(e)}`); + if (current()) setError(`Couldn’t create note. ${message(e)}`); + } finally { + if (current()) { + createInFlight.current = null; + setCreatingNote(false); + } } } async function move(note: Note) { @@ -1562,11 +1637,23 @@ export function Workspace({ className={drawer ? "sidebar open" : "sidebar"} aria-label="Notes navigation" > -
- - Orbyn Notes Beta - - {session.email} +
+ + + {session.email} + +
{sourceOwner ? (