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
25 changes: 25 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,28 @@ 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).


## Current route headings

Notes, Study, Journal, Clips and Tags share a content-free heading cue issued
only after a changed visible route is admitted by existing navigation guards.
The cue crosses CollectionsHost as presentation metadata; actual owners register
the current h1. A single CSS opacity animation lasts 150ms with no travel, both
in normal and reduced motion. It does not clone text, remount actors or delay
navigation. Initial load, same-row actions and ordinary content/count updates
stay static. Real cover/leave retires a cue; hidden actors cannot replay it later.

Current owner reads admit each route's presentation latch. Owner/epoch/account
invalidation synchronously cancels and clears registered effects before private
projection clearing. Admission rotates DOM registration separately from cue scope,
so a legitimate cold-route request after invalidation survives its fresh read.
CSS completion stays at opacity 1 without event/timer-driven route or focus work.

Journal's actual public locked/setup title may consume the entry cue, with
credential fields untouched. Unlocking does not replay it on the protected title.
Study consumes its existing 150ms content fade rather than adding a nested fade.
Search 240/170ms surface motion, review/Done, errors, Settings sections and the
absent native caption title remain separate contracts. The source is design
revision f610ced1da443b04699137333c1309925092fffa, Motion `sidenav`, Notes
`layout-desktop` and `lock`.
20 changes: 20 additions & 0 deletions web/src/styles/route-heading.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/* Motionaed675f3 #sidenav: current authorized title only. */
[data-route-heading-motion="enter"] {
animation: route-heading-in 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes route-heading-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
:root:not([data-motion="on"]) [data-route-heading-motion="enter"] {
animation: route-heading-in 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important;
}
}
:root[data-motion="reduced"] [data-route-heading-motion="enter"] {
animation: route-heading-in 150ms cubic-bezier(0.2, 0.8, 0.2, 1) both !important;
}
22 changes: 20 additions & 2 deletions web/src/ui/ClipsView.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useRouteHeading, type HeadingPresentation } from "./routeHeadingMotion";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { zipSync, strToU8 } from "fflate";
Expand Down Expand Up @@ -60,13 +61,15 @@ const isConflict = (error: unknown) => {
};
export function ClipsView({
store,
heading,
onOpenExternal,
openClip,
onSearchReaderResult,
accountId,
holdSearchOrigin = false,
restoreSearchOrigin = 0,
}: {
heading?: HeadingPresentation;
store: ClipsStore;
onOpenExternal?: (url: string) => Promise<void>;
openClip?: ClipSearchOpenRequest | null;
Expand All @@ -75,6 +78,9 @@ export function ClipsView({
holdSearchOrigin?: boolean;
restoreSearchOrigin?: number;
}) {
const headingLive = useRef(heading);
headingLive.current = heading;
const headingOwner = useRef(store);
useEditorPreferences();
const [snapshot, setSnapshot] = useState<ClipSnapshot | null>(null),
[error, setError] = useState(""),
Expand Down Expand Up @@ -225,12 +231,20 @@ export function ClipsView({
const value = await store.read();
if (!alive.current || invocation !== generation.current)
throw new Error("Clips is no longer available.");
if (sequence === readSequence.current) adopt(value);
if (sequence === readSequence.current) {
adopt(value);
headingLive.current?.controller.admit("clips");
}
return value;
}
useLayoutEffect(() => {
if (headingOwner.current !== store) {
headingLive.current?.controller.invalidate("clips");
headingOwner.current = store;
}
alive.current = true;
const stopInvalidation = store.onInvalidate((reason) => {
headingLive.current?.controller.invalidate("clips");
heldSearchOrigin.current = null;
generation.current++;
readSequence.current++;
Expand Down Expand Up @@ -721,6 +735,10 @@ export function ClipsView({
)}
</>
);
const headingRef = useRouteHeading(
heading && { ...heading, active: heading.active && !form && !exportOpen && !discard },
"clips", closed || error && !snapshot ? "absent" : !snapshot ? "pending" : "target",
);
if (closed)
return (
<section className="empty" aria-label="Clips">
Expand Down Expand Up @@ -756,7 +774,7 @@ export function ClipsView({
<section className="clips-view" aria-label="Clips">
<header className="page-heading">
<div>
<h1>Clips</h1>
<h1 ref={headingRef}>Clips</h1>
<span>{unread} unread</span>
</div>
<div className="actions">
Expand Down
14 changes: 12 additions & 2 deletions web/src/ui/CollectionsHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
import type { AccountSession } from "../domain/contracts";
import type { CollectionsStore } from "../collections/contracts";
import { trackOperation } from "../desktop/closeGuard";
import type { HeadingPresentation } from "./routeHeadingMotion";
import { StudyView } from "./StudyView";
import { JournalView } from "./JournalView";
import { ClipsView } from "./ClipsView";
Expand All @@ -22,6 +23,7 @@ import type {
/** Opens real account-scoped collections only when their route is selected. */
export function CollectionsHost({
session,
heading,
onOpenNote,
onEpochChange,
view = "study",
Expand All @@ -37,6 +39,7 @@ export function CollectionsHost({
covered = false,
onReopen,
}: {
heading?: HeadingPresentation;
onReopen?: () => void;
covered?: boolean;
openClip?: ClipSearchOpenRequest | null;
Expand All @@ -53,6 +56,8 @@ export function CollectionsHost({
onEpochChange?: () => void;
view?: "study" | "journal" | "clips";
}) {
const headingLive = useRef(heading);
headingLive.current = heading;
const [clips, setClips] = useState<ClipsStore | null>(null);
const [clipsError, setClipsError] = useState(false);
const [clipsAttempt, setClipsAttempt] = useState(0);
Expand Down Expand Up @@ -130,8 +135,10 @@ export function CollectionsHost({
})
.then((value) => {
opened = value;
if (active) setVault(value);
else value.close();
if (active) {
if (value.status().phase === "locked") headingLive.current?.controller.admit("journal");
setVault(value);
} else value.close();
})
.catch(() => {
if (active) setVaultError(true);
Expand Down Expand Up @@ -214,6 +221,7 @@ export function CollectionsHost({
);
return (
<ClipsView
heading={heading}
store={clips}
accountId={session.accountId}
openClip={openClip}
Expand Down Expand Up @@ -247,13 +255,15 @@ export function CollectionsHost({
);
return (
<JournalView
heading={heading}
vault={vault}
onReopen={() => setVaultAttempt((value) => value + 1)}
/>
);
}
return (
<StudyView
heading={heading}
store={store}
covered={covered}
onReopen={
Expand Down
52 changes: 47 additions & 5 deletions web/src/ui/JournalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
useState,
useSyncExternalStore,
} from "react";
import type { MutableRefObject } from "react";
import { useRouteHeading, type HeadingPresentation } from "./routeHeadingMotion";
import type { MutableRefObject, RefCallback } from "react";
import { flushSync } from "react-dom";
import { strToU8, zipSync } from "fflate";
import type {
Expand Down Expand Up @@ -91,7 +92,9 @@ function readHabitDraft(vault: JournalVault) {
}
}

function useVaultStatus(vault: JournalVault) {
function useVaultStatus(vault: JournalVault, onHide: () => void) {
const hide = useRef(onHide);
hide.current = onHide;
const active = useRef<JournalVault | null>(null);
useLayoutEffect(() => {
active.current = vault;
Expand All @@ -115,6 +118,7 @@ function useVaultStatus(vault: JournalVault) {
(cached.phase === "unlocked" && next.phase !== "unlocked") ||
(cached.phase !== next.phase &&
(next.phase === "closed" || next.phase === "revoked"));
if (hiding) hide.current();
cached = next;
if (hiding) flushSync(notify);
else notify();
Expand All @@ -133,12 +137,33 @@ function useVaultStatus(vault: JournalVault) {
/** Metadata is the only state retained while Journal is locked. */
export function JournalView({
vault,
heading,
onReopen,
}: {
vault: JournalVault;
heading?: HeadingPresentation;
onReopen?: () => void;
}) {
const status = useVaultStatus(vault);
const headingLive = useRef(heading);
headingLive.current = heading;
const headingOwner = useRef(vault);
const status = useVaultStatus(vault, () => headingLive.current?.controller.invalidate("journal"));
useLayoutEffect(() => {
if (headingOwner.current !== vault) {
headingLive.current?.controller.invalidate("journal");
headingOwner.current = vault;
}
try {
const metadata = vault.status();
if (metadata.phase === "locked") headingLive.current?.controller.admit("journal");
else if (metadata.phase === "closed" || metadata.phase === "revoked")
headingLive.current?.controller.invalidate("journal");
} catch {
// The existing Journal view owns read/recovery feedback. Failed title
// admission only disables its cosmetic effect; it never retries a read.
headingLive.current?.controller.invalidate("journal");
}
}, [vault, heading?.controller]);
const formDirty = useRef(false);
const [authPending, setAuthPending] = useState(false);
const [navigation, setNavigation] = useState(initialNavigation);
Expand Down Expand Up @@ -174,6 +199,8 @@ export function JournalView({
if (status.phase === "revoked" || status.phase === "closed")
setNavigation(initialNavigation());
}, [status.phase]);
const headingRef = useRouteHeading(heading, "journal",
status.phase === "locked" || status.phase === "unlocked" && !authPending ? "target" : "absent");
return (
<section className="journal-view" aria-label="Journal">
{closeNotice && (
Expand All @@ -185,6 +212,8 @@ export function JournalView({
<JournalWorkspace
key={owner}
vault={vault}
headingRef={headingRef}
heading={heading}
status={status}
formDirty={formDirty}
navigation={navigation}
Expand All @@ -203,6 +232,7 @@ export function JournalView({
<Credentials
key={owner}
vault={vault}
headingRef={headingRef}
status={status}
formDirty={formDirty}
pending={authPending}
Expand All @@ -215,12 +245,14 @@ export function JournalView({

function Credentials({
vault,
headingRef,
status,
formDirty,
pending,
onPending,
}: {
vault: JournalVault;
headingRef?: RefCallback<HTMLHeadingElement>;
status: JournalVaultStatus;
formDirty: MutableRefObject<boolean>;
pending: boolean;
Expand Down Expand Up @@ -297,7 +329,7 @@ function Credentials({
<path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
</svg>
</span>
<h1>{creating ? "Set a Journal passphrase" : "Journal is locked"}</h1>
<h1 ref={headingRef}>{creating ? "Set a Journal passphrase" : "Journal is locked"}</h1>
<p>
{creating
? "If you forget it, Orbyn can’t unlock your Journal."
Expand Down Expand Up @@ -378,12 +410,16 @@ function Credentials({

function JournalWorkspace({
vault,
headingRef,
heading,
status,
formDirty,
navigation,
onNavigate,
}: {
vault: JournalVault;
headingRef?: RefCallback<HTMLHeadingElement>;
heading?: HeadingPresentation;
status: JournalVaultStatus;
formDirty: MutableRefObject<boolean>;
navigation: JournalNavigation;
Expand All @@ -399,6 +435,9 @@ function JournalWorkspace({
);
const [cancelHabitOpen, setCancelHabitOpen] = useState(false);
const [discardOpen, setDiscardOpen] = useState(false);
useLayoutEffect(() => {
if (heading?.active && (form || cancelHabitOpen || discardOpen)) heading.controller.retire();
}, [heading?.controller, heading?.active, form, cancelHabitOpen, discardOpen]);
const [busy, setBusy] = useState(false);
const lockRequested = useRef(false);
const [savingShown, setSavingShown] = useState(false);
Expand Down Expand Up @@ -426,6 +465,9 @@ function JournalWorkspace({
return null;
}
}, [vault, status]);
useLayoutEffect(() => {
if (data && vault.status().phase === "unlocked") heading?.controller.admit("journal");
}, [data, vault, heading?.controller]);
const habitDraft = useMemo(() => readHabitDraft(vault), [vault, status]);
const linkDraft = useMemo(() => {
try {
Expand Down Expand Up @@ -700,7 +742,7 @@ function JournalWorkspace({
<>
<header className="journal-heading">
<div>
<h1>Journal</h1>
<h1 ref={headingRef}>Journal</h1>
<span>
{history
? monthTitle(month.year, month.month)
Expand Down
Loading
Loading