diff --git a/docs/architecture.md b/docs/architecture.md index c31caf5..45d84fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`. diff --git a/web/src/styles/route-heading.css b/web/src/styles/route-heading.css new file mode 100644 index 0000000..2a8a69b --- /dev/null +++ b/web/src/styles/route-heading.css @@ -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; +} diff --git a/web/src/ui/ClipsView.tsx b/web/src/ui/ClipsView.tsx index 7c57cb2..48e6f2f 100644 --- a/web/src/ui/ClipsView.tsx +++ b/web/src/ui/ClipsView.tsx @@ -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"; @@ -60,6 +61,7 @@ const isConflict = (error: unknown) => { }; export function ClipsView({ store, + heading, onOpenExternal, openClip, onSearchReaderResult, @@ -67,6 +69,7 @@ export function ClipsView({ holdSearchOrigin = false, restoreSearchOrigin = 0, }: { + heading?: HeadingPresentation; store: ClipsStore; onOpenExternal?: (url: string) => Promise; openClip?: ClipSearchOpenRequest | null; @@ -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(null), [error, setError] = useState(""), @@ -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++; @@ -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 (
@@ -756,7 +774,7 @@ export function ClipsView({
-

Clips

+

Clips

{unread} unread
diff --git a/web/src/ui/CollectionsHost.tsx b/web/src/ui/CollectionsHost.tsx index 398ca0b..432d4d7 100644 --- a/web/src/ui/CollectionsHost.tsx +++ b/web/src/ui/CollectionsHost.tsx @@ -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"; @@ -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", @@ -37,6 +39,7 @@ export function CollectionsHost({ covered = false, onReopen, }: { + heading?: HeadingPresentation; onReopen?: () => void; covered?: boolean; openClip?: ClipSearchOpenRequest | null; @@ -53,6 +56,8 @@ export function CollectionsHost({ onEpochChange?: () => void; view?: "study" | "journal" | "clips"; }) { + const headingLive = useRef(heading); + headingLive.current = heading; const [clips, setClips] = useState(null); const [clipsError, setClipsError] = useState(false); const [clipsAttempt, setClipsAttempt] = useState(0); @@ -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); @@ -214,6 +221,7 @@ export function CollectionsHost({ ); return ( setVaultAttempt((value) => value + 1)} /> @@ -254,6 +263,7 @@ export function CollectionsHost({ } return ( void) { + const hide = useRef(onHide); + hide.current = onHide; const active = useRef(null); useLayoutEffect(() => { active.current = vault; @@ -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(); @@ -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); @@ -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 (
{closeNotice && ( @@ -185,6 +212,8 @@ export function JournalView({ ; status: JournalVaultStatus; formDirty: MutableRefObject; pending: boolean; @@ -297,7 +329,7 @@ function Credentials({ -

{creating ? "Set a Journal passphrase" : "Journal is locked"}

+

{creating ? "Set a Journal passphrase" : "Journal is locked"}

{creating ? "If you forget it, Orbyn can’t unlock your Journal." @@ -378,12 +410,16 @@ function Credentials({ function JournalWorkspace({ vault, + headingRef, + heading, status, formDirty, navigation, onNavigate, }: { vault: JournalVault; + headingRef?: RefCallback; + heading?: HeadingPresentation; status: JournalVaultStatus; formDirty: MutableRefObject; navigation: JournalNavigation; @@ -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); @@ -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 { @@ -700,7 +742,7 @@ function JournalWorkspace({ <>

-

Journal

+

Journal

{history ? monthTitle(month.year, month.month) diff --git a/web/src/ui/StudyView.tsx b/web/src/ui/StudyView.tsx index e7c4e53..34ce65a 100644 --- a/web/src/ui/StudyView.tsx +++ b/web/src/ui/StudyView.tsx @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useRouteHeading, type HeadingPresentation } from "./routeHeadingMotion"; import { flushSync } from "react-dom"; import { onLock, lockVersion } from "../account/session"; import { @@ -105,6 +106,7 @@ function intervalText(due: string, at: Date) { export function StudyView({ store, + heading, onOpenNote, onOpenSource, openEntry, @@ -115,6 +117,7 @@ export function StudyView({ covered = false, onReopen, }: { + heading?: HeadingPresentation; onReopen?: () => void; covered?: boolean; store: CollectionsStore; @@ -126,6 +129,9 @@ export function StudyView({ onReturnToSearch?: () => void; accountId?: string; }) { + const headingLive = useRef(heading); + headingLive.current = heading; + const headingOwner = useRef({ store, accountId }); const [cardPreview, setCardPreview] = useState<{ request: CardSearchOpenRequest; input: CardContentInput; @@ -232,7 +238,8 @@ export function StudyView({ entry?.revision === item.entryRevision ); } - function clearPrivate(closed: boolean, synchronous = true) { + function clearPrivate(closed: boolean, synchronous = true, retireHeading = true) { + if (retireHeading) headingLive.current?.controller.invalidate("study"); ownerGeneration.current++; sequence.current++; cardAdmission.current++; @@ -318,6 +325,7 @@ export function StudyView({ function accept(value: CollectionsState) { if (revoked.current) return; if (current.current && current.current.epoch !== value.epoch) { + headingLive.current?.controller.invalidate("study"); motion.invalidate(); setCardPreview(null); cardAdmission.current++; @@ -398,6 +406,7 @@ export function StudyView({ const value = await store.read(); if (!ownerCurrent(ownerTicket) || request !== sequence.current) return; accept(value); + if (current.current === value) headingLive.current?.controller.admit("study"); setReadError(""); if (resetConflict) { if (pendingReview.current?.epoch === value.epoch) @@ -413,7 +422,11 @@ export function StudyView({ useLayoutEffect(() => { mounted.current = true; ownerLockVersion.current = lockVersion(); - clearPrivate(false, false); + if (headingOwner.current.store !== store || headingOwner.current.accountId !== accountId) { + headingLive.current?.controller.invalidate("study"); + headingOwner.current = { store, accountId }; + } + clearPrivate(false, false, false); let stopClose: (() => void) | undefined, stopEpoch: (() => void) | undefined, stopLock: (() => void) | undefined, @@ -892,6 +905,12 @@ export function StudyView({ snapshot ? reviewQueue(snapshot.study, now, deckId ?? undefined) : [], [snapshot?.study, now, deckId], ); + const headingRef = useRouteHeading( + heading && { ...heading, active: heading.active && !covered && !form }, + "study", + unavailable || readError ? "absent" : !snapshot ? "pending" : session || cardPreview ? "absent" : "target", + coldLoading.entering, + ); if (unavailable) return (
@@ -1281,7 +1300,7 @@ export function StudyView({ <>
-

+

{page === "progress" ? "Progress" : (deck?.title ?? "Study")}

diff --git a/web/src/ui/TagsView.tsx b/web/src/ui/TagsView.tsx index 63318b1..a4106dc 100644 --- a/web/src/ui/TagsView.tsx +++ b/web/src/ui/TagsView.tsx @@ -7,6 +7,7 @@ import type { TagColor, } from "../library/contracts"; import { registerCloseGuard } from "../desktop/closeGuard"; +import { useRouteHeading, type HeadingPresentation } from "./routeHeadingMotion"; import { Dialog } from "./primitives"; import { Menu } from "./Menu"; import { LibraryActionFeedback, useLibraryAction } from "./libraryAction"; @@ -27,12 +28,14 @@ type Form = { }; export function TagsView({ library, + heading, snapshot, onResult, reload, onOpenTag, readOnly = false, }: { + heading?: HeadingPresentation; readOnly?: boolean; library: NotesLibrary; snapshot: LibrarySnapshot; @@ -42,6 +45,9 @@ export function TagsView({ }) { const [form, setForm] = useState
(null); const [discard, setDiscard] = useState(false); + const headingRef = useRouteHeading( + heading && { ...heading, active: heading.active && !form && !discard }, "tags", "target", + ); const dirty = useRef(false); const action = useLibraryAction(); const readOnlyRef = useRef(readOnly); @@ -114,7 +120,7 @@ export function TagsView({
-

Tags

+

Tags

{active.length} tags