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
19 changes: 19 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ themes, DesignSync-ready), run the briefs in Claude Design, port the
results, then the code-side consolidation refactor the sheet's backlog
section lists.

Primitives consolidation (2026-07-29, branch
`feat/primitives-consolidation`). The fourth brief's result, ported.
New `src/lib/styles/primitives.css`, loaded last, owns the families that
had no single owner: the modal, the empty state, the chip remove
affordance, and the shared focus ring. `.btn` is now the complete family
(states plus `.btn-accept`), `.icon-btn` gains `.sm` and `.danger`,
`.seg` gains `.seg-count`, and `tokens.css` gains the seven-step type
ramp, `--danger-contrast`, and a per-theme `--select-caret`. Migrated
across the app: the `.rv-btn` family, `.rv-quick-btn`, `.rb-btn`,
`.mini-btn`, `.tool-btn`, `.send-btn` and `.pop-open` onto `.btn`/
`.icon-btn`; `.rtabs`, `.rv-filters`, `.rv-mtabs` and
`.revision-filter-chip` onto `.seg`; the three hand-rolled modals onto
one primitive; twelve empty-state spellings onto `.empty-state`. About
1,400 lines of CSS deleted, including the drift sheet's dead blocks
(`.ctx-menu`, `.settings-nav`, `.note-card`, `.icon-button`, `.kbd`).
Remaining from the sheet's backlog: the duplicated component blocks
(assistant chat, `.insp-*`, kanban), the residual bare `#fff`, and focus
reachability on the sidebar rows.

Dev environment automation (2026-07-28, branch
`feat/dev-env-automation`). Setting the project up on a fresh macOS
machine turned up three things CI never sees, all fixed here.
Expand Down
16 changes: 8 additions & 8 deletions e2e/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ test('assistant tab: gated by the account switch and muted per story', async ({
// With the account on, the Assistant tab shows; opening it reveals the chat.
// The click retries: right after the create-story navigation the page may
// not be hydrated yet, and a pre-hydration click goes nowhere.
const tab = page.locator('.rtab', { hasText: 'Assistant' });
const tab = page.locator('.right-head .seg-btn', { hasText: 'Assistant' });
await expect(tab).toBeVisible();
await expect(async () => {
await tab.click();
Expand Down Expand Up @@ -237,10 +237,10 @@ test('assistant tab: gated by the account switch and muted per story', async ({

// The command palette carries the Assistant's quick actions while it is on.
await page.keyboard.press('ControlOrMeta+k');
await expect(page.locator('.palette')).toBeVisible();
await expect(page.locator('.palette-item', { hasText: 'Catch me up' })).toBeVisible();
await expect(page.locator('.modal-panel')).toBeVisible();
await expect(page.locator('.modal-panel .menu-item', { hasText: 'Catch me up' })).toBeVisible();
await expect(
page.locator('.palette-item', { hasText: 'Review with the Assistant' })
page.locator('.modal-panel .menu-item', { hasText: 'Review with the Assistant' })
).toBeVisible();
await page.keyboard.press('Escape');

Expand All @@ -257,7 +257,7 @@ test('assistant tab: gated by the account switch and muted per story', async ({
await expect(status).toHaveText('Assistant off');
await gotoReady(page, storyUrl);
await expect(page.locator('.story-title')).toHaveText('Gatekeeper');
await expect(page.locator('.rtab', { hasText: 'Assistant' })).toHaveCount(0);
await expect(page.locator('.right-head .seg-btn', { hasText: 'Assistant' })).toHaveCount(0);

// And the menus carry no Assistant entries while it is off.
await expect(page.locator('.cm-content')).toBeVisible();
Expand All @@ -277,7 +277,7 @@ test('assistant tab: gated by the account switch and muted per story', async ({

// And the palette drops the Assistant commands.
await page.keyboard.press('ControlOrMeta+k');
await expect(page.locator('.palette')).toBeVisible();
await expect(page.locator('.palette-item', { hasText: 'Focus mode' })).toBeVisible();
await expect(page.locator('.palette-item', { hasText: 'Catch me up' })).toHaveCount(0);
await expect(page.locator('.modal-panel')).toBeVisible();
await expect(page.locator('.modal-panel .menu-item', { hasText: 'Focus mode' })).toBeVisible();
await expect(page.locator('.modal-panel .menu-item', { hasText: 'Catch me up' })).toHaveCount(0);
});
7 changes: 5 additions & 2 deletions e2e/core-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,10 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
// Alice joined the Factions category above; the kind line carries it.
await expect(page.locator('.entity-card .pop-role')).toHaveText('Character · Factions');
await expect(page.locator('.entity-card .pop-summary')).toHaveText('A toll-road smuggler.');
await expect(page.locator('.entity-card .pop-open')).toHaveAttribute('href', /\/plan\?entity=/);
await expect(page.locator('.entity-card .btn-primary')).toHaveAttribute(
'href',
/\/plan\?entity=/
);
await fenwickSave;

// The worker indexes the mention asynchronously; once it has, the scene's
Expand Down Expand Up @@ -609,7 +612,7 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows
const relDelete = page.waitForResponse(
(r) => r.url().includes('/api/relationships/') && r.request().method() === 'DELETE' && r.ok()
);
await page.locator('.rel-remove').click();
await page.locator('.rel-row .icon-btn').click();
await relDelete;
await expect(page.locator('.rel-row')).toHaveCount(0);

Expand Down
4 changes: 2 additions & 2 deletions e2e/entity-card.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ test('entity card: open from a mention, then back to the tabs', async ({ page })
await expect(card.locator('.insp-name')).toHaveText('Veylan');
await expect(card.locator('.insp-open')).toBeVisible();
// The tabs are replaced while the card is open.
await expect(page.locator('.rtabs')).toHaveCount(0);
await expect(page.locator('.right-head .seg')).toHaveCount(0);

// Back returns to the tabbed panel.
await card.locator('.back-btn').click();
await expect(card).toHaveCount(0);
await expect(page.locator('.rtabs')).toBeVisible();
await expect(page.locator('.right-head .seg')).toBeVisible();
});
4 changes: 2 additions & 2 deletions e2e/find-and-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ test('find in the editor and search the prose from the palette', async ({ page }
// The palette finds the prose and lands on the scene.
await page.keyboard.press('ControlOrMeta+k');
await page.getByPlaceholder('Search, or type a command...').fill(`pin${stamp}`);
const passage = page.locator('.palette-item', { hasText: 'In the text' });
const passage = page.locator('.modal-panel .menu-item', { hasText: 'In the text' });
await expect(passage).toHaveCount(1);
await expect(passage).toContainText(`pin${stamp} near the well`);
await passage.click();
Expand All @@ -76,7 +76,7 @@ test('find in the editor and search the prose from the palette', async ({ page }

await page.keyboard.press('ControlOrMeta+k');
await page.getByPlaceholder('Search, or type a command...').fill(`pin${stamp}`);
const jump = page.locator('.palette-item', { hasText: 'In the text' });
const jump = page.locator('.modal-panel .menu-item', { hasText: 'In the text' });
await expect(jump).toHaveCount(1);
await jump.click();
await expect(page.locator('.cm-content')).toContainText(`pin${stamp}`);
Expand Down
2 changes: 1 addition & 1 deletion e2e/review-entity-card.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ test('review: hovering an entity mention opens its quick card', async ({ page })
const card = page.locator('.entity-card');
await expect(card).toBeVisible();
await expect(card.locator('.pop-name')).toHaveText('Veylan');
await expect(card.locator('.pop-open')).toBeVisible();
await expect(card.locator('.btn-primary')).toBeVisible();

// Moving the pointer away dismisses it.
await page.mouse.move(2, 2);
Expand Down
2 changes: 1 addition & 1 deletion e2e/review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ test('guest review: invite, comment as a guest, reply and resolve as the author'

// Resolve the thread, then the Done filter shows both outcomes.
await page.getByRole('button', { name: 'Resolve comment' }).click();
await page.locator('.rv-filter', { hasText: 'Done' }).click();
await page.locator('.rv-panel-head .seg-btn', { hasText: 'Done' }).click();
await expect(page.locator('.rv-status.resolved')).toBeVisible();
await expect(page.locator('.rv-status.accepted')).toBeVisible();

Expand Down
2 changes: 1 addition & 1 deletion e2e/scene-board.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ test('scene board: a card moves along the status ladder and stays there', async
await expect(page.locator('.ent-row')).toHaveCount(1);
await expect(page.locator('.ent-row .name')).toHaveText('Ferry');
await page.getByLabel('Filter characters, places, lore...').fill('zzz-nobody');
await expect(page.locator('.search-empty')).toBeVisible();
await expect(page.locator('.empty-state-text', { hasText: 'Nothing matches.' })).toBeVisible();
await page.getByRole('button', { name: 'Clear' }).click();

// The universe plan shows the story board: this story sits in the lane
Expand Down
4 changes: 3 additions & 1 deletion e2e/scene-title.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ test('opening a story resumes the last-edited scene', async ({ page }) => {
await expect(page.locator('.scene-row')).toHaveCount(1);
await expect(page.locator('.scene-row .scene-name')).toHaveText('Second thoughts');
await page.getByLabel('Filter chapters and scenes...').fill('zzz-no-such-scene');
await expect(page.locator('.search-empty')).toBeVisible();
await expect(
page.locator('.empty-state-text', { hasText: 'No chapters or scenes match.' })
).toBeVisible();
await page.getByRole('button', { name: 'Clear' }).click();
await expect(page.locator('.scene-row')).toHaveCount(2);
});
2 changes: 1 addition & 1 deletion e2e/universe-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ test('universe settings: contents, categories, history, export, and the trash',
await page.getByRole('link', { name: 'History' }).click();
const entry = page.locator('.revision-entry', { hasText: 'Histor' }).first();
await expect(entry).toBeVisible();
await expect(entry.locator('.revision-source-kind')).toHaveText('Character');
await expect(entry.locator('.pill')).toHaveText('Character');
await page.getByRole('button', { name: 'Checkpoints only' }).click();
await expect(page.locator('.revision-entry', { hasText: 'Histor' })).toHaveCount(0);
await page.getByRole('button', { name: 'All', exact: true }).click();
Expand Down
2 changes: 1 addition & 1 deletion scratch/design-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ repo to project; never edit the system inside the project.

Foundations: `colors.html`, `type.html`, `spacing.html`.
Components: `buttons.html`, `forms.html`, `menus.html`, `badges.html`,
`seg.html`, `cards.html`, `empty-states.html`.
`seg.html`, `cards.html`, `empty-states.html`, `modal.html`.
44 changes: 42 additions & 2 deletions scratch/design-kit/buttons.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
<button class="btn btn-secondary" type="button">Secondary</button>
<button class="btn btn-ghost" type="button">Ghost</button>
<button class="btn btn-danger" type="button">Delete</button>
<button class="btn btn-accept" type="button">Accept</button>
</div>
<div class="row">
<button class="btn btn-primary btn-sm" type="button">Small primary</button>
<button class="btn btn-secondary btn-sm" type="button">Small secondary</button>
<button class="btn btn-accept btn-sm" type="button">Accept all</button>
<button class="btn btn-secondary" type="button" disabled>Disabled</button>
</div>
<div class="row">
Expand Down Expand Up @@ -49,11 +51,49 @@
<path d="m21 21-4.3-4.3" />
</svg>
</button>
<button class="icon-btn sm" type="button" title="Add" aria-label="Add">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button class="icon-btn sm danger" type="button" title="Delete" aria-label="Delete">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13" />
</svg>
</button>
<button class="icon-btn sm" type="button" title="Add" aria-label="Add" disabled>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<p class="note">
The only general button system. One primary action per view; ghost for quiet actions
inside panels; danger only for destructive actions. Icon-only buttons use icon-btn with an
aria-label. Do not invent a new button skin.
inside panels; danger only for destructive actions; accept for the affirmative decision.
Icon-only buttons use icon-btn with an aria-label, at 32px in chrome and 28px (icon-btn
sm) inside cards, panel heads and modal heads. Disabled buttons drop to 0.45 opacity and
do not light up on hover. Do not invent a new button skin.
</p>
</template>
<script src="kit.js"></script>
Expand Down
34 changes: 29 additions & 5 deletions scratch/design-kit/empty-states.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<title>Empty states</title>
<link rel="stylesheet" href="styles/tokens.css" />
<link rel="stylesheet" href="styles/theme.css" />
<link rel="stylesheet" href="styles/pages.css" />
<link rel="stylesheet" href="styles/primitives.css" />
<link rel="stylesheet" href="kit.css" />
<style>
.pane .kit-frame {
Expand All @@ -19,15 +21,37 @@
<div class="themes" id="themes"></div>
<template id="sample">
<div class="kit-frame">
<div class="empty">Select a scene to start writing.</div>
<div class="empty-state">
<div class="empty-state-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 5h16v14H4z" />
<path d="M8 9h8M8 13h5" />
</svg>
</div>
<p class="empty-state-text">Select a scene to start writing.</p>
<div class="empty-state-action">
<button class="btn btn-sm btn-secondary" type="button">New scene</button>
</div>
</div>
</div>
<div class="kit-frame">
<div class="empty">No mentions in this scene yet.</div>
<div class="empty-state tight">
<p class="empty-state-text">No mentions in this scene yet.</p>
</div>
</div>
<p class="note">
One empty-state primitive: centred, faint, thirteen pixels. The text is one short
sentence telling the user what to do next, never why the area exists. The dashed frame
here is scaffolding, not part of the primitive.
One empty-state primitive: a centred column, faint, with one short sentence telling the
user what to do next, never why the area exists. The icon and the action are optional;
the sentence is not. Use the tight variant inside a right-panel card or a sidebar list,
where the full padding would push the panel into a scroll. The dashed frame here is
scaffolding, not part of the primitive.
</p>
</template>
<script src="kit.js"></script>
Expand Down
100 changes: 100 additions & 0 deletions scratch/design-kit/modal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<!-- @dsCard group="Components" -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Modal</title>
<link rel="stylesheet" href="styles/tokens.css" />
<link rel="stylesheet" href="styles/theme.css" />
<link rel="stylesheet" href="styles/pages.css" />
<link rel="stylesheet" href="styles/menus.css" />
<link rel="stylesheet" href="styles/primitives.css" />
<link rel="stylesheet" href="kit.css" />
<style>
/* The real backdrop is fixed to the viewport; the card pins it in flow so
three themes can sit side by side. Everything else is the primitive. */
.pane .modal-backdrop {
position: relative;
inset: auto;
z-index: auto;
padding: var(--space-5);
border-radius: var(--radius);
border: 1px dashed var(--border-strong);
margin-bottom: 14px;
}
.pane .modal-panel {
animation: none;
}
</style>
</head>
<body>
<div class="themes" id="themes"></div>
<template id="sample">
<div class="kit-h">Default (420px)</div>
<div class="modal-backdrop">
<div class="modal-panel">
<div class="modal-head">
<div class="modal-head-main">
<h2 class="modal-title">Delete this scene?</h2>
<p class="modal-sub">
The scene and its review notes go with it. This cannot be undone.
</p>
</div>
<button class="icon-btn sm" type="button" title="Close" aria-label="Close">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
<div class="modal-foot">
<div class="modal-foot-note">Esc to close</div>
<button class="btn btn-sm btn-secondary" type="button">Cancel</button>
<button class="btn btn-sm btn-danger" type="button">Delete</button>
</div>
</div>
</div>

<div class="kit-h">Wide (modal-lg), search head and rows</div>
<div class="modal-backdrop">
<div class="modal-panel modal-lg">
<div class="modal-head searching">
<input
class="modal-search"
type="text"
placeholder="Search, or type a command..."
aria-label="Search everything"
/>
</div>
<div class="modal-body rows">
<div class="modal-label">Commands</div>
<button class="menu-item active" type="button">Start a review</button>
<button class="menu-item" type="button">New scene</button>
<button class="menu-item" type="button">Export the story</button>
</div>
<div class="modal-foot">
<div class="modal-foot-note">
<span><kbd>Enter</kbd> to open</span>
<span><kbd>Esc</kbd> to close</span>
</div>
</div>
</div>
</div>
<p class="note">
One overlay primitive, two sizes and no third: 420px by default, 640px for a list to scan
or a passage to read. The backdrop is drawn from the page background, so each theme dims
in its own key. The head carries a title or a search field, the close is an icon-btn sm,
and the primary action sits last in the footer. Esc closes, focus is trapped in the panel
and returned to the opener, and a backdrop click closes only when nothing is unsaved:
that behaviour is the component's, not the CSS's.
</p>
</template>
<script src="kit.js"></script>
</body>
</html>
Loading