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 @@ + + + + + + App 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} + + + + + + +
+ {#if saveStatus !== 'idle'} + {SAVE_LABEL[saveStatus]} + {/if} + {#if shell === 'author' && page.data.user} + + {/if} + + + + + ? + + + {#if shell === 'author'} + + + {/if} + {#if who} + + {authorInitials(who)} + Reviewing as {who} + + {/if} +
+
+ + diff --git a/src/lib/components/CrumbMenu.svelte b/src/lib/components/CrumbMenu.svelte new file mode 100644 index 00000000..1f3636c2 --- /dev/null +++ b/src/lib/components/CrumbMenu.svelte @@ -0,0 +1,75 @@ + + + trigger?.focus() }} +> + + + + + diff --git a/src/lib/components/DocsShell.svelte b/src/lib/components/DocsShell.svelte new file mode 100644 index 00000000..45a3815e --- /dev/null +++ b/src/lib/components/DocsShell.svelte @@ -0,0 +1,80 @@ + + +
+ +
+ +
+
+ {@render children()} +
+
+
+
+ + 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)}.`); - diff --git a/src/lib/components/TopBar.svelte b/src/lib/components/TopBar.svelte deleted file mode 100644 index 4d1f48fe..00000000 --- a/src/lib/components/TopBar.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - -
- - - Codex - - -
- {#if saveStatus !== 'idle'} - {SAVE_LABEL[saveStatus]} - {/if} - - - {#if story} - - - - {:else} - - - - {/if} - - {#if help} - - {/if} - -
-
diff --git a/src/lib/components/UserMenu.svelte b/src/lib/components/UserMenu.svelte index 69f13ed8..6242d43e 100644 --- a/src/lib/components/UserMenu.svelte +++ b/src/lib/components/UserMenu.svelte @@ -2,22 +2,21 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { browser } from '$app/environment'; - import { flipTheme } from '$lib/theme'; + import { cycleTheme, currentTheme, nextTheme } from '$lib/theme'; import { dismiss } from '$lib/dismiss'; import { authorInitials } from '$lib/review-ui'; import Icon from './Icon.svelte'; + import type { ConcreteTheme } from '$lib/appearance'; type MenuUser = { displayName: string; email: string; isAdmin: boolean }; const user = $derived(page.data.user as MenuUser | null); let open = $state(false); - // Accent is left untouched; flipTheme handles the flip, the localStorage - // mirror, and the account persist. - let dark = $state(browser && document.documentElement.getAttribute('data-theme') === 'dark'); - function toggleTheme() { - dark = flipTheme(true) === 'dark'; - } + // The same dark -> light -> warm cycle as the bar's theme tool; accent is + // left untouched, and cycleTheme handles the localStorage mirror and the + // account persist. + let theme = $state(browser ? currentTheme() : 'dark'); {#if user} @@ -60,14 +59,18 @@
-
diff --git a/src/lib/docs/account.md b/src/lib/docs/account.md index e94e2915..122f3971 100644 --- a/src/lib/docs/account.md +++ b/src/lib/docs/account.md @@ -1,6 +1,6 @@ # Your account -Open your account page from the avatar menu in the top bar. It has four +Open your account page from the avatar menu at the top right of any page. It has four sections: Profile, Security, Assistant, and Display. Changes save when you select the save button in each block. diff --git a/src/lib/docs/editor.md b/src/lib/docs/editor.md index c61f6d96..661dd367 100644 --- a/src/lib/docs/editor.md +++ b/src/lib/docs/editor.md @@ -2,7 +2,7 @@ The editor is where you draft scenes. It saves on its own as you type, so there is no save button; the status near the top shows when your work was last stored. -The gear in the top bar opens the story's settings: details, cover, page setup, publishing, review links, exports, and more. Clicking the story's title in the breadcrumb goes there too. +The story's title in the path at the top of the window is a menu. Select it to open the story: its settings (details, cover, page setup, publishing, review links, exports, and more), the print preview, and the public reading page once you have published one. The first item in that menu, "Go to the story", brings you back to the editor from anywhere. ## Scenes and chapters diff --git a/src/lib/docs/getting-started.md b/src/lib/docs/getting-started.md index 5d236b58..92d1c542 100644 --- a/src/lib/docs/getting-started.md +++ b/src/lib/docs/getting-started.md @@ -16,7 +16,7 @@ For a story that does not belong to any world, use the "New standalone story" ca That is enough to start writing. When you are ready, the planning tools help you keep track of who and what is in your world, and publishing turns a finished story into a public reading page. -Each of those has its own help article. Look for the "?" next to a heading anywhere in the app to open the guide for what you are looking at. +Each of those has its own help article. Select the "?" at the top right of any page to open the guide for what you are looking at; select it again to go back to what you were doing. The "?" next to a heading opens the guide for that section. ## Deleting things diff --git a/src/lib/help.svelte.ts b/src/lib/help.svelte.ts index fec9a2d3..b63ceaf2 100644 --- a/src/lib/help.svelte.ts +++ b/src/lib/help.svelte.ts @@ -1,12 +1,21 @@ -// Shared state for the in-app help modal: which topic is open, if any. A -// HelpLink sets it; the single HelpModal mounted in the root layout reads it. +// The help pages are a place, not an overlay: the question mark in the app +// bar navigates to them, lights up while you are there, and takes you back to +// where you were when you press it again. This remembers that location for the +// length of the session. -export const help = $state<{ topic: string | null }>({ topic: null }); +const state = $state<{ from: string | null }>({ from: null }); -export function openHelp(topic: string): void { - help.topic = topic; +export function rememberLocation(url: string): void { + state.from = url; } -export function closeHelp(): void { - help.topic = null; +// Where the lit question mark leads. The fallback covers a reader who opened +// a help page directly, so there is nothing to go back to. +export function helpReturn(fallback: string): string { + return state.from ?? fallback; +} + +// The remembered location itself, or null when help was opened directly. +export function helpFrom(): string | null { + return state.from; } diff --git a/src/lib/server/publish.ts b/src/lib/server/publish.ts index bc3bc838..3fb01562 100644 --- a/src/lib/server/publish.ts +++ b/src/lib/server/publish.ts @@ -100,6 +100,26 @@ export async function publishStory( } } +// The story's public reading page, for the story menu in the app bar. Null +// unless the story has a current, non-removed edition under a handle, so the +// menu leaves the item out rather than offering a link that 404s. +export async function readingPageRef( + db: Database, + storyId: string +): Promise<{ handle: string; storyId: string } | null> { + const [row] = await db + .select({ handle: publications.handle, storyId: publications.storyId }) + .from(publications) + .where( + and( + eq(publications.storyId, storyId), + eq(publications.isCurrent, true), + isNull(publications.removedAt) + ) + ); + return row ?? null; +} + // The author shelf: current, non-removed editions of stories the author // has set public. Unlisted stories stay reachable by direct link only. export async function publicShelf(db: Database, handle: string) { diff --git a/src/lib/styles/chrome.css b/src/lib/styles/chrome.css new file mode 100644 index 00000000..6cca9b16 --- /dev/null +++ b/src/lib/styles/chrome.css @@ -0,0 +1,333 @@ +/* =========================================================================== + Codex chrome: one navigation bar for every page. + + Imported last from +layout.svelte, so it also beats the three bars it + replaces: the editor's .topbar, the pages' .page-shell .topbar + + .back-link, and the review surface's .review-guest-bar. Tokens only; no + new colours. + + THE RULE + Every page wears the same bar - brand, path, tools - in that order. The path + is Library / Universe / Story / Page: every crumb opens that place's home, + and the last crumb is the page you are on and does not link. A place in the + path is a menu: its own name opens everything that belongs to it, starting + with going there and including its settings. A page in the path is plain + text, because nothing belongs to it. There is no gear anywhere in the bar. + + THREE SHELLS + .appbar signed-in author. Brand links home, full path, five tools. + .appbar.guest invited reviewer. Brand is a lockup, path starts at the one + story they were sent, tools reduced to theme, help, who. + .appbar.reader public reading page. Brand links to Codex, path is the + story and its author as text, tools reduced to theme, help. + A shell may drop a tool. It never reorders one and never adds a bar. + =========================================================================== */ + +.appbar { + position: sticky; + top: 0; + z-index: 30; + flex: none; + display: flex; + align-items: center; + gap: var(--space-3); + height: 52px; + padding: 0 var(--space-4); + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); +} +/* The bar is one row: the path is the only part allowed to shrink. */ +.appbar > * { + flex: none; +} + +.appbar-brand { + display: flex; + align-items: center; + gap: 9px; + padding: 3px 5px 3px 3px; + margin-left: -3px; + border-radius: var(--radius-sm); + color: var(--text); + text-decoration: none; +} +a.appbar-brand:hover { + background: var(--bg-hover); +} +.appbar-brand:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.appbar-rule { + width: 1px; + height: 20px; + background: var(--border); +} + +/* ---- the path ---- */ +.path { + display: flex; + align-items: center; + gap: var(--space-1); + min-width: 0; + flex: 1 1 auto; + font-size: var(--text-base); +} +.path .crumb { + max-width: 22em; + overflow: hidden; + text-overflow: ellipsis; +} +.path a.crumb:focus-visible, +.path .crumb:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +/* Not a link, because it is where you already are. */ +.path .crumb.current { + cursor: default; +} +.path .crumb.current:hover { + background: none; +} +.crumb-sep { + display: grid; + place-items: center; + width: 12px; + height: 12px; + color: var(--text-faint); + flex: none; +} +.crumb-sep svg { + width: 12px; + height: 12px; +} +/* Every place in the path is a menu: the crumb itself is the button, and behind + it is everything that belongs to that place, starting with "go there". So the + name is one control with one meaning, settings is a word rather than a glyph, + and the bar carries no icon that needs a tooltip to say whose it is. */ +.crumb-place { + position: relative; + display: inline-flex; + min-width: 0; +} +.crumb-menu { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + cursor: pointer; +} +.crumb-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.crumb-menu svg { + width: 11px; + height: 11px; + flex: none; + color: var(--text-faint); + transition: transform 0.13s; +} +.crumb-menu:hover, +.crumb-menu.current:hover { + background: var(--bg-hover); + color: var(--text); +} +.crumb-menu[aria-expanded='true'] { + background: var(--bg-active); + color: var(--text); +} +.crumb-menu[aria-expanded='true'] svg { + transform: rotate(180deg); +} +.crumb-menu:hover svg, +.crumb-menu[aria-expanded='true'] svg { + color: var(--text-muted); +} +.crumb-pop { + position: absolute; + top: calc(100% + 7px); + left: 0; + min-width: 216px; + padding: 5px; + display: none; + z-index: 40; +} +.crumb-place.open .crumb-pop { + display: block; +} +.crumb-pop .menu-item { + width: 100%; + text-decoration: none; + border: 0; + background: none; + font-size: var(--text-base); +} +.crumb-pop .menu-item[aria-current='page'] { + background: var(--bg-active); + color: var(--text); + font-weight: 550; +} +.crumb-pop-sep { + height: 1px; + background: var(--border); + margin: 4px 7px; +} + +/* ---- the tools, always in this order ---- */ +.appbar-tools { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--space-2); +} +.tool-search { + display: inline-flex; + align-items: center; + gap: 7px; + height: 30px; + padding: 0 9px 0 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-inset); + color: var(--text-muted); + font-size: var(--text-sm); + text-decoration: none; + white-space: nowrap; +} +.tool-search kbd { + white-space: nowrap; +} +.tool-search svg { + width: 13px; + height: 13px; + color: var(--text-faint); +} +.tool-search:hover { + border-color: var(--border-strong); + color: var(--text); +} +.tool-search:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.appbar-tools .icon-btn svg { + width: 16px; + height: 16px; +} +.appbar-tools .icon-btn.help { + font-size: var(--text-lg); + font-weight: 650; +} +/* A tool that is a place, not a popup: it lights up while you are there. */ +.appbar-tools .icon-btn[aria-current='page'] { + background: var(--accent-soft); + color: var(--accent); +} +/* Who you are. In the guest shell this is who you are reviewing as. */ +.appbar-who { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: var(--text-sm); + color: var(--text-muted); +} + +/* ---- the mode strip ---- + Always the same four modes in the same order at the same size, wherever it + appears: with a story open, or with a universe open. A mode that needs a + story you have not opened yet still works - it opens the story list. A mode + your role does not include is disabled and the strip says so in one line, + under the strip, where it can be read without a pointer. */ +.mode-strip { + width: 100%; +} +a.seg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} +.seg-btn[aria-disabled='true'] { + opacity: 0.4; + cursor: not-allowed; +} +.mode-note { + margin: 7px 3px 0; + font-size: var(--text-meta); + line-height: 1.45; + color: var(--text-faint); + text-wrap: pretty; +} + +/* ---- the left pane's other job ---- + Where there is no mode - settings, insights, print, help - the same slot + holds that page's own section list. Same width, same place, same rhythm. */ +.side-nav { + display: flex; + flex-direction: column; + gap: 1px; + padding: var(--space-2) var(--space-2) var(--space-4); +} +.side-nav a { + display: flex; + align-items: center; + gap: 8px; + height: var(--row-h); + padding: 0 10px; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: var(--text-base); + text-decoration: none; +} +.side-nav a:hover { + background: var(--bg-hover); + color: var(--text); +} +.side-nav a[aria-current='page'] { + background: var(--bg-active); + color: var(--text); + font-weight: 550; + box-shadow: inset 2px 0 0 var(--accent); +} +.side-nav a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} +.side-nav-label { + padding: var(--space-4) 12px var(--space-1); + font-size: var(--text-micro); + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-faint); +} +.side-head { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 12px 10px; + border-bottom: 1px solid var(--border); +} +.side-head-title { + font-size: var(--text-base); + font-weight: 650; + line-height: 1.25; +} +.side-head-sub { + font-size: var(--text-meta); + color: var(--text-muted); + margin-top: 1px; +} + +/* Links in running text. Chrome controls are never running text, so this rule + is opt-in: it cannot reach the gear, the question mark, or the avatar. */ +.appbar a.text-link, +.page-body a.text-link { + color: var(--accent); +} +.appbar a.text-link:hover, +.page-body a.text-link:hover { + color: color-mix(in oklab, var(--accent) 82%, var(--text)); +} diff --git a/src/lib/styles/pages.css b/src/lib/styles/pages.css index f38228e4..047fcc62 100644 --- a/src/lib/styles/pages.css +++ b/src/lib/styles/pages.css @@ -2,8 +2,8 @@ Ported from the design prototype. Loaded globally after theme.css. Reconciled with the editor design system in theme.css: - - .topbar / .brand are scoped under .page-shell so the editor's own top bar - (inside .app) is untouched. + - the one navigation bar lives in chrome.css and serves every shell; this + file keeps the brand lockup it shares with the auth and landing pages. - the segmented control (.seg / .seg-btn) and the toggle (.toggle / .toggle-track) already live in theme.css; this file reuses them and only adds .toggle-row. */ @@ -15,27 +15,9 @@ background: var(--bg); } -/* ============ TOP BAR ============ */ -.page-shell .topbar { - display: flex; - align-items: center; - gap: 14px; - height: 52px; - padding: 0 16px; - flex: none; - border-bottom: 1px solid var(--border); - background: var(--bg-elevated); - position: sticky; - top: 0; - z-index: 20; -} -.page-shell .brand { - display: flex; - align-items: center; - gap: 9px; - text-decoration: none; - color: var(--text); -} +/* ============ BRAND LOCKUP ============ + The bar itself is .appbar in chrome.css; the mark and wordmark below are + shared with the auth and landing surfaces. */ .logo { width: 26px; height: 26px; @@ -55,59 +37,9 @@ font-size: 15px; letter-spacing: -0.01em; } -.divider { - width: 1px; - height: 22px; - background: var(--border); -} .spacer { flex: 1; } -.back-link, -.breadcrumb-current { - display: inline-flex; - align-items: center; - gap: 7px; - color: var(--text-muted); - text-decoration: none; - font-size: 13.5px; - padding: 5px 9px; - border-radius: 7px; -} -.back-link svg { - width: 12px; - height: 12px; -} -.back-link:hover, -.breadcrumb-current:hover { - background: var(--bg-hover); - color: var(--text); -} -.breadcrumb { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; -} -.breadcrumb-current { - font-weight: 550; - color: var(--text); -} -.save-status { - color: var(--text-faint); - font-size: 12.5px; - display: flex; - align-items: center; - gap: 6px; -} -.save-status::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 99px; - background: var(--accent); - opacity: 0.7; -} .avatar { width: 28px; height: 28px; @@ -537,17 +469,6 @@ gap: 9px; min-width: 0; } -.universe-edit { - display: grid; - place-items: center; - padding: 4px; - border-radius: 7px; - color: var(--text-faint); -} -.universe-edit:hover { - color: var(--text); - background: var(--bg-hover); -} .universe-name-link { text-decoration: none; } @@ -567,8 +488,14 @@ font-size: 13.5px; margin: 4px 0 0; } +/* The two destinations a universe row offers, spelled out in words: the same + pair the universe crumb's menu offers, in the same order. */ .universe-actions { flex: none; + display: flex; + align-items: center; + gap: var(--space-2); + padding-top: 2px; } .card-add { diff --git a/src/lib/styles/review.css b/src/lib/styles/review.css index 2cf7b62e..01d0e475 100644 --- a/src/lib/styles/review.css +++ b/src/lib/styles/review.css @@ -601,27 +601,6 @@ animation: popIn 0.12s ease; } -/* The guest header is a stripped topbar: story title and who is reviewing. */ -.review-guest-bar { - display: flex; - align-items: center; - gap: 12px; -} -.review-guest-bar .rg-title { - font-weight: 650; - font-size: 14px; -} -.review-guest-bar .rg-who { - font-size: 12.5px; - color: var(--text-muted); -} -.review-guest-bar .rg-title, -.review-guest-bar .rg-who { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - /* ---- responsive: the three panes collapse behind a tab bar on narrow screens ---- */ /* The shell wraps the tab bar and the three-pane grid. On desktop the tab bar is hidden and the grid fills the row exactly as before. */ diff --git a/src/lib/styles/theme.css b/src/lib/styles/theme.css index 61e9a8a9..1a8246ea 100644 --- a/src/lib/styles/theme.css +++ b/src/lib/styles/theme.css @@ -8,50 +8,21 @@ background: var(--bg); } -/* ---- Top bar ---- */ -.topbar { - display: flex; - align-items: center; - gap: 16px; - padding: 0 16px; - border-bottom: 1px solid var(--border); - background: var(--bg-elevated); -} +/* ---- Brand lockup ---- + The one navigation bar lives in chrome.css; what stays here is the lockup + the auth and landing surfaces share with it. */ .brand { display: flex; align-items: center; gap: 9px; } -.brand-mark { - width: 26px; - height: 26px; - border-radius: 7px; - background: linear-gradient(140deg, var(--accent), color-mix(in oklab, var(--accent) 55%, #000)); - display: grid; - place-items: center; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); -} -.brand-mark svg { - width: 15px; - height: 15px; -} .brand-name { font-weight: 700; font-size: 15px; letter-spacing: -0.01em; } -.crumbs { - display: flex; - align-items: center; - gap: 9px; - color: var(--text-muted); - font-size: 13.5px; - min-width: 0; -} -.crumbs .sep { - color: var(--text-faint); -} +/* A single crumb. The path that holds them is .path in chrome.css. */ .crumb { color: var(--text-muted); padding: 3px 7px; @@ -70,12 +41,6 @@ font-weight: 550; } -.topbar-right { - margin-left: auto; - display: flex; - align-items: center; - gap: 12px; -} .saved { color: var(--text-faint); font-size: 12.5px; @@ -1225,7 +1190,7 @@ .app.focus-mode { grid-template-rows: 1fr; } -.app.focus-mode .topbar { +.app.focus-mode .appbar { display: none; } .app.focus-mode .body { diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 16af7349..1fafeb2b 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -1,25 +1,25 @@ import type { ConcreteTheme } from './appearance'; -function concrete(value: string | null, fallback: ConcreteTheme): ConcreteTheme { - return value === 'light' || value === 'warm' || value === 'dark' ? value : fallback; +// The order the theme tool in the app bar walks through. Every shell (author, +// guest, reader) cycles the same three palettes from the same control. +export const THEME_CYCLE: ConcreteTheme[] = ['dark', 'light', 'warm']; + +export function currentTheme(): ConcreteTheme { + const value = document.documentElement.getAttribute('data-theme'); + return value === 'light' || value === 'warm' || value === 'dark' ? value : 'dark'; } -// Flips the document between its light and dark sides, resolving the concrete -// palette through the saved system mappings (so the light side can be Warm), -// mirrors it to localStorage so a reload keeps it, and (when the viewer is -// signed in) persists it to the account. Returns the brightness now in effect. -export function flipTheme(persist: boolean): 'light' | 'dark' { - const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; - const nextBrightness: 'light' | 'dark' = isDark ? 'light' : 'dark'; - let next: ConcreteTheme; - try { - next = - nextBrightness === 'dark' - ? concrete(localStorage.getItem('codex-system-dark'), 'dark') - : concrete(localStorage.getItem('codex-system-light'), 'light'); - } catch { - next = nextBrightness; - } +export function nextTheme(theme: ConcreteTheme): ConcreteTheme { + return THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length]; +} + +// Moves the document to the next palette in the cycle, mirrors it to +// localStorage so a reload keeps it, and (when the viewer is signed in) +// persists it to the account. Without the account write the layout re-applies +// the stored appearance on the next data refresh and snaps the theme back, so +// signed-in callers must pass true. Returns the palette now in effect. +export function cycleTheme(persist: boolean): ConcreteTheme { + const next = nextTheme(currentTheme()); document.documentElement.setAttribute('data-theme', next); try { localStorage.setItem('codex-theme', next); @@ -32,8 +32,8 @@ export function flipTheme(persist: boolean): 'light' | 'dark' { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ theme: next }) }).catch(() => { - /* the optimistic flip stands; it just will not survive a reload */ + /* the optimistic change stands; it just will not survive a reload */ }); } - return nextBrightness; + return next; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6c8b80aa..62d09959 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -12,12 +12,13 @@ import '$lib/styles/editor.css'; import '$lib/styles/review.css'; import '$lib/styles/menus.css'; - // Last, so the consolidated primitives win over any screen-local skin. + // Late, so the consolidated primitives win over any screen-local skin. import '$lib/styles/primitives.css'; + // Last of all: the one navigation bar, which beats the bars it replaces. + import '$lib/styles/chrome.css'; import favicon from '$lib/assets/favicon.svg'; import { browser } from '$app/environment'; import { applyAppearance } from '$lib/appearance-apply'; - import HelpModal from '$lib/components/HelpModal.svelte'; import CommandPalette from '$lib/components/CommandPalette.svelte'; import ActivityCenter from '$lib/components/ActivityCenter.svelte'; import type { Snippet } from 'svelte'; @@ -52,7 +53,6 @@ {@render children()} - {#if data.user} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a4d82454..cbc8c2dd 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,7 +2,8 @@ import { resolve } from '$app/paths'; import Icon from '$lib/components/Icon.svelte'; import Landing from '$lib/components/Landing.svelte'; - import PageTopBar from '$lib/components/PageTopBar.svelte'; + import AppBar from '$lib/components/AppBar.svelte'; + import { libraryCrumb } from '$lib/chrome'; import { formatNumber } from '$lib/format'; import type { ActionData, PageData } from './$types'; @@ -82,7 +83,7 @@ {#snippet library()}
- +
@@ -171,19 +172,27 @@ >

{universe.name}

- - -
{#if universe.descriptionMd}

{universe.descriptionMd}

{/if}
+
{#each universeStories(universe.id) as story (story.id)} diff --git a/src/routes/@[handle]/+page.svelte b/src/routes/@[handle]/+page.svelte index 7a208895..0d2e04a5 100644 --- a/src/routes/@[handle]/+page.svelte +++ b/src/routes/@[handle]/+page.svelte @@ -2,6 +2,8 @@ import { resolve } from '$app/paths'; import { entityColor } from '$lib/entity-color'; import { renderMarkdown } from '$lib/markdown'; + import AppBar from '$lib/components/AppBar.svelte'; + import { pageCrumb } from '$lib/chrome'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -34,6 +36,13 @@ {/if} + +
{#if profile}
diff --git a/src/routes/@[handle]/[story=uuid]/+page.svelte b/src/routes/@[handle]/[story=uuid]/+page.svelte index fefad2d3..ace5f689 100644 --- a/src/routes/@[handle]/[story=uuid]/+page.svelte +++ b/src/routes/@[handle]/[story=uuid]/+page.svelte @@ -1,6 +1,8 @@ {data.article.title} - Help - Codex -
- All help + +
Help
{@html renderMarkdown(data.article.body)}
- -
+ {#if next} + + {/if} + diff --git a/src/routes/review/[token]/+page.svelte b/src/routes/review/[token]/+page.svelte index 831b76e7..3d5d179f 100644 --- a/src/routes/review/[token]/+page.svelte +++ b/src/routes/review/[token]/+page.svelte @@ -1,6 +1,7 @@