diff --git a/TODO.md b/TODO.md
index 34fa0d18..49b00945 100644
--- a/TODO.md
+++ b/TODO.md
@@ -5,6 +5,29 @@ per line; details live in the roadmap. Cross off as things merge to develop.
## Open
+Navigation model (2026-07-30, branch `feat/navigation-model`, PR #507). The
+second design-pass brief's result, ported. One `AppBar.svelte` over
+`src/lib/styles/chrome.css` replaces the editor's `.topbar`, the pages'
+`.page-shell .topbar` with its `.back-link`, the print page's one-off bar and
+`.review-guest-bar`, in three shells (author, guest, reader). The two
+contested decisions landed as the owner ratified them: a place in the path is
+a menu, so the universe and story names open everything belonging to them
+starting with "Go to the ...", with no gear anywhere in the chrome; and the
+help modal is deleted, the `?` now navigating to the help pages, lighting up
+while there and returning when pressed again. Paths are built in
+`$lib/chrome.ts`; the theme tool cycles dark -> light -> warm through
+`theme.ts` (`cycleTheme` replaces `flipTheme`, and the avatar menu walks the
+same cycle); the mode strip is always four, with a rendered note where a mode
+is unavailable. Every page that escaped a shell now has one: docs, print,
+guest review, and the public reader. `/docs` became public, because the guest
+and reader shells keep the help tool. Deleted with it: `HelpModal`, `TopBar`,
+`PageTopBar`, `PaletteButton`, and the `.topbar`, `.crumbs`, `.back-link`,
+`.breadcrumb`, `.save-status`, `.review-guest-bar` and `.universe-edit` CSS.
+New kit card `scratch/design-kit/appbar.html`. Deferred on purpose: the mock's
+universe-overview restructure (the Plan page keeps its `PlanSidebar` entity
+list, gaining only the bar, the four-mode strip and a small Insights link),
+and full reader retheming, which is session 3.
+
Visual design pass (2026-07-29, branch `worktree-design-system-sheet`).
Groundwork for a whole-app cohesion pass with Claude Design: a components
sheet written from a full audit of `src/lib/styles/` and
diff --git a/e2e/account.spec.ts b/e2e/account.spec.ts
index 0c65027e..5e119ced 100644
--- a/e2e/account.spec.ts
+++ b/e2e/account.spec.ts
@@ -27,18 +27,20 @@ test('account settings: rename and see the current session', async ({ page }) =>
await page.keyboard.press('Escape');
await expect(avatar).toHaveAttribute('aria-expanded', 'false');
- // The avatar-menu theme toggle persists across a reload, not just the current
- // view (regression: it used to write only localStorage, so the next
- // server-rendered navigation reverted it).
+ // The avatar-menu theme control walks the same dark -> light -> warm cycle
+ // as the bar's theme tool, and the choice persists across a reload, not
+ // just the current view (regression: it used to write only localStorage, so
+ // the next server-rendered navigation reverted it).
await avatar.click();
- const wasDark = (await page.locator('html').getAttribute('data-theme')) === 'dark';
- const toggleTo = wasDark ? 'light' : 'dark';
+ const CYCLE = ['dark', 'light', 'warm'];
+ const before = (await page.locator('html').getAttribute('data-theme')) ?? 'dark';
+ const next = CYCLE[(CYCLE.indexOf(before) + 1) % CYCLE.length];
const themeSave = page.waitForResponse((r) => r.url().includes('/api/appearance') && r.ok());
- await page.getByRole('menuitem', { name: `Switch to ${toggleTo}` }).click();
+ await page.getByRole('menuitem', { name: `Switch to ${next}` }).click();
await themeSave;
- await expect(page.locator('html')).toHaveAttribute('data-theme', toggleTo);
+ await expect(page.locator('html')).toHaveAttribute('data-theme', next);
await page.reload();
- await expect(page.locator('html')).toHaveAttribute('data-theme', toggleTo);
+ await expect(page.locator('html')).toHaveAttribute('data-theme', next);
// Sessions live under Security, on its own page; the signed-in device
// shows as current.
@@ -64,12 +66,12 @@ test('account settings: rename and see the current session', async ({ page }) =>
// Display: a saved theme applies app-wide via the data-theme attribute.
await page.getByRole('link', { name: 'Display' }).click();
- await page.getByLabel('Theme').selectOption('dark');
+ await page.getByLabel('Theme', { exact: true }).selectOption('dark');
await expect(page.getByRole('status')).toContainText('Saved');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// Reset so repeated runs start from a known theme.
- await page.getByLabel('Theme').selectOption('system');
+ await page.getByLabel('Theme', { exact: true }).selectOption('system');
await expect(page.getByRole('status')).toContainText('Saved');
});
diff --git a/e2e/core-flow.spec.ts b/e2e/core-flow.spec.ts
index 75dee02d..32cb1931 100644
--- a/e2e/core-flow.spec.ts
+++ b/e2e/core-flow.spec.ts
@@ -37,23 +37,39 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
await page.getByLabel('New story').fill('Book of Ash');
await page.getByRole('button', { name: 'Create story' }).click();
- // Opening a story lands in the editor shell: breadcrumb and sidebar both
- // carry the story title.
- await expect(page.locator('.crumb.current')).toHaveText('Book of Ash');
+ // Opening a story lands in the editor shell: the path and the sidebar both
+ // carry the story title, and the story crumb is the menu of everything that
+ // belongs to the story, starting with going there.
+ const path = page.getByRole('navigation', { name: 'Where you are' });
+ const storyCrumb = path.getByRole('button', { name: 'Book of Ash' });
+ await expect(storyCrumb).toHaveAttribute('aria-current', 'page');
await expect(page.locator('.story-title')).toHaveText('Book of Ash');
- // The top-bar help opens the editor article in a modal; Esc closes it.
- // Opening is idempotent, so retry the click until the dialog shows: a lone
- // click can be dropped if it lands as the top bar re-renders.
- const help = page.getByRole('dialog', { name: 'Writing in the editor' });
- await expect(async () => {
- await page.getByRole('button', { name: 'Help: the editor' }).click();
- await expect(help.getByRole('heading', { name: 'Writing in the editor' })).toBeVisible({
- timeout: 2000
- });
- }).toPass({ timeout: 15000 });
+ await expect(storyCrumb).toHaveAttribute('aria-expanded', 'false');
+ await storyCrumb.click();
+ await expect(storyCrumb).toHaveAttribute('aria-expanded', 'true');
+ const storyMenu = page.getByRole('menu');
+ await expect(storyMenu.getByRole('menuitem', { name: 'Go to the story' })).toHaveAttribute(
+ 'aria-current',
+ 'page'
+ );
+ await expect(storyMenu.getByRole('menuitem', { name: 'Story settings' })).toBeVisible();
+ await expect(storyMenu.getByRole('menuitem', { name: 'Print preview' })).toBeVisible();
+ // No public edition yet, so the reading page is left out rather than dead.
+ await expect(storyMenu.getByRole('menuitem', { name: 'Public reading page' })).toHaveCount(0);
await page.keyboard.press('Escape');
- await expect(help).toBeHidden();
+ await expect(storyCrumb).toHaveAttribute('aria-expanded', 'false');
+
+ // The help tool is a place, not an overlay: it navigates to the editor
+ // article, lights up while there, and comes back when pressed again.
+ await page.getByRole('link', { name: 'Help: the editor' }).click();
+ await expect(page).toHaveURL('/docs/editor');
+ await expect(page.getByRole('dialog')).toHaveCount(0);
+ await expect(
+ page.getByRole('heading', { name: 'Writing in the editor', level: 1 })
+ ).toBeVisible();
+ await page.getByRole('link', { name: /Help is open/ }).click();
+ await expect(page).toHaveURL(/\/stories\//);
// Build the tree: a chapter, then a scene inside it, which opens.
await page.getByRole('button', { name: 'New chapter' }).click();
@@ -67,9 +83,9 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
// menu on the editor's formatting bar, so it needs a scene open.
await page.getByTitle('Switch view').click();
await page.getByRole('menuitem', { name: 'Focus' }).click();
- await expect(page.locator('.topbar')).toBeHidden();
+ await expect(page.locator('.appbar')).toBeHidden();
await page.keyboard.press('Escape');
- await expect(page.locator('.topbar')).toBeVisible();
+ await expect(page.locator('.appbar')).toBeVisible();
// Write prose: the autosave chip confirms, and a reload preserves it.
await page.locator('.cm-content').click();
@@ -435,7 +451,9 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
const PNG_B64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
if (process.env.ASSET_S3_BUCKET) {
- await page.locator('.crumb.current').click();
+ // Settings sits behind the story's own name in the path.
+ await path.getByRole('button', { name: 'Book of Ash' }).click();
+ await page.getByRole('menuitem', { name: 'Story settings' }).click();
await page.getByRole('link', { name: 'Cover' }).click();
await expect(page.getByRole('heading', { name: 'Cover' })).toBeVisible();
await expect(page.locator('svg.cover')).toBeVisible();
@@ -486,7 +504,8 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
// Publishing: set the story public, freeze an edition, and read it
// back anonymously on the public pages.
- await page.locator('.crumb.current').click();
+ await path.getByRole('button', { name: 'Book of Ash' }).click();
+ await page.getByRole('menuitem', { name: 'Story settings' }).click();
await page.getByRole('link', { name: 'Publish' }).click();
await expect(page.getByRole('heading', { name: 'Publish' })).toBeVisible();
await page.getByLabel('Visibility').selectOption('public');
@@ -501,7 +520,8 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
await expect(reader.getByRole('heading', { name: '@e2e-tester' })).toBeVisible();
await reader.getByRole('link', { name: 'Book of Ash' }).first().click();
await expect(reader.getByRole('heading', { level: 1, name: 'Book of Ash' })).toBeVisible();
- await expect(reader.locator('.reader')).toContainText('The gate of Halden');
+ // main.reader is the prose; .appbar.reader above it is the chrome shell.
+ await expect(reader.locator('main.reader')).toContainText('The gate of Halden');
// Inline images of a published edition must serve to the anonymous
// reader, not 404 (bug_004).
if (process.env.ASSET_S3_BUCKET) {
@@ -563,9 +583,10 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
await expect(page.locator('.doc-scene-mark')).toHaveCount(0);
await gotoReady(page, proseSceneUrl);
- // The breadcrumb leads to the universe editor: the same cast at universe
- // scope, with no per-story notes section.
- await page.getByRole('link', { name: universeName }).click();
+ // The universe crumb has one destination now, its Plan: the same cast at
+ // universe scope, with no per-story notes section.
+ await path.getByRole('button', { name: universeName }).click();
+ await page.getByRole('menuitem', { name: 'Go to the universe' }).click();
await expect(page).toHaveURL(/\/universes\/[^/]+\/plan$/);
await page.locator('.ent-row', { hasText: 'Alice Vane' }).click();
await expect(page.getByPlaceholder('Name', { exact: true })).toHaveValue('Alice Vane');
@@ -621,8 +642,9 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
await page.getByRole('button', { name: 'Add character' }).click();
await expect(page.getByPlaceholder('Name', { exact: true })).toHaveValue('Corvin');
- // The dashboard reaches the story directly, under its universe.
- await page.locator('.brand').click();
+ // The dashboard reaches the story directly, under its universe. The brand
+ // in the bar is the way home from anywhere.
+ await page.getByRole('link', { name: 'Codex, your library' }).click();
await expect(page).toHaveURL('/');
const universeSection = page.locator('section', { hasText: universeName });
await expect(universeSection.getByRole('link', { name: 'Book of Ash' })).toBeVisible();
diff --git a/e2e/docs.spec.ts b/e2e/docs.spec.ts
index 68f1dc8d..0fbd4c89 100644
--- a/e2e/docs.spec.ts
+++ b/e2e/docs.spec.ts
@@ -6,10 +6,41 @@ test('help: browse the index and open an article', async ({ page }) => {
await gotoReady(page, '/docs');
await expect(page.getByRole('heading', { name: 'Help', level: 1 })).toBeVisible();
+ // Help is a page in the chrome, not a bare page: the bar is there, the path
+ // starts at Help, and the article list is the left pane.
+ await expect(page.getByRole('navigation', { name: 'Where you are' })).toContainText('Help');
+ const topics = page.getByRole('navigation', { name: 'Help topics' });
+ await expect(topics.getByRole('link')).not.toHaveCount(0);
- await page.getByRole('link', { name: /Writing in the editor/ }).click();
+ await page
+ .getByRole('main')
+ .getByRole('link', { name: /Writing in the editor/ })
+ .click();
await expect(page).toHaveURL('/docs/editor');
await expect(
page.getByRole('heading', { name: 'Writing in the editor', level: 1 })
).toBeVisible();
+ // The open article is ticked in the list, and the path names it.
+ await expect(topics.getByRole('link', { name: 'Writing in the editor' })).toHaveAttribute(
+ 'aria-current',
+ 'page'
+ );
+ await expect(page.getByRole('navigation', { name: 'Where you are' })).toContainText(
+ 'Writing in the editor'
+ );
+});
+
+test('help: the question mark is a place, and takes you back out', async ({ page }) => {
+ await gotoReady(page, '/');
+
+ // The tool navigates to help rather than opening a modal over your work.
+ await page.getByRole('link', { name: /^Help:/ }).click();
+ await expect(page).toHaveURL(/^.*\/docs(\/|$)/);
+ await expect(page.getByRole('dialog')).toHaveCount(0);
+
+ // While you are there it lights up, and pressing it again returns you.
+ const lit = page.getByRole('link', { name: /Help is open/ });
+ await expect(lit).toHaveAttribute('aria-current', 'page');
+ await lit.click();
+ await expect(page).toHaveURL('/');
});
diff --git a/e2e/review.spec.ts b/e2e/review.spec.ts
index a3a31db7..14e33337 100644
--- a/e2e/review.spec.ts
+++ b/e2e/review.spec.ts
@@ -48,12 +48,28 @@ test('guest review: invite, comment as a guest, reply and resolve as the author'
await guest.getByLabel('Your name').fill('Margin Walker');
await guest.getByLabel('Email (optional)').fill('margin@example.com');
await guest.getByRole('button', { name: 'Start reviewing' }).click();
+ // The guest shell: the same bar with the parts a reviewer has no use for
+ // removed, and the parts they were missing (theme, help) put back.
await expect(guest.getByText('Reviewing as Margin Walker')).toBeVisible();
+ await expect(guest.locator('.appbar.guest')).toBeVisible();
+ await expect(guest.getByRole('navigation', { name: 'Where you are' })).toContainText(
+ 'Review only'
+ );
+ await expect(guest.getByRole('button', { name: /^Theme:/ })).toBeVisible();
+ await expect(guest.getByRole('link', { name: 'Help: reviewing' })).toBeVisible();
+ // No search, no bell, no account menu for someone without an account.
+ await expect(guest.getByRole('button', { name: 'Search and commands' })).toHaveCount(0);
+ await expect(guest.getByRole('button', { name: 'Account menu' })).toHaveCount(0);
await expect(guest.locator('.review-prose')).toContainText('opinions about this gate');
- // A guest cannot leave review mode: the other tabs are disabled.
- await expect(guest.locator('.seg-btn', { hasText: 'Write' })).toBeDisabled();
- await expect(guest.locator('.seg-btn', { hasText: 'Plan' })).toBeDisabled();
+ // A guest cannot leave review mode: the strip keeps all four modes, three
+ // switched off in place, and one line under it says why.
+ const modes = guest.locator('.mode-strip .seg-btn');
+ await expect(modes).toHaveCount(4);
+ for (const mode of ['Write', 'Plan', 'Notes']) {
+ await expect(modes.filter({ hasText: mode })).toHaveAttribute('aria-disabled', 'true');
+ }
+ await expect(guest.locator('.mode-note')).toContainText('belong to the author');
// A whole-scene comment from the panel.
await guest.getByRole('button', { name: 'Whole scene' }).click();
diff --git a/scratch/design-kit/README.md b/scratch/design-kit/README.md
index ab869a6d..27e09831 100644
--- a/scratch/design-kit/README.md
+++ b/scratch/design-kit/README.md
@@ -53,5 +53,5 @@ repo to project; never edit the system inside the project.
## Cards
Foundations: `colors.html`, `type.html`, `spacing.html`.
-Components: `buttons.html`, `forms.html`, `menus.html`, `badges.html`,
-`seg.html`, `cards.html`, `empty-states.html`, `modal.html`.
+Components: `appbar.html`, `buttons.html`, `forms.html`, `menus.html`,
+`badges.html`, `seg.html`, `cards.html`, `empty-states.html`, `modal.html`.
diff --git a/scratch/design-kit/appbar.html b/scratch/design-kit/appbar.html
new file mode 100644
index 00000000..ad98b973
--- /dev/null
+++ b/scratch/design-kit/appbar.html
@@ -0,0 +1,298 @@
+
+
+
+
+ One bar for every page: brand, then the path, then the tools, in that order. A shell may drop
+ a tool; none reorders one, and none adds a second bar. Every crumb opens that place's home,
+ the last crumb is the page you are on and does not link, and a place in the path is a menu
+ whose first item goes to the place itself and whose rest are that place's own things,
+ settings included. A page in the path is plain text, because nothing belongs to it. There is
+ no gear and no icon-only destination. Page actions (Print, Save, New universe) belong in the
+ page header under the bar, never in the bar.
+
+
+
+
+
diff --git a/scratch/system-design/design-system.md b/scratch/system-design/design-system.md
index 9d8c9f46..0c1f1018 100644
--- a/scratch/system-design/design-system.md
+++ b/scratch/system-design/design-system.md
@@ -105,9 +105,9 @@ Every page uses exactly one of these four wrappers. Do not build a fifth.
| Shell | Component(s) | Used by |
|---|---|---|
-| Workspace | `.app` grid + `TopBar.svelte` | Write, Plan, Notes, Review (story scope); Plan, Notes (universe scope) |
-| Page | `.page-shell` + `PageTopBar.svelte` | Library |
-| Settings | `SettingsShell.svelte` + `PageTopBar.svelte` | Account, admin, story settings, universe settings, insights |
+| Workspace | `.app` grid + `AppBar.svelte` | Write, Plan, Notes, Review (story scope); Plan, Notes (universe scope); help; guest review |
+| Page | `.page-shell` + `AppBar.svelte` | Library, print preview, public reading pages |
+| Settings | `SettingsShell.svelte` + `AppBar.svelte` | Account, admin, story settings, universe settings, insights |
| Auth | `AuthShell.svelte` | Sign-in, sign-up, and every email-flow page |
The workspace shell is a three-column grid: structure left (fixed 240px),
@@ -115,12 +115,81 @@ work centre, reference right (fixed 280px). Sidebar widths are a settled
decision; resize is deferred. Focus mode (`.app.focus-mode`) hides both
sidebars with `visibility: hidden` so the prose column does not shift.
-`TopBar` carries breadcrumbs (Library > Universe > Story), save status,
-palette button, notification bell, help, and the user menu. `PageTopBar`
-carries a single back link instead of breadcrumbs. The pages that currently
-escape all shells (docs, print, guest review, public reader) are known
-drift; the fix is scoped in `design-pass-prompts.md`, brief 2. Do not model
-a new page on them.
+Every one of them wears the same bar; see Chrome below. Nothing escapes a
+shell any more: docs, print, guest review and the public reader all carry it.
+
+## Chrome
+
+One navigation bar for every page type, defined in `chrome.css` (imported
+last, so it beats the bars it replaced) and rendered by `AppBar.svelte`:
+brand, then the path, then the tools, in that order, 52px, `--bg-elevated`,
+one bottom border.
+
+Three shells, one family. A shell may drop a tool; none reorders one, and
+none adds a second bar.
+
+| Shell | Brand | Path | Tools |
+|---|---|---|---|
+| `.appbar` (author) | links to your library | Library / Universe / Story / Page, each place a menu | Search, Theme, Help, Notifications, You |
+| `.appbar.guest` | lockup, not a link | the one story they were sent, plus a "Review only" pill | Theme, Help, "Reviewing as ..." |
+| `.appbar.reader` | links to Codex | the story and its author, as text | Theme, Help |
+
+The path and its crumb menus:
+
+- The path is `Library / Universe / Story / Page`. Every crumb opens that
+ place's home; the last crumb is the page you are on and does not link.
+- **Crumbs go to homes.** The story crumb opens the story's Write mode; the
+ universe crumb opens the universe's Plan, always, whether or not a story
+ is open.
+- **A place in the path is a menu; a page in the path is text.** The
+ universe and story crumbs are `.crumb-menu` buttons carrying a caret. The
+ menu's first item goes to the place itself ("Go to the universe", "Go to
+ the story") and is ticked when you are already there; below it sit that
+ place's own things. Crumbs for pages (Insights, Print preview, a help
+ article) are plain current text, because nothing belongs to them.
+- There is no gear anywhere in the chrome, and no icon-only destination.
+- Menus compose the shared `.popover`/`.menu-item` skin, carry
+ `aria-haspopup`/`aria-expanded`, tick the current page with
+ `aria-current="page"`, and close on outside pointerdown or Escape through
+ the shared `dismiss` action.
+- Paths are built by `$lib/chrome.ts` (`libraryCrumb`, `universeCrumb`,
+ `storyCrumb`, `pageCrumb`, `storyPath`, `universePath`), so every screen
+ spells the same destinations the same way. Do not hand-roll a path.
+
+Page actions are not bar actions. Anything that acts on the page (Print,
+Page setup, Save, New universe) lives in the page header under the bar,
+never in the bar.
+
+Tools:
+
+- `.tool-search` is the palette trigger: a labelled button with a `kbd`
+ hint, not an icon. Ctrl+K does the same.
+- The theme tool cycles dark -> light -> warm (`$lib/theme.ts`,
+ `cycleTheme`). A signed-in viewer's choice persists through the appearance
+ API; a guest reviewer's and a reader's is kept in localStorage only.
+- The `?` is a tool that is a place, not a popup: it navigates to the help
+ pages, lights up with `aria-current="page"` while you are there, and takes
+ you back to where you were when pressed again. There is no help modal.
+
+The left slot is always 240px, whatever fills it:
+
+- **The mode strip is always four.** Write, Plan, Notes, Review, same order,
+ same width, wherever it appears; it never resizes silently.
+ `ModeSwitcher.svelte` is the only implementation. At universe scope Write
+ and Review stay live and open the universe's story list. For a guest,
+ three are `aria-disabled` and stay in place. Where a mode is unavailable,
+ one `.mode-note` line under the strip says why: rendered, not a tooltip,
+ so it is readable by keyboard and by screen reader. The two lines live as
+ `UNIVERSE_MODE_NOTE` and `GUEST_MODE_NOTE` in `$lib/chrome.ts`.
+- Where there is no mode at all (settings, insights, print, help) the same
+ slot holds that page's own section list: `.side-nav`, with
+ `.side-nav-label` group headings, `.side-head` for the identity block, and
+ `aria-current="page"` on the open item. In-page anchors are a sanctioned
+ use of `.side-nav`.
+
+The command palette stays the fast path to everything, but it is a shortcut
+to this model, never a substitute for it: anything reachable only from the
+palette is a bug.
The right sidebar of the workspace shell has up to four tabs with fixed
semantics (see `design.md`): Reference (what is in view), History (what was
@@ -204,8 +273,8 @@ A head can carry a `.modal-search` field instead of a title, with
The behaviour is the component's, not the CSS's: Esc closes, focus is
trapped in the panel and returned to the opener, and a backdrop click
-closes only when nothing is unsaved. `CommandPalette`, `ReviewModal` and
-`HelpModal` are the three reference uses.
+closes only when nothing is unsaved. `CommandPalette` and `ReviewModal` are
+the two reference uses. Help is not one of them: it is a page, not a modal.
### Badges, chips, pills
@@ -272,8 +341,8 @@ inline ad-hoc SVG in components.
### Status and feedback
-- Save state: the top-bar save pill (`Saving...` / `Saved just now` /
- retry on error).
+- Save state: the app bar's save pill in the tools cluster (`Saving...` /
+ `Saved just now` / retry on error), on the editor pages only.
- Keyboard hints: the `kbd` element (styled globally in `pages.css`).
- Tooltips: native `title` attributes plus `aria-label` for icon-only
controls. There is no custom tooltip primitive; do not build one ad hoc.
@@ -318,8 +387,9 @@ shared class inside a component (this still happens for `.btn-sm` and
## New page checklist
-1. Pick the shell; wire the top bar (breadcrumbs or back link) and the
- palette/bell/help/user cluster that shell provides.
+1. Pick the shell; wire `AppBar` with a path built from `$lib/chrome.ts` and
+ the tools cluster that shell provides. A new page adds a crumb; it does
+ not add a bar.
2. Compose from the canonical primitives above; extend shared CSS if a
variant is genuinely missing.
3. Tokens only; no raw colours, no `[data-theme]` branches, no token
@@ -393,18 +463,18 @@ once the drag handlers stop swallowing keyboard events.
## Component index
Reusable primitives: `Icon`, `EntityBadge`, `EntityQuickCard`, `TagInput`,
-`ModeSwitcher`, `ThemeToggle`, `PaletteButton`, `SidebarSearch`,
+`ModeSwitcher`, `ThemeToggle`, `CrumbMenu`, `SidebarSearch`,
`HelpLink`, `FormStatus`, `ReviewAvatar`, `ReviewReplyForm`,
`ReviewReplyRow`, `ReviewMarginRail`, `ReviewSelectionToolbar`.
-Shared composites: `TopBar`, `PageTopBar`, `SettingsShell`, `AuthShell`,
+Shared composites: `AppBar`, `SettingsShell`, `DocsShell`, `AuthShell`,
`UserMenu`, `NotificationBell`, `ActivityCenter`, `ViewMenu`,
`EditorToolbar`, `SelectionMenu`, `NotesSidebar`, `PlanSidebar`,
`StoryOutline`, `StoryRowMenu`, `RevisionHistory`, `EntityBadgePicker`,
`EntityRelationships`, `ReviewSurface`, `ReviewPanel`, `ReviewCommentCard`,
`ReviewSuggestionCard`, `ReviewSceneHead`, `AssistantProposal`.
-Single-purpose surfaces: `Landing`, `CommandPalette`, `HelpModal`,
+Single-purpose surfaces: `Landing`, `CommandPalette`,
`SceneEditor`, `StoryPreview`, `NoteEditor`, `RevisionPreview`,
`SessionPanel`, `ExportPanel`, `AssistantPanel`, `CoauthorPanel`,
`ReviewWorkspace`, `ReviewEditor`, `ReviewModal`, `EntityEditor`,
diff --git a/scratch/system-design/design.md b/scratch/system-design/design.md
index d182451b..532baf6f 100644
--- a/scratch/system-design/design.md
+++ b/scratch/system-design/design.md
@@ -86,11 +86,13 @@ The editor exists at two scopes: **story** and **universe**. Both use the same t
- **Plan** swaps the left sidebar for a list of the story's entities (the universe entities it uses, grouped by category) and the centre column for an entity editor with fields for summary, body, aliases, story-specific notes, and relationships. Right sidebar shows mentions and relations.
- **Notes** shows freeform notes grouped by pinned and recent, with the same prose-first editor in the centre. Notes can be scoped to the universe, the story, or a specific scene.
-**Inside a universe**, the editor offers two of those views — **Plan** and **Notes** — without Write. A universe doesn't contain prose directly; it contains the worldbuilding that stories draw on. The universe-level Plan is the same entity editor component as the story-level Plan, except it shows every entity in the universe (rather than the subset a story uses) and hides the *In this book* story-scoped section (there's no story context at this scope to define "in this book" against). The universe-level Notes is the same notes component, filtered to notes that aren't attached to any specific story.
+**Inside a universe**, the editor still shows all four modes, because the strip is the same four everywhere; **Plan** and **Notes** open at universe scope, while Write and Review resolve to the universe's story list, since both need a story open. One line under the strip says so. A universe doesn't contain prose directly; it contains the worldbuilding that stories draw on. The universe-level Plan is the same entity editor component as the story-level Plan, except it shows every entity in the universe (rather than the subset a story uses) and hides the *In this book* story-scoped section (there's no story context at this scope to define "in this book" against). The universe-level Notes is the same notes component, filtered to notes that aren't attached to any specific story.
This structure — universes as first-class editable surfaces, not just settings containers — is important for worldbuilders and TTRPG designers who may never write a prose "story" but need a full editor for characters, places, and lore. It's also important for novelists adding a character who'll cross multiple books: the natural home for such a character is the universe, not whichever book they happened to appear in first.
-Across every view at either scope, the right sidebar has four tabs: **Reference** (panels for what's in view), **History** (the revision timeline for whatever is currently being edited), **Session** (quick settings that govern how you're working in this moment), and **Assistant** (the LLM chat surface). Each tab has a distinct semantic axis: *what's in view*, *what was in view*, *how you're working with it*, and *who you're working with*. The Assistant tab shows only when the account has the Assistant configured and enabled and the story has not muted it (see `assistant.md` and "AI: the Assistant" below); it is a peer of the other three rather than nested inside Session. A command palette (Ctrl+K), which absorbs search and cross-view navigation into one surface, was added later in Phase 7.
+Across every view at either scope, the right sidebar has four tabs: **Reference** (panels for what's in view), **History** (the revision timeline for whatever is currently being edited), **Session** (quick settings that govern how you're working in this moment), and **Assistant** (the LLM chat surface). Each tab has a distinct semantic axis: *what's in view*, *what was in view*, *how you're working with it*, and *who you're working with*. The Assistant tab shows only when the account has the Assistant configured and enabled and the story has not muted it (see `assistant.md` and "AI: the Assistant" below); it is a peer of the other three rather than nested inside Session. A command palette (Ctrl+K), which absorbs search and cross-view navigation into one surface, was added later in Phase 7; it is a shortcut to the navigation model, never a substitute for it, so anything reachable only from the palette is a bug.
+
+Every page, at either scope, wears one navigation bar: brand, then the path (Library / Universe / Story / Page), then the tools. A crumb opens that place's home, and a place in the path is a menu whose first item goes to the place itself and whose remaining items are that place's own things, settings included. There is no gear and no icon-only destination. Help is a page in that same chrome, reached from the question mark in the tools, which lights up while you are there and takes you back when pressed again; it never opens over the top of your work. See `design-system.md` for the full chrome contract.
### The Session tab
@@ -134,7 +136,7 @@ A diff view sits alongside the timeline. Any two revisions can be compared, and
### Focus mode
-A button in the top bar hides both sidebars using `visibility: hidden` rather than `display: none`, so the prose column stays anchored where it was instead of shifting to the new centre of a now-wider grid. The mode doesn't widen the prose — it removes the chrome around it. The content width setting controls how wide the prose column actually is, independently of focus.
+Focus mode, chosen from the View menu on the editor's formatting bar, hides the navigation bar and both sidebars, using `visibility: hidden` on the sidebars rather than `display: none`, so the prose column stays anchored where it was instead of shifting to the new centre of a now-wider grid. The mode doesn't widen the prose — it removes the chrome around it. The content width setting controls how wide the prose column actually is, independently of focus.
### Continuous view
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index d27fb23a..3f64f27b 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -23,6 +23,9 @@ const PUBLIC_PATHS = new Set([
// themselves on the signed challenge cookie.
'/api/passkeys/signin-options',
'/api/passkeys/signin',
+ // The help pages. The question mark in the bar is there for a guest
+ // reviewer and a reader too, and neither has an account.
+ '/docs',
// Liveness probe for the reverse proxy and orchestrators; no auth.
'/healthz'
]);
@@ -30,7 +33,7 @@ const PUBLIC_PATHS = new Set([
const AUTH_PATHS = new Set(['/login', '/signup']);
// Reader pages are public; assets and export downloads check publication
// state themselves, and review pages guard on the magic-link token.
-const PUBLIC_PREFIXES = ['/@', '/assets/', '/artifacts/', '/review/'];
+const PUBLIC_PREFIXES = ['/@', '/assets/', '/artifacts/', '/review/', '/docs/'];
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
diff --git a/src/lib/autosave.ts b/src/lib/autosave.ts
index 5a82b1e5..ac310a13 100644
--- a/src/lib/autosave.ts
+++ b/src/lib/autosave.ts
@@ -15,7 +15,7 @@ export type AutosaveOptions = {
// must outlive the page (note the keepalive body cap; the debounced saves
// already covered the text up to the last pause).
save: (opts: { keepalive: boolean }) => Promise;
- // Save feedback for a status indicator (the TopBar).
+ // Save feedback for a status indicator (the app bar's save pill).
onStatus?: (status: SaveStatus) => void;
// Runs after a save that succeeded and was not re-dirtied mid-flight; for
// follow-ups that want settled text (an entity rename offer).
diff --git a/src/lib/chrome.ts b/src/lib/chrome.ts
new file mode 100644
index 00000000..76d3888e
--- /dev/null
+++ b/src/lib/chrome.ts
@@ -0,0 +1,160 @@
+import { resolve } from '$app/paths';
+
+// The path in the app bar, built here so every screen spells the same
+// destinations the same way. The model: Library / Universe / Story / Page.
+// Every crumb opens that place's home, the last crumb is the page you are on
+// and does not link, and a place in the path is a menu whose first item goes
+// to the place itself. A page in the path is plain text, because nothing
+// belongs to it.
+
+// The line under the mode strip at universe scope. Write and Review need a
+// story open, so both open the universe's own list of stories.
+export const UNIVERSE_MODE_NOTE =
+ 'Write and Review need a story open. Both take you to the story list.';
+
+// The line under the mode strip for an invited reviewer.
+export const GUEST_MODE_NOTE =
+ 'You were invited to review. Write, Plan and Notes belong to the author, so they are here but switched off.';
+
+export type CrumbMenuItem = { label: string; href: string; current?: boolean };
+
+export type Crumb =
+ // A place further up the path, opened by clicking its name.
+ | { kind: 'link'; label: string; href: string }
+ // The page you are on, or a place with nothing behind it.
+ | { kind: 'text'; label: string }
+ // A place: the name is the menu, and `groups` are separated by a rule.
+ | { kind: 'place'; label: string; current: boolean; groups: CrumbMenuItem[][] };
+
+export type UniverseRef = { slug: string; name: string };
+
+// A story's public reading page, when it has one. Absent when the story has
+// no published edition, or the owner has no public handle: the menu then
+// leaves the item out rather than offering a dead link.
+export type ReadingRef = { handle: string; storyId: string };
+
+export type StoryRef = { slug: string; title: string; reading?: ReadingRef | null };
+
+// Which universe-scoped page is open, so the menu can tick it.
+export type UniverseAt = 'universe' | 'settings' | 'insights' | 'export' | null;
+// Which story-scoped page is open, so the menu can tick it.
+export type StoryAt = 'story' | 'settings' | 'print' | 'export' | 'reading' | null;
+
+export function libraryCrumb(current = false): Crumb {
+ return current
+ ? { kind: 'text', label: 'Library' }
+ : { kind: 'link', label: 'Library', href: resolve('/') };
+}
+
+export function universeCrumb(universe: UniverseRef, at: UniverseAt = null): Crumb {
+ const settings = resolve('/universes/[id]/[[section]]', { id: universe.slug });
+ return {
+ kind: 'place',
+ label: universe.name,
+ current: at === 'universe',
+ groups: [
+ [
+ {
+ label: 'Go to the universe',
+ href: resolve('/universes/[id]/plan', { id: universe.slug }),
+ current: at === 'universe'
+ }
+ ],
+ [
+ { label: 'Universe settings', href: settings, current: at === 'settings' },
+ {
+ label: 'Insights',
+ href: resolve('/universes/[id]/insights', { id: universe.slug }),
+ current: at === 'insights'
+ }
+ ],
+ [
+ {
+ label: 'Export the universe',
+ href: resolve('/universes/[id]/[[section]]', {
+ id: universe.slug,
+ section: 'export'
+ }),
+ current: at === 'export'
+ }
+ ]
+ ]
+ };
+}
+
+export function storyCrumb(story: StoryRef, at: StoryAt = null): Crumb {
+ const own: CrumbMenuItem[] = [
+ {
+ label: 'Story settings',
+ href: resolve('/stories/[id]/settings/[[section]]', { id: story.slug }),
+ current: at === 'settings'
+ },
+ {
+ label: 'Print preview',
+ href: resolve('/stories/[id]/print', { id: story.slug }),
+ current: at === 'print'
+ },
+ {
+ label: 'Export a file',
+ href: resolve('/stories/[id]/settings/[[section]]', {
+ id: story.slug,
+ section: 'export'
+ }),
+ current: at === 'export'
+ }
+ ];
+ const groups: CrumbMenuItem[][] = [
+ [
+ {
+ label: 'Go to the story',
+ href: resolve('/stories/[id]', { id: story.slug }),
+ current: at === 'story'
+ }
+ ],
+ own
+ ];
+ if (story.reading) {
+ groups.push([
+ {
+ label: 'Public reading page',
+ href: resolve('/@[handle]/[story=uuid]', {
+ handle: story.reading.handle,
+ story: story.reading.storyId
+ }),
+ current: at === 'reading'
+ }
+ ]);
+ }
+ return { kind: 'place', label: story.title, current: at === 'story', groups };
+}
+
+// A page under a place: Insights, Print preview, a help article.
+export function pageCrumb(label: string): Crumb {
+ return { kind: 'text', label };
+}
+
+// The whole path for a story-scoped page, which is every editor view plus the
+// pages that hang off a story.
+export function storyPath(
+ universe: UniverseRef,
+ story: StoryRef,
+ options: { storyAt?: StoryAt; page?: string } = {}
+): Crumb[] {
+ const crumbs: Crumb[] = [
+ libraryCrumb(),
+ universeCrumb(universe),
+ storyCrumb(story, options.storyAt ?? null)
+ ];
+ if (options.page) crumbs.push(pageCrumb(options.page));
+ return crumbs;
+}
+
+// The whole path for a universe-scoped page.
+export function universePath(
+ universe: UniverseRef,
+ options: { universeAt?: UniverseAt; page?: string } = {}
+): Crumb[] {
+ const crumbs: Crumb[] = [libraryCrumb(), universeCrumb(universe, options.universeAt ?? null)];
+ if (options.page) crumbs.push(pageCrumb(options.page));
+ return crumbs;
+}
diff --git a/src/lib/components/AppBar.svelte b/src/lib/components/AppBar.svelte
new file mode 100644
index 00000000..96e905ba
--- /dev/null
+++ b/src/lib/components/AppBar.svelte
@@ -0,0 +1,162 @@
+
+
+
+ {#if shell === 'guest'}
+
+
+ Codex
+
+ {:else}
+
+
+ Codex
+
+ {/if}
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/HelpLink.svelte b/src/lib/components/HelpLink.svelte
index 87177bcd..31f06b1e 100644
--- a/src/lib/components/HelpLink.svelte
+++ b/src/lib/components/HelpLink.svelte
@@ -1,20 +1,21 @@
-
+
diff --git a/src/lib/components/Icon.svelte b/src/lib/components/Icon.svelte
index 94c5c4a9..ba6bc4e5 100644
--- a/src/lib/components/Icon.svelte
+++ b/src/lib/components/Icon.svelte
@@ -3,6 +3,8 @@
const PATHS = {
feather: ['M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z', 'M16 8 2 22', 'M17.5 15H9'],
chevron: ['M9 6l6 6-6 6'],
+ // Points down at rest; the crumb menu rotates it when the menu opens.
+ caret: ['M6 9.5 12 15.5 18 9.5'],
// Marks a link that leaves the current view.
'arrow-out': ['M7 17 17 7', 'M8 7h9v9'],
plus: ['M12 5v14', 'M5 12h14'],
diff --git a/src/lib/components/ModeSwitcher.svelte b/src/lib/components/ModeSwitcher.svelte
index 672a1aea..e21fdbe9 100644
--- a/src/lib/components/ModeSwitcher.svelte
+++ b/src/lib/components/ModeSwitcher.svelte
@@ -1,16 +1,21 @@
-
+
{#each MODES as { mode, label } (mode)}
{#if mode === active}
-
- {:else if hrefs[mode] === 'disabled'}
-
- {:else if hrefs[mode]}
+
+ {:else if hrefs[mode] && hrefs[mode] !== 'disabled'}
{label}
+ {:else}
+ {label}
{/if}
{/each}
+{#if note}
+
{note}
+{/if}
diff --git a/src/lib/components/NotesSidebar.svelte b/src/lib/components/NotesSidebar.svelte
index 8f101032..834cece8 100644
--- a/src/lib/components/NotesSidebar.svelte
+++ b/src/lib/components/NotesSidebar.svelte
@@ -14,6 +14,7 @@
planHref,
writeHref,
reviewHref,
+ modeNote,
universeNotes = [],
universeNotesPath,
form
@@ -22,10 +23,12 @@
selectedId?: string;
notesPath: string;
planHref: string;
- // Present at story scope only; the universe Notes view has no Write.
+ // At universe scope both point at the universe's own story list, which
+ // is where you pick the story to write in or review.
writeHref?: string;
- // Present at story scope only; the universe Notes view has no Review.
reviewHref?: string;
+ // The line under the mode strip, when the strip needs explaining.
+ modeNote?: string;
// Universe notes shown read-only at story scope, linking to the universe
// Notes view to edit. Empty at universe scope.
universeNotes?: NoteListItem[];
@@ -46,7 +49,11 @@
diff --git a/src/lib/components/ReviewEditor.svelte b/src/lib/components/ReviewEditor.svelte
index 2b6d44d4..cc0593c0 100644
--- a/src/lib/components/ReviewEditor.svelte
+++ b/src/lib/components/ReviewEditor.svelte
@@ -85,7 +85,7 @@
entityHref: ((entity: MentionEntity) => string) | null;
onStartComment: (sel: { start: number; end: number; text: string }) => void;
onStartSuggest: (sel: { start: number; end: number; text: string }) => void;
- // Save feedback for the TopBar, same contract as SceneEditor's.
+ // Save feedback for the app bar, same contract as SceneEditor's.
onStatus?: (status: SaveStatus) => void;
} = $props();
diff --git a/src/lib/components/ReviewWorkspace.svelte b/src/lib/components/ReviewWorkspace.svelte
index 1044b011..3ff9badf 100644
--- a/src/lib/components/ReviewWorkspace.svelte
+++ b/src/lib/components/ReviewWorkspace.svelte
@@ -25,6 +25,7 @@
import type { SaveStatus } from './SceneEditor.svelte';
import type { ViewItem } from './ViewMenu.svelte';
import ModeSwitcher from './ModeSwitcher.svelte';
+ import { GUEST_MODE_NOTE } from '$lib/chrome';
import {
duplicateScene as duplicateSceneAction,
mergeScenes as mergeScenesAction
@@ -102,7 +103,7 @@
proposals?: Omit[];
} | null;
}[];
- // The author editor's autosave feedback, surfaced in the page's TopBar.
+ // The author editor's autosave feedback, surfaced in the page's app bar.
onSaveStatus?: (status: SaveStatus) => void;
} = $props();
@@ -425,6 +426,7 @@
hrefs={seg
? { write: seg.writeHref, plan: seg.planHref, notes: seg.notesHref }
: { write: 'disabled', plan: 'disabled', notes: 'disabled' }}
+ note={seg ? undefined : GUEST_MODE_NOTE}
/>
diff --git a/src/lib/components/SettingsShell.svelte b/src/lib/components/SettingsShell.svelte
index 9527173a..8ab9dc25 100644
--- a/src/lib/components/SettingsShell.svelte
+++ b/src/lib/components/SettingsShell.svelte
@@ -2,8 +2,8 @@
import type { Snippet } from 'svelte';
// The shared admin-shell scaffold behind the settings-style pages (account,
- // admin, story settings, universe, universe insights): a top bar, a left
- // sidebar, and the scrolling main column. Each page supplies its own top bar
+ // admin, story settings, universe, universe insights): the app bar, a left
+ // sidebar, and the scrolling main column. Each page supplies its own bar
// and sidebar (title + nav) and drops its content in as the default child;
// the wrapper structure and its classes live here once.
let {
@@ -11,7 +11,7 @@
sidebar,
children
}: {
- // The PageTopBar for this page, rendered above the shell.
+ // The AppBar for this page, rendered above the shell.
topbar: Snippet;
// The sidebar body: the title block and the nav.
sidebar: Snippet;
diff --git a/src/lib/components/ThemeToggle.svelte b/src/lib/components/ThemeToggle.svelte
index 37c7fd7e..853325da 100644
--- a/src/lib/components/ThemeToggle.svelte
+++ b/src/lib/components/ThemeToggle.svelte
@@ -2,22 +2,27 @@
import { browser } from '$app/environment';
import { page } from '$app/state';
import Icon from './Icon.svelte';
- import { flipTheme } from '$lib/theme';
+ import { cycleTheme, currentTheme, nextTheme } from '$lib/theme';
+ import type { ConcreteTheme } from '$lib/appearance';
+
+ // The theme control in the app bar. It walks dark -> light -> warm, the
+ // same three palettes in the same order for every shell. A signed-in
+ // viewer gets the choice saved to their account; a guest reviewer or a
+ // reader keeps it in this browser only.
// Initialised from the attribute the app.html inline script set; reassigned
- // by the toggle (writable derived).
- let theme = $derived<'light' | 'dark'>(
- browser && document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'
- );
+ // by the control.
+ let theme = $state(browser ? currentTheme() : 'dark');
- // Signed-in viewers get the flip saved to their account; without that, the
- // layout re-applies the stored appearance on the next data refresh (for
- // example after an autosave) and snaps the theme back.
- function toggleTheme() {
- theme = flipTheme(Boolean(page.data.user));
- }
+ const label = $derived(`Theme: ${theme}. Click for ${nextTheme(theme)}.`);
-