Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 12 additions & 10 deletions e2e/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
});

Expand Down
70 changes: 46 additions & 24 deletions e2e/core-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 32 additions & 1 deletion e2e/docs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/');
});
22 changes: 19 additions & 3 deletions e2e/review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions scratch/design-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Loading