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
19 changes: 19 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
44 changes: 36 additions & 8 deletions web/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
124 changes: 106 additions & 18 deletions web/src/ui/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Suspense,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
Expand Down Expand Up @@ -124,6 +125,29 @@ export function Workspace({
session: AccountSession;
onSignOut: () => Promise<void>;
}) {
const [creatingNote, setCreatingNote] = useState(false);
const createGeneration = useRef(0);
const createInFlight = useRef<number | null>(null);
const createFocusTimer = useRef<ReturnType<typeof setTimeout> | 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<EditorSourceTarget | null>(
null,
),
Expand Down Expand Up @@ -167,6 +191,7 @@ export function Workspace({
null,
);
const links = useNoteLinkOwner(sourceOwner, (reason) => {
retireCreate();
try {
onNoteLinkScopeRevoked?.();
} catch {
Expand Down Expand Up @@ -256,6 +281,7 @@ export function Workspace({
setSourceUnavailable(null);
}
const sourceOwners = useStudySourceOwners(sourceOwner, session, () => {
retireCreate();
revokeSearchScope();
sourceRequest.current++;
sourceWorking.current = false;
Expand Down Expand Up @@ -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);
Expand All @@ -319,6 +346,8 @@ export function Workspace({
}
const [notes, setNotes] = useState<Note[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const selectedForCreateFocus = useRef(selected);
selectedForCreateFocus.current = selected;
const [module, setModuleState] = useState<WorkspaceModule>("notes");
const [searchActive, setSearchActive] = useState(false),
[searchQuery, setSearchQuery] = useState(""),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1562,11 +1637,23 @@ export function Workspace({
className={drawer ? "sidebar open" : "sidebar"}
aria-label="Notes navigation"
>
<div className="identity">
<b>
Orbyn Notes <small>Beta</small>
</b>
<span title={session.email}>{session.email}</span>
<div className="sidebar-account" role="group" aria-label="Account">
<span className="sidebar-account-avatar" aria-hidden="true">
{accountInitial}
</span>
<span className="sidebar-account-email" title={session.email}>
<bdi>{session.email}</bdi>
</span>
<button
className="sidebar-account-create"
type="button"
aria-label="New note"
title="New note"
disabled={readOnly || creatingNote}
onClick={() => void create()}
>
<Plus size={14} aria-hidden="true" />
</button>
</div>
{sourceOwner ? (
<button
Expand Down Expand Up @@ -1784,7 +1871,7 @@ export function Workspace({
</nav>
<footer>
<p title="Notes stay encrypted in this browser. This beta does not sync.">
◉ Local only
◉ Beta · Local only
</p>
<button
className="nav"
Expand Down Expand Up @@ -2038,6 +2125,7 @@ export function Workspace({
onSearchReaderResult={searchReaderResult}
view={view}
onEpochChange={() => {
retireCreate();
selectedSnapshot.current = undefined;
setSelected(null);
}}
Expand Down Expand Up @@ -2304,7 +2392,7 @@ export function Workspace({
</button>
<button
className="primary"
disabled={readOnly}
disabled={readOnly || creatingNote}
onClick={() => void create()}
>
<Plus size={15} />
Expand Down Expand Up @@ -2404,7 +2492,7 @@ export function Workspace({
<div className="library-empty-actions">
<button
className="primary"
disabled={readOnly}
disabled={readOnly || creatingNote}
onClick={() => void create()}
>
New note
Expand Down
4 changes: 2 additions & 2 deletions web/tests/integration/beta.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async function signIn(page: Page, email: string, password: string) {
await page.getByLabel('Email', { exact: true }).fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page.getByRole('button', { name: 'New note', exact: true }).first()).toBeVisible();
await expect(page.locator('#main').getByRole('button', { name: 'New note', exact: true }).first()).toBeVisible();
}

test('invited account can write, reopen offline, export and lock its local library', async ({ page, context, profile }, testInfo) => {
Expand Down Expand Up @@ -87,7 +87,7 @@ test('invited account can write, reopen offline, export and lock its local libra
await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();
await page.getByLabel('Verification code', { exact: true }).fill(await verificationCode(email));
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await page.getByRole('button', { name: 'New note', exact: true }).first().click();
await page.locator('#main').getByRole('button', { name: 'New note', exact: true }).first().click();
await page.getByLabel('Note title', { exact: true }).fill('Beta persistence proof');
await page.getByRole('textbox', { name: 'Note content', exact: true }).fill('Encrypted local content survives offline reload.');
await page.getByLabel('Note tags, separated by commas').fill('beta-proof');
Expand Down
4 changes: 2 additions & 2 deletions web/tests/integration/desktop-layout-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ try {
await expect(rail).toHaveAttribute("aria-valuenow", String(width));
const title = await geometry(".desktop-titlebar"),
sheet = await geometry(".sheet"),
identity = await geometry(".identity"),
identity = await geometry(".sidebar-account"),
scroll = await geometry(".desktop-sheet-content");
expect(title.height).toBe(44);
expect(title.bg).toBe("rgba(0, 0, 0, 0)");
Expand All @@ -120,7 +120,7 @@ try {
expect((await geometry(".sheet")).x).toBe(420);
await rail.focus();
await page.keyboard.press("Enter");
await page
await page.locator('#main')
.getByRole("button", { name: "New note", exact: true })
.first()
.click();
Expand Down
2 changes: 1 addition & 1 deletion web/tests/integration/incoming-note-link-layout.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ try {
await page.setViewportSize({ width: 1160, height: 850 });
const base = `${process.env.NOTES_PREVIEW_URL ?? "http://127.0.0.1:5179"}/tests/integration/note-link-entry-browser.html`;
await page.goto(base);
await page.getByRole("button", { name: "New note", exact: true }).click();
await page.locator('#main').getByRole("button", { name: "New note", exact: true }).first().click();
await page.getByLabel("Note title").fill("Layout target");
await page
.getByLabel("Note content", { exact: true })
Expand Down
2 changes: 1 addition & 1 deletion web/tests/integration/library-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const page=await browser.newPage({viewport:{width:1160,height:800}});const error
try {
await page.goto('http://127.0.0.1:5179/tests/integration/library-browser.html');
await expect(page.getByRole('heading',{name:'All notes',exact:true})).toBeVisible();
await page.getByRole('button',{name:'New note',exact:true}).click();
await page.locator('#main').getByRole('button',{name:'New note',exact:true}).first().click();
await page.getByLabel('Note title',{exact:true}).fill('Encrypted library journey');
await page.getByLabel('Note content',{exact:true}).fill('First retained writing');
await expect(page.getByText('Saved in this browser',{exact:true})).toBeVisible();
Expand Down
4 changes: 2 additions & 2 deletions web/tests/integration/note-link-entry-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ try {
await setup.getByRole("button", { name: "Skip", exact: true }).click();
await expect(setup).not.toBeVisible();
for (const title of ["Entry target", "Entry other"]) {
await page.getByRole("button", { name: "New note", exact: true }).first().click();
await page.locator('#main').getByRole("button", { name: "New note", exact: true }).first().click();
await page.getByLabel("Note title").fill(title);
await page
.getByLabel("Note content", { exact: true })
Expand Down Expand Up @@ -68,7 +68,7 @@ try {
await page.getByRole("button", { name: "Dismiss", exact: true }).click();
// The existing unsaved-note boundary must also guard external hash entry.
await page.getByRole("button", { name: "Back to notes" }).click();
await page.getByRole("button", { name: "New note", exact: true }).click();
await page.locator('#main').getByRole("button", { name: "New note", exact: true }).first().click();
// New note itself is already committed. Make an actual unsaved title edit.
await page.getByLabel("Note title").fill("Keep this draft");
await page.evaluate((value) => {
Expand Down
Loading
Loading