diff --git a/app/bundle-budget.json b/app/bundle-budget.json
index 7a64097b..45b6ba25 100644
--- a/app/bundle-budget.json
+++ b/app/bundle-budget.json
@@ -1,5 +1,5 @@
{
- "startup": { "jsKb": 180, "cssKb": 15 },
+ "startup": { "jsKb": 185, "cssKb": 18 },
"after": { "within": "features/page/", "entry": "features/page/PageBody" },
"lazyKb": {
"editor/markdown/worker": 45,
diff --git a/app/scripts/build-tokens.test.ts b/app/scripts/build-tokens.test.ts
index 513eb03e..c465a1fe 100644
--- a/app/scripts/build-tokens.test.ts
+++ b/app/scripts/build-tokens.test.ts
@@ -69,6 +69,27 @@ describe('buildCss', () => {
expect(css).toContain('[data-theme-scope="light"],\n:root[data-page-color="paper"] [data-region="page"] {');
});
+ it('writes the warm accents and the ambient sky in both themes and in the scoped blocks', () => {
+ expect(blockOf(':root')).toContain('--color-accent-candle: #9A5410;');
+ expect(blockOf(':root')).toContain('--color-accent-night-subtle: #E8EAF3;');
+ expect(blockOf(':root')).toContain('--color-ambient-canvas-top: #FFF0D4;');
+ expect(blockOf(':root[data-theme="dark"]')).toContain('--color-accent-dusk: #EBA0B2;');
+ expect(blockOf(':root[data-theme="dark"]')).toContain('--color-ambient-canvas-bottom: #33243A;');
+ expect(blockOf('[data-theme-scope="dark"]')).toContain('--color-ambient-spark: #E7D9B0;');
+ expect(css).toContain('[data-theme-scope="light"],\n:root[data-page-color="paper"] [data-region="page"] {');
+ });
+
+ it('flattens the ambient wash and the stars to Canvas under a Windows contrast theme', () => {
+ const forced = css.slice(css.indexOf('@media (forced-colors: active)'));
+ ['canvas-top', 'canvas-bottom', 'glow', 'spark'].forEach((name) =>
+ expect(forced).toContain(`--color-ambient-${name}: Canvas;`),
+ );
+ ['candle', 'dusk', 'night'].forEach((name) => {
+ expect(forced).toContain(`--color-accent-${name}: CanvasText;`);
+ expect(forced).toContain(`--color-accent-${name}-subtle: Canvas;`);
+ });
+ });
+
it('reduces motion for the in-app setting as for the system one', () => {
expect(blockOf(':root[data-motion="reduce"]')).toContain('--motion-duration-base: 100ms;');
expect(blockOf(':root[data-motion="reduce"]')).toContain('--motion-duration-spring: 100ms;');
diff --git a/app/scripts/layer-order.test.ts b/app/scripts/layer-order.test.ts
new file mode 100644
index 00000000..8fb0293a
--- /dev/null
+++ b/app/scripts/layer-order.test.ts
@@ -0,0 +1,32 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import { LAYERS_CSS, layerOrderStatement, withLayerOrder } from './layer-order.ts';
+
+describe('layerOrderStatement', () => {
+ it('reads the order from styles/layers.css', () => {
+ expect(layerOrderStatement(readFileSync(LAYERS_CSS, 'utf8'))).toBe('@layer tokens,base,components,states,forced;');
+ });
+
+ it('skips a block @layer and finds the order statement', () => {
+ const css = '@layer base { a { color: red; } }\n@layer one,\n two;';
+ expect(layerOrderStatement(css)).toBe('@layer one,two;');
+ });
+
+ it('throws when the file has no order statement, so the build fails instead of shipping the wrong order', () => {
+ expect(() => layerOrderStatement('@layer base { a { color: red; } }')).toThrow(/no @layer order/);
+ });
+});
+
+describe('withLayerOrder', () => {
+ const statement = '@layer tokens,base,components,states,forced;';
+
+ it('puts the order in front of a sheet that opens with a later layer', () => {
+ const css = '@layer components{._row_1{outline-offset:-2px}}';
+ expect(withLayerOrder(css, statement)).toBe(`${statement}${css}`);
+ });
+
+ it('leaves a sheet that already starts with the order as it is', () => {
+ const css = `${statement}@layer base{a{color:red}}`;
+ expect(withLayerOrder(css, statement)).toBe(css);
+ });
+});
diff --git a/app/scripts/layer-order.ts b/app/scripts/layer-order.ts
new file mode 100644
index 00000000..3f6add85
--- /dev/null
+++ b/app/scripts/layer-order.ts
@@ -0,0 +1,43 @@
+// The app's style sheets rely on one cascade layer order (styles/layers.css, ARCHITECTURE.md section 4.6). A browser
+// fixes that order the first time it meets each layer name. A build splits the CSS into one file per chunk. It links
+// the shared chunks' files before the entry's own, so a sheet that opens with @layer components loaded before
+// layers.css. Base rules then beat components, states, and forced colors.
+//
+// This build plugin starts every emitted style sheet with the order statement from layers.css. The order is then
+// the same whichever file loads first, including sheets that lazy chunks add later. Repeating the statement once
+// the order is set changes nothing.
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import type { Plugin } from 'vite';
+
+/** The style sheet that declares the layer order. */
+export const LAYERS_CSS = join(import.meta.dirname, '..', 'src', 'styles', 'layers.css');
+
+/** The `@layer a, b, c;` order statement in `css`, on one line. Throws when there is none. */
+export function layerOrderStatement(css: string): string {
+ const match = /@layer\s+[\w-]+(?:\s*,\s*[\w-]+)+\s*;/.exec(css);
+ if (!match) throw new Error('styles/layers.css has no @layer order statement.');
+ return match[0].replace(/\s*,\s*/g, ',').replace(/\s+/g, ' ');
+}
+
+/** `css` with the order statement in front, unless it already starts with it. */
+export function withLayerOrder(css: string, statement: string): string {
+ return css.startsWith(statement) ? css : `${statement}${css}`;
+}
+
+export function layerOrder(source = LAYERS_CSS): Plugin {
+ return {
+ name: 'opennote:layer-order',
+ apply: 'build',
+ enforce: 'post',
+ generateBundle(_options, bundle) {
+ const statement = layerOrderStatement(readFileSync(source, 'utf8'));
+ for (const file of Object.values(bundle)) {
+ if (file.type !== 'asset' || !file.fileName.endsWith('.css')) continue;
+ const css = typeof file.source === 'string' ? file.source : new TextDecoder().decode(file.source);
+ file.source = withLayerOrder(css, statement);
+ }
+ },
+ };
+}
diff --git a/app/src/commands/dispatcher.scopes.test.tsx b/app/src/commands/dispatcher.scopes.test.tsx
index 0f17f1c4..4129a5ac 100644
--- a/app/src/commands/dispatcher.scopes.test.tsx
+++ b/app/src/commands/dispatcher.scopes.test.tsx
@@ -6,8 +6,8 @@ import { createMemoryNotesService } from '../services/notes/memory';
import { pushLayer } from '../state/layers';
import { resetStores } from '../state/store';
import { createTestPlatform } from '../test/platform';
-import { activeScopes, commandForKey, installDispatcher, reachesCommandsInText } from './dispatcher';
-import { chord, configureCommands, defineCommand } from './registry';
+import { activeScopes, commandForKey, installDispatcher, layerScopes, reachesCommandsInText } from './dispatcher';
+import { chord, configureCommands, defineCommand, focusZone } from './registry';
import type { CommandDef } from './types';
const stops: (() => void)[] = [];
@@ -124,6 +124,25 @@ describe('text fields, dialogs, repeats, and flags', () => {
expect(run(document.body, 'n', 'KeyN', 'ctrl alt')).toBe('test.newPage');
});
+ it('run the commands of the scopes a modal layer holds, such as the tree in the notebooks drawer', () => {
+ add('rename', { keys: [chord('Ctrl+Alt+E')], scope: 'tree' });
+ add('newPage', { keys: [chord('Ctrl+Alt+N')], scope: 'workspace' });
+ const pop = pushLayer({ id: 'drawer', kind: 'drawer', modal: true, close() {} });
+ const row = element(
+ '
' +
+ '
',
+ '#row',
+ );
+ expect([...layerScopes(row)].sort()).toEqual(['notebooksTree', 'tree', 'workspace']);
+ expect(focusZone(row)).toBe('notebooks');
+ expect(run(row, 'e', 'KeyE', 'ctrl alt')).toBe('test.rename');
+ expect(run(row, 'n', 'KeyN', 'ctrl alt')).toBe('test.newPage');
+ // A global command still needs to allow the modal layer.
+ add('global', { keys: [chord('Ctrl+Alt+G')] });
+ expect(run(row, 'g', 'KeyG', 'ctrl alt')).toBeNull();
+ pop();
+ });
+
it('repeat only commands that allow it, skip flags that are off, and leave key capture alone', () => {
add('toggle', { keys: [chord('Ctrl+Shift+D')] });
add('move', { keys: [chord('Ctrl+Shift+Up')], allowRepeat: true });
diff --git a/app/src/commands/dispatcher.ts b/app/src/commands/dispatcher.ts
index 4395bbc8..af64ea36 100644
--- a/app/src/commands/dispatcher.ts
+++ b/app/src/commands/dispatcher.ts
@@ -4,7 +4,8 @@
// 2. The active scopes come from the focused element's data-scope attributes and the view.
// 3. The most specific command wins. One whose `when` or `enabled` is false lets the key go on.
// 4. Text fields take only chords with Ctrl or Alt, or function keys, and keep their own editing chords.
-// 5. While a modal layer is open, only commands that allow it run, and those of the layer's own scope.
+// 5. While a modal layer is open, only commands that allow it run, those of a modal scope, and those of a scope the
+// layer holds itself, such as the tree inside the notebooks drawer.
// 6. Held keys repeat only commands that allow it. The default is prevented only when a command runs.
import { isEnabled } from '../app/flags';
@@ -12,7 +13,7 @@ import { getLocation } from '../app/location';
import { topLayer } from '../state/layers';
import { chordMatches, keyKind, loadKeyboardLayout, parseChord, primaryChord } from './chords';
import type { KeyInput } from './chords';
-import { SCOPE_PARENT, commandsForChord, scopeDepth } from './keymap';
+import { SCOPE_PARENT, commandsForChord, scopeChain, scopeDepth } from './keymap';
import type { AnyCommand } from './keymap';
import { commandContext, executeCommand } from './registry';
import type { Chord, KeyScope } from './types';
@@ -47,19 +48,39 @@ export function reachesCommandsInText(chord: Chord): boolean {
const VIEWS_WITH_WORKSPACE = new Set(['workspace', 'trash']);
const MODAL_SCOPES = new Set(['palette', 'dialog']);
-/** The scopes active for a focused element: global, the workspace while it shows, and every data-scope around it. */
-export function activeScopes(element: Element | null, view: string = getLocation().view): Set {
- const scopes = new Set(['global']);
- if (VIEWS_WITH_WORKSPACE.has(view)) scopes.add('workspace');
+const DIALOG = '[role="dialog"], dialog';
+
+/** The scopes the data-scope attributes on and around an element name, up to the edge of `within` if given. */
+function scopesAround(element: Element | null, within?: Element): KeyScope[] {
+ const found: KeyScope[] = [];
for (let at = element?.closest('[data-scope]'); at; at = at.parentElement?.closest('[data-scope]')) {
+ if (within && !within.contains(at)) break;
for (const token of (at.getAttribute('data-scope') ?? '').split(/\s+/)) {
- if (token in SCOPE_PARENT) scopes.add(token as KeyScope);
+ if (token in SCOPE_PARENT) found.push(token as KeyScope);
}
}
- if (!scopes.has('palette') && element?.closest('[role="dialog"], dialog')) scopes.add('dialog');
+ return found;
+}
+
+/** The scopes active for a focused element: global, the workspace while it shows, and every data-scope around it. */
+export function activeScopes(element: Element | null, view: string = getLocation().view): Set {
+ const scopes = new Set(['global', ...scopesAround(element)]);
+ if (VIEWS_WITH_WORKSPACE.has(view)) scopes.add('workspace');
+ if (!scopes.has('palette') && element?.closest(DIALOG)) scopes.add('dialog');
return scopes;
}
+/**
+ * The scopes the dialog around a focused element holds itself, with the scopes they sit in short of global. The
+ * notebooks drawer is a modal dialog with the notebooks tree inside, so the tree's commands and the workspace's,
+ * which the tree's menus list with their shortcuts, still run there.
+ */
+export function layerScopes(element: Element | null): Set {
+ const layer = element?.closest(DIALOG);
+ const held = layer ? scopesAround(element, layer) : [];
+ return new Set(held.flatMap(scopeChain).filter((scope) => scope !== 'global'));
+}
+
function bySpecificity(a: AnyCommand, b: AnyCommand): number {
const depth = scopeDepth(b.scope) - scopeDepth(a.scope);
if (depth !== 0) return depth;
@@ -71,10 +92,11 @@ interface Press {
readonly repeat: boolean;
}
-function allowed(def: AnyCommand, press: Press, textInput: boolean): boolean {
+function allowed(def: AnyCommand, press: Press, textInput: boolean, held: Set): boolean {
if (press.repeat && !def.allowRepeat) return false;
if (textInput && !def.allowInTextInput) return false;
- if (topLayer()?.modal && !def.allowInModal && !MODAL_SCOPES.has(def.scope ?? 'global')) return false;
+ const scope = def.scope ?? 'global';
+ if (topLayer()?.modal && !def.allowInModal && !MODAL_SCOPES.has(scope) && !held.has(scope)) return false;
const ctx = commandContext('keyboard');
return (!def.when || def.when(ctx)) && (!def.enabled || def.enabled(ctx));
}
@@ -84,13 +106,14 @@ export function commandForKey(event: KeyInput & Press): AnyCommand | null {
const { target } = event;
if (target?.closest('[data-key-capture]')) return null;
const scopes = activeScopes(target);
+ const held = layerScopes(target);
const textInput = isTextInput(target);
for (const { chord } of chordMatches(event)) {
if (textInput && !reachesCommandsInText(chord)) continue;
const candidates = commandsForChord(chord)
.filter((def) => (!def.flag || isEnabled(def.flag)) && scopes.has(def.scope ?? 'global'))
.sort(bySpecificity);
- const def = candidates.find((candidate) => allowed(candidate, event, textInput));
+ const def = candidates.find((candidate) => allowed(candidate, event, textInput, held));
if (def) return def;
}
return null;
diff --git a/app/src/commands/registry.ts b/app/src/commands/registry.ts
index 89023619..aea26ee4 100644
--- a/app/src/commands/registry.ts
+++ b/app/src/commands/registry.ts
@@ -40,8 +40,11 @@ export function focusZone(element: Element | null = document.activeElement): Foc
if (!element || element === document.body) return 'none';
if (element.matches('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) return 'textInput';
if (element.closest('[data-scope~="palette"]')) return 'palette';
- if (element.closest('[role="dialog"], dialog')) return 'dialog';
- const region = element.closest('[data-region]')?.getAttribute('data-region') as FocusZone | undefined;
+ const dialog = element.closest('[role="dialog"], dialog');
+ const regionElement = element.closest('[data-region]');
+ // A region a dialog holds, such as the notebooks in the drawer, is still that region.
+ if (dialog && !(regionElement && dialog.contains(regionElement))) return 'dialog';
+ const region = regionElement?.getAttribute('data-region') as FocusZone | undefined;
return region && REGIONS.includes(region) ? region : 'none';
}
diff --git a/app/src/features/page/EmptyPageArt.tsx b/app/src/features/page/EmptyPageArt.tsx
deleted file mode 100644
index cd704132..00000000
--- a/app/src/features/page/EmptyPageArt.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-// The empty page's illustration (BRAND.md section 8): a window at dusk with a crescent moon and stars, a candle
-// and a potted plant on the sill, and a vine along the frame. Hand-drawn strokes in three token colors, with plenty
-// of empty paper. It is decorative, so screen readers skip it; the text beside it says what to do.
-import styles from './PageView.module.css';
-
-export function EmptyPageArt() {
- return (
-
- );
-}
diff --git a/app/src/features/page/PageView.module.css b/app/src/features/page/PageView.module.css
index e3912b9c..0505ff2f 100644
--- a/app/src/features/page/PageView.module.css
+++ b/app/src/features/page/PageView.module.css
@@ -48,33 +48,19 @@
text-align: center;
}
- .art {
- inline-size: min(12rem, 60%);
- block-size: auto;
- fill: none;
- stroke-width: 1.5;
- stroke-linecap: round;
- stroke-linejoin: round;
+ /* The drawing above the "no page" sentence: a little company, with the sentence below it keeping the meaning. */
+ .page .art {
+ margin-block-start: var(--space-5);
}
- .artFrame {
- stroke: var(--color-border-control);
- }
-
- .artSky {
- stroke: var(--color-status-warning);
- }
-
- .artCandle {
- stroke: var(--color-accent-clay);
- }
-
- .artPlant {
- stroke: var(--color-accent-primary);
+ .page .art + .note {
+ margin-block-start: var(--space-4);
}
/* With the editor on, the page fills the region and its viewport scrolls (ARCHITECTURE.md section 5.1). */
.editing {
+ /* The sheet stays opaque paper over the ambient canvas (BRAND.md section 7). */
+ background: var(--color-surface-page);
display: flex;
flex-direction: column;
block-size: 100%;
@@ -94,9 +80,5 @@
.title::after {
background: CanvasText;
}
-
- .art g {
- stroke: CanvasText;
- }
}
}
diff --git a/app/src/features/page/PageView.test.tsx b/app/src/features/page/PageView.test.tsx
index 05bd6ca4..75ae63fb 100644
--- a/app/src/features/page/PageView.test.tsx
+++ b/app/src/features/page/PageView.test.tsx
@@ -13,6 +13,15 @@ describe('the page view', () => {
expect(screen.getByText('Choose a page to see it here.')).toBeTruthy();
});
+ it('puts a small drawing above the prompt, hidden from screen readers, and none on an open page', async () => {
+ const { container } = await renderApp();
+ const prompt = await screen.findByText('Choose a page to see it here.');
+ const art = container.querySelector('article svg[viewBox="0 0 110 54"]');
+ expect(art?.getAttribute('aria-hidden')).toBe('true');
+ expect(art?.getAttribute('focusable')).toBe('false');
+ expect((art as Node).compareDocumentPosition(prompt) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
it('shows the open page as a heading that Enter in the tree moves focus to', async () => {
await renderApp();
const notebooks = await screen.findByRole('tree', { name: 'Notebooks' });
diff --git a/app/src/features/page/PageView.tsx b/app/src/features/page/PageView.tsx
index 748f7715..4d744167 100644
--- a/app/src/features/page/PageView.tsx
+++ b/app/src/features/page/PageView.tsx
@@ -11,9 +11,8 @@ import type { NodeId, NodeSummary } from '../../services/notes';
import { registerRegionMain } from '../../shell/regions';
import { formatDate } from '../../strings/format';
import { t } from '../../strings/t';
-import { ProgressBar, useDelayedFlag } from '../../ui';
+import { EmptyArt, ProgressBar, useDelayedFlag } from '../../ui';
import { titleOf, useTreeNode } from '../tree';
-import { EmptyPageArt } from './EmptyPageArt';
import { TitleSlot, useTitleBand } from './title/TitleSlot';
import styles from './PageView.module.css';
import { usePageZoom } from './zoom';
@@ -80,7 +79,7 @@ export function PageView() {
{page && !editing &&
{t('tree.page.empty')}
}
{!page && !loading && (
-
+
{t('tree.page.none')}
)}
diff --git a/app/src/features/palette/CommandPalette.module.css b/app/src/features/palette/CommandPalette.module.css
index 456d1b20..2e38d27f 100644
--- a/app/src/features/palette/CommandPalette.module.css
+++ b/app/src/features/palette/CommandPalette.module.css
@@ -48,14 +48,29 @@
font-weight: var(--font-weight-semibold);
}
+ /* In a short window the list stops 16 px above the window's bottom edge, so the palette keeps its corners and
+ shadow. Below the palette's top line it leaves 174 px: the dialog layer's padding 16, the dialog's padding and
+ border 50, the input, chips and their gaps 92, and 16 px under the palette. */
.list {
display: grid;
gap: var(--space-3);
- max-block-size: 60vh;
+ max-block-size: min(60vh, calc(100dvh - var(--size-palette-top) - 174px));
overflow-y: auto;
overscroll-behavior: contain;
}
+ /* An empty list takes no room, so the line that says nothing matched sits where the first result would. */
+ .list:empty {
+ display: none;
+ }
+
+ /* Padded like an option, so its text starts where the results' titles do. */
+ .empty {
+ margin: 0;
+ padding: var(--space-2) var(--space-4);
+ color: var(--color-text-secondary);
+ }
+
.group {
display: grid;
}
@@ -104,6 +119,26 @@
overflow-wrap: anywhere;
}
+ /* The notebook's color, as a small dot before a page or notebook. The title says it all; the dot is only company. */
+ .ink {
+ display: inline-block;
+ inline-size: var(--space-3);
+ block-size: var(--space-3);
+ margin-inline-end: var(--space-2);
+ border-radius: var(--radius-pill);
+ forced-color-adjust: none;
+ }
+
+ /* The dot hangs in front, so a wrapped title and the detail under it start where the title's text starts. */
+ .optionTitle:has(.ink),
+ .option:has(.ink) .detail {
+ padding-inline-start: calc(var(--space-3) + var(--space-2));
+ }
+
+ .optionTitle:has(.ink) {
+ text-indent: calc(-1 * (var(--space-3) + var(--space-2)));
+ }
+
.keys {
color: var(--color-text-secondary);
font-family: var(--font-ui);
diff --git a/app/src/features/palette/CommandPalette.test.tsx b/app/src/features/palette/CommandPalette.test.tsx
index 80248b78..e7f92aa0 100644
--- a/app/src/features/palette/CommandPalette.test.tsx
+++ b/app/src/features/palette/CommandPalette.test.tsx
@@ -56,14 +56,15 @@ describe('the command palette', () => {
opener.remove();
});
- it('announces how many results there are, then "No results"', async () => {
+ it('announces how many results there are, then says "No results." in the list too', async () => {
await renderApp();
const box = await openWith('Ctrl+K');
await typeInto(box, 'dark');
await waitFor(() => expect(announcements().at(-1)).toMatch(/^\d+ results?$/), { timeout: 3000 });
await typeInto(box, 'zzqx');
- await waitFor(() => expect(announcements().at(-1)).toBe('No results'), { timeout: 3000 });
+ await waitFor(() => expect(announcements().at(-1)).toBe('No results.'), { timeout: 3000 });
expect(screen.queryAllByRole('option')).toHaveLength(0);
+ expect(within(screen.getByRole('dialog')).getByText('No results.')).toBeVisible();
});
it('moves with the arrow keys, closes on Escape, and Ctrl+K again closes it', async () => {
@@ -103,6 +104,34 @@ describe('the quick switcher', () => {
await waitFor(() => expect(getLocation()).toMatchObject({ view: 'workspace', pageId: 'p-mitosis' }));
});
+ it('shows the notebook color as a small hidden dot before a page, and adds nothing to its name', async () => {
+ await renderApp();
+ const box = await openWith('Ctrl+O');
+ await typeInto(box, 'mitosis');
+ // Each letter searches again, and a slow machine lists Mitosis for a part of the word before the list is rebuilt
+ // for all of it. The count is announced once every provider has answered for the whole word, so the option
+ // that is measured is the one that stays.
+ await waitFor(() => expect(announcements().at(-1)).toMatch(/^\d+ results?$/), { timeout: 3000 });
+ const option = screen.getByRole('option', { name: /Mitosis/ });
+ const dot = option.querySelector('span[style*="--ink-fern"]');
+ expect(dot?.getAttribute('aria-hidden')).toBe('true');
+ expect(dot?.textContent).toBe('');
+ expect(option.getAttribute('aria-label')).toBeNull();
+ // The dot hangs in front: the title's text and the detail under it start at the same place, and the dot ends
+ // before them. The text is measured as a range; the dot has no text, so it is measured as the box it is (an
+ // empty element's range has no size, and where browsers put it differs).
+ const textLeft = (node: Node) => {
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ return Math.round(range.getBoundingClientRect().left);
+ };
+ const title = textLeft(dot?.parentElement?.lastChild as Node);
+ expect(textLeft(option.querySelector('[id$="-detail"]') as Node)).toBe(title);
+ const dotBox = (dot as Element).getBoundingClientRect();
+ expect(dotBox.width).toBeGreaterThan(0);
+ expect(Math.round(dotBox.right)).toBeLessThanOrEqual(title);
+ });
+
it('lists recent pages first when nothing is typed', async () => {
await renderApp();
navigate({ view: 'workspace', notebookId: id('n-biology'), sectionId: id('s-lectures'), pageId: id('p-meiosis') });
diff --git a/app/src/features/palette/ResultList.tsx b/app/src/features/palette/ResultList.tsx
index fac34dc5..6cf85a9b 100644
--- a/app/src/features/palette/ResultList.tsx
+++ b/app/src/features/palette/ResultList.tsx
@@ -41,7 +41,12 @@ function Option({ result, id, active, onHover, onChoose }: OptionProps) {
onPointerMove={onHover}
onClick={onChoose}
>
- {result.title}
+
+ {result.ink && (
+
+ )}
+ {result.title}
+
{first && {formatChord(first)}}
{result.detail && (
@@ -67,28 +72,34 @@ export function ResultList({ id, ranked, activeIndex, onHover, onChoose }: Resul
const starts = ranked.groups.map((_, g) =>
ranked.groups.slice(0, g).reduce((count, group) => count + group.results.length, 0),
);
+ // Once every provider has answered, an empty list says so where the results would be. The announcer has told
+ // screen readers already, so the line isn't a live region.
+ const empty = ranked.settled && ranked.flat.length === 0;
return (
-
}
diff --git a/app/src/features/settings/SettingsView.module.css b/app/src/features/settings/SettingsView.module.css
index a0143f66..9b057002 100644
--- a/app/src/features/settings/SettingsView.module.css
+++ b/app/src/features/settings/SettingsView.module.css
@@ -10,9 +10,10 @@
}
.header {
- padding: var(--space-3) var(--space-4);
+ padding: var(--space-3) var(--space-4) var(--space-2);
}
+ /* The scroll padding brings a control that Tab reaches below the fold in with room for its focus ring. */
.body {
display: grid;
grid-template-columns: var(--size-settings-nav) minmax(0, 1fr);
@@ -20,17 +21,28 @@
align-content: start;
min-block-size: 0;
overflow-y: auto;
+ scroll-padding-block: var(--space-3);
}
:global(:root[data-size-class='compact']) .body {
grid-template-columns: minmax(0, 1fr);
}
+ /* The section list stays in view while a long section scrolls under the header, so its column is never an empty
+ strip and another section is always one click away. */
+ .nav {
+ position: sticky;
+ inset-block-start: 0;
+ align-self: start;
+ }
+
+ /* The list's block padding is room inside the scroller for the first link's focus ring. The header and the
+ main column give up and take back the same 4 px, so nothing moves. */
.nav ul {
display: grid;
gap: var(--space-1);
margin: 0;
- padding: 0 var(--space-3);
+ padding: var(--space-2) var(--space-3);
list-style: none;
}
@@ -67,7 +79,7 @@
gap: var(--space-6);
align-content: start;
min-inline-size: 0;
- padding: var(--space-2) var(--space-7) var(--space-8);
+ padding: var(--space-3) var(--space-7) var(--space-8);
}
.heading,
@@ -106,6 +118,7 @@
}
.help {
+ margin: 0;
color: var(--color-text-secondary);
font-size: var(--type-small-size);
line-height: var(--type-small-line);
@@ -116,6 +129,25 @@
font-family: var(--font-mono);
}
+ /* The ink stroke signs off the heading, so it sits close under it instead of a grid gap away. */
+ .stroke {
+ margin-block-start: calc(-1 * var(--space-5));
+ }
+
+ /* The logo mark and the window, side by side above the facts. */
+ .mark {
+ display: flex;
+ align-items: center;
+ gap: var(--space-6);
+ }
+
+ /* A block, so no text line under it pushes the mark above the window's middle. */
+ .logoMark svg {
+ display: block;
+ inline-size: var(--space-10);
+ block-size: var(--space-10);
+ }
+
.actions {
display: flex;
flex-wrap: wrap;
@@ -145,4 +177,16 @@
padding: 0;
list-style: none;
}
+
+ /* A labeled switch's hover area pads its label by 8 px and a 1 px border. The area hangs out to the start, so
+ the label lines up with the heading above and the help text below. */
+ .block [role='switch'] {
+ margin-inline-start: calc(-1 * (var(--space-3) + 1px));
+ }
+
+ /* One width for every flag's switch, so the tracks line up at its end instead of following each label. */
+ .flags [role='switch'] {
+ display: flex;
+ inline-size: min(100%, 24rem);
+ }
}
diff --git a/app/src/features/settings/SettingsView.test.tsx b/app/src/features/settings/SettingsView.test.tsx
index 2803f7d3..443deb6c 100644
--- a/app/src/features/settings/SettingsView.test.tsx
+++ b/app/src/features/settings/SettingsView.test.tsx
@@ -43,6 +43,23 @@ describe('the Settings page', () => {
const heading = await screen.findByRole('heading', { level: 1, name: 'About' });
await expectFocus(heading);
expect(screen.getByRole('link', { name: 'About' }).getAttribute('aria-current')).toBe('page');
+ // The logo mark, the window, and the heading's ink stroke are drawings that screen readers skip.
+ const main = heading.closest('main') as HTMLElement;
+ await expect
+ .poll(() => main.querySelector('svg[viewBox="0 0 100 103"][aria-hidden="true"]'), { timeout: 5000 })
+ .toBeTruthy();
+ await expect
+ .poll(() => main.querySelector('svg[viewBox="0 0 64 64"][aria-hidden="true"]'), { timeout: 5000 })
+ .toBeTruthy();
+ await expect
+ .poll(() => main.querySelector('svg[viewBox="0 0 56 8"][aria-hidden="true"]'), { timeout: 5000 })
+ .toBeTruthy();
+ // The logo mark sits level with the window beside it.
+ const middle = (selector: string) => {
+ const box = (main.querySelector(selector) as Element).getBoundingClientRect();
+ return box.top + box.height / 2;
+ };
+ expect(Math.abs(middle('svg[viewBox="0 0 64 64"]') - middle('svg[viewBox="0 0 100 103"]'))).toBeLessThan(1);
expect(await screen.findByText('Apache License 2.0')).toBeTruthy();
expect(screen.queryByRole('heading', { name: 'Notes folder' })).toBeNull();
expect(getLocation()).toEqual({ view: 'settings', section: 'about' });
diff --git a/app/src/features/setup/SetupView.module.css b/app/src/features/setup/SetupView.module.css
index aa63dde4..edc5cb7f 100644
--- a/app/src/features/setup/SetupView.module.css
+++ b/app/src/features/setup/SetupView.module.css
@@ -1,5 +1,6 @@
-/* First-run setup (ARCHITECTURE.md section 17.2): a centered card on the app surface. In compact it fills the
- window with 16 px margins, its cards stack, and Back and Continue stay in a footer that is always visible. */
+/* First-run setup (ARCHITECTURE.md section 17.2): a centered card on the app surface, with the window light of the
+ day or the evening sky behind it. In compact it fills the window with 16 px margins, its cards stack, and Back and
+ Continue stay in a footer that is always visible. */
@layer components {
.screen {
display: grid;
@@ -9,6 +10,23 @@
background: var(--color-surface-app);
}
+ /* Window light. By day, a warm pool falls from the top corner. In the evening, a dusk sky ends in a low sunset
+ band. It paints the backdrop, never the card, and stays close to the surface (BRAND.md section 4). The patch of
+ light on the desk belongs to the page canvas, so the setup steps stay calm. */
+ .screen {
+ background:
+ radial-gradient(ellipse 90% 80% at 100% 0%, var(--color-ambient-canvas-top), transparent 75%),
+ var(--color-surface-app);
+ }
+
+ :global(:root[data-theme='dark']) .screen {
+ background:
+ /* The last of the sunset, low on the horizon. */
+ radial-gradient(ellipse 130% 28% at 50% 100%, var(--color-ambient-glow), transparent),
+ /* Night at the top, deepening through dusk toward the horizon. */
+ linear-gradient(to bottom, var(--color-ambient-canvas-top) 10%, var(--color-ambient-canvas-bottom) 88%);
+ }
+
.card {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
@@ -21,9 +39,11 @@
box-shadow: var(--elevation-2);
}
+ /* The scroll padding brings a control that Tab reaches below the fold in with room for its focus ring. */
.content {
overflow-y: auto;
padding: var(--space-8) var(--space-8) var(--space-5);
+ scroll-padding-block: var(--space-3);
}
.step {
@@ -31,6 +51,24 @@
gap: var(--space-6);
}
+ /* In setup the theme cards grow past their Settings width to fill the column. The row's outer edges then meet
+ the edges of the buttons below, and the row centers under the centered heading. Settings keeps its row
+ start-aligned with the text above it. */
+ .look [role='radiogroup'] {
+ justify-content: center;
+ }
+
+ .look [role='radiogroup'] > [role='radio'] {
+ max-inline-size: none;
+ }
+
+ /* At first run the Match Windows caption says why it is preselected, which takes two lines in a row of cards.
+ Choosing another card puts back the one-line caption. Every caption keeps room for two lines, so the row
+ doesn't jump. Stacked in compact, each caption has the width for one line. */
+ :global(:root:not([data-size-class='compact'])) .look [role='radiogroup'] > [role='radio'] {
+ grid-template-rows: auto auto minmax(calc(2 * var(--type-small-line)), auto);
+ }
+
.header {
display: grid;
gap: var(--space-3);
@@ -57,6 +95,20 @@
color: var(--color-text-secondary);
}
+ /* The welcome step: the title stays where every other step has it, the note sits at the bottom, and the desk is
+ centered in the room between them. */
+ .welcome {
+ grid-template-rows: auto 1fr auto;
+ min-block-size: 100%;
+ text-align: center;
+ }
+
+ .scene {
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ }
+
.note {
margin: 0;
color: var(--color-text-secondary);
@@ -115,6 +167,12 @@
align-content: start;
}
+ /* The notebook color chips share the width evenly, so every chip is the same size. */
+ .chips :where([role='radiogroup']) {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(calc(var(--space-9) * 2), 1fr));
+ }
+
:global(:root[data-size-class='compact']) .screen {
grid-template-rows: minmax(0, 1fr);
align-items: stretch;
@@ -135,6 +193,10 @@
@layer forced {
@media (forced-colors: active) {
+ .screen {
+ background-image: none;
+ }
+
.card {
border-color: ButtonBorder;
}
diff --git a/app/src/features/setup/SetupView.test.tsx b/app/src/features/setup/SetupView.test.tsx
index eb226717..7ace7968 100644
--- a/app/src/features/setup/SetupView.test.tsx
+++ b/app/src/features/setup/SetupView.test.tsx
@@ -48,6 +48,29 @@ describe('the first step', () => {
await expectNoAxeViolations(container);
});
+ it('shows the desk by the window between the sentence and the note, hidden from screen readers', async () => {
+ const { container } = await renderApp(firstRun());
+ await form(/Welcome to OpenNote/);
+ const desk = container.querySelector('svg[viewBox="0 0 240 150"]');
+ expect(desk?.getAttribute('aria-hidden')).toBe('true');
+ expect(desk?.getAttribute('focusable')).toBe('false');
+ const sentence = screen.getByText(/OpenNote is a notebook for typing/);
+ const note = screen.getByText(/Setup takes about a minute/);
+ expect(sentence.compareDocumentPosition(desk as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect((desk as Node).compareDocumentPosition(note) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ // The heading signs off with the ink stroke, a 56 by 8 drawing that is also hidden.
+ expect(container.querySelector('svg[viewBox="0 0 56 8"][aria-hidden="true"]')).toBeTruthy();
+ });
+
+ it('keeps the title where the next step has it, so Get started does not move it', async () => {
+ await renderApp(firstRun());
+ const top = async (name: string) =>
+ Math.round((await screen.findByRole('heading', { level: 1, name })).getBoundingClientRect().top);
+ const welcome = await top('Welcome to OpenNote');
+ fireEvent.click(button('Get started'));
+ await expect.poll(() => top('Choose your look')).toBe(welcome);
+ });
+
it('stays out of the way when setup is done', async () => {
await renderApp();
expect(getLocation().view).toBe('workspace');
diff --git a/app/src/features/setup/StepHeader.tsx b/app/src/features/setup/StepHeader.tsx
index fc95bd82..fb406b66 100644
--- a/app/src/features/setup/StepHeader.tsx
+++ b/app/src/features/setup/StepHeader.tsx
@@ -4,6 +4,7 @@
import type { ReactNode } from 'react';
import type { SetupStepProps } from '../../registries';
import { t } from '../../strings/t';
+import { InkStroke } from '../../ui';
import styles from './SetupView.module.css';
type Position = Pick;
@@ -18,6 +19,7 @@ export function StepHeader(props: Position & { title: string; subtitle?: ReactNo
);
diff --git a/app/src/features/shortcuts/ChordList.tsx b/app/src/features/shortcuts/ChordList.tsx
index b2995fdc..6fdffa7c 100644
--- a/app/src/features/shortcuts/ChordList.tsx
+++ b/app/src/features/shortcuts/ChordList.tsx
@@ -1,6 +1,6 @@
-// A command's keys, each in a , with "or" between them. No keys reads "None".
+// A command's keys, each in a , with "or" between them. No keys reads "None". When the keys wrap, "or" goes
+// to the next line with the chord it introduces, never alone at the end of a line.
-import { Fragment } from 'react';
import { formatChord } from '../../commands/keymap';
import type { Chord } from '../../commands/types';
import { t } from '../../strings/t';
@@ -11,10 +11,10 @@ export function ChordList({ chords }: { chords: readonly string[] }) {
return (
{chords.map((chord, i) => (
-
+
{i > 0 && {t('shortcuts.or')}}
{formatChord(chord as Chord)}
-
+
))}
);
diff --git a/app/src/features/shortcuts/ShortcutEditor.test.tsx b/app/src/features/shortcuts/ShortcutEditor.test.tsx
index 116f8be7..95832614 100644
--- a/app/src/features/shortcuts/ShortcutEditor.test.tsx
+++ b/app/src/features/shortcuts/ShortcutEditor.test.tsx
@@ -37,6 +37,9 @@ describe('changing a shortcut', () => {
const dialog = await openList();
const { row, field } = await startChange(dialog, 'Toggle dark mode');
expect(field.getAttribute('data-key-capture')).toBe('');
+ // The field takes the empty actions cell too, so its placeholder fits in a narrow window.
+ expect((field.closest('td') as HTMLTableCellElement).colSpan).toBe(2);
+ expect(field.scrollWidth).toBeLessThanOrEqual(field.clientWidth);
expect(row.textContent).toContain('Press the new shortcut. Escape cancels, and Backspace removes it.');
await pressChord('Ctrl+Alt+J');
await waitFor(() => expect(shortcutsOf('theme.toggle')).toEqual(['Ctrl+Alt+J']));
diff --git a/app/src/features/shortcuts/ShortcutList.module.css b/app/src/features/shortcuts/ShortcutList.module.css
index 16e838f8..0fa4d013 100644
--- a/app/src/features/shortcuts/ShortcutList.module.css
+++ b/app/src/features/shortcuts/ShortcutList.module.css
@@ -5,6 +5,18 @@
gap: var(--space-6);
}
+ /* The two shortcut sets are one choice, so their cards share one width and the descriptions wrap inside. In
+ compact they stack. */
+ .section > [role='radiogroup'] {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ max-inline-size: 44rem;
+ }
+
+ :global(:root[data-size-class='compact']) .section > [role='radiogroup'] {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
.list {
display: grid;
gap: var(--space-5);
@@ -14,18 +26,45 @@
.toolbar {
display: flex;
flex-wrap: wrap;
- align-items: end;
+ align-items: start;
justify-content: space-between;
gap: var(--space-4);
}
+ /* The switch and the Reset all button line up with the field's input, under its label line, not with the help
+ text below it. */
+ .toolbar > :not(:first-child) {
+ margin-block-start: calc(var(--type-small-line) + var(--space-2));
+ }
+
+ /* The switch's hover area pads it by 8 px and a 1 px border. When the switch ends the row, the area hangs out
+ past the end, so its track lines up with the tables' end edge, as Settings does at the start edge. */
+ .toolbar > [role='switch']:last-child {
+ margin-inline-end: calc(-1 * (var(--space-3) + 1px));
+ }
+
+ /* On the Settings page the tables don't scroll on their own, but keep the dialog's space between them. Positioned,
+ so the visually hidden table heads stay inside the Settings scroller. Unpositioned, they escaped it and made the
+ whole window scrollable. */
+ .groups {
+ position: relative;
+ display: grid;
+ gap: var(--space-6);
+ }
+
+ /* Positioned, so the visually hidden table heads stay inside the scroller instead of stretching the dialog. In a
+ short window the height leaves room for the rest of the dialog, which takes 344 px. The title bar takes 40 and
+ the dialog layer's padding 32. The dialog's padding with its border takes 50, its title 28, and its
+ description 22. The filter row takes 76, the Close button 32, and four 16 px gaps 64. The scroll padding
+ brings a control that Tab reaches below the fold in with room for its focus ring. */
.scroll {
+ position: relative;
display: grid;
gap: var(--space-6);
- max-block-size: 55vh;
+ max-block-size: min(55vh, calc(100dvh - 344px));
overflow-y: auto;
overscroll-behavior: contain;
- padding-inline-end: var(--space-2);
+ scroll-padding-block: var(--space-3);
}
.scroll:focus-visible {
@@ -51,6 +90,17 @@
table-layout: fixed;
}
+ /* The columns set the widths, so a row whose key field spans the keys and actions cells moves no other row. The
+ names wrap more gracefully than the keys, so the keys get the room: two chords fit on one line, and no key cap
+ runs into the Change button. The actions column holds Change and Reset side by side. */
+ .nameColumn {
+ inline-size: 33%;
+ }
+
+ .actionsColumn {
+ inline-size: 10rem;
+ }
+
.table th,
.table td {
padding: var(--space-2) var(--space-3);
@@ -60,7 +110,6 @@
}
.name {
- inline-size: 50%;
font-weight: var(--font-weight-regular);
}
@@ -76,18 +125,21 @@
}
.actions {
- inline-size: 11rem;
white-space: nowrap;
text-align: end;
}
- .chords {
+ .chords,
+ .chord {
display: inline-flex;
- flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
+ .chords {
+ flex-wrap: wrap;
+ }
+
.or {
color: var(--color-text-secondary);
font-size: var(--type-caption-size);
diff --git a/app/src/features/shortcuts/ShortcutList.tsx b/app/src/features/shortcuts/ShortcutList.tsx
index 4704f08e..8597ae88 100644
--- a/app/src/features/shortcuts/ShortcutList.tsx
+++ b/app/src/features/shortcuts/ShortcutList.tsx
@@ -74,7 +74,7 @@ export function ShortcutList({ scrolls = false, level = 3 }: { scrolls?: boolean
{anyChanged && }
diff --git a/app/src/features/theme/AppearanceSection.module.css b/app/src/features/theme/AppearanceSection.module.css
index 70b73b6e..4dcc693d 100644
--- a/app/src/features/theme/AppearanceSection.module.css
+++ b/app/src/features/theme/AppearanceSection.module.css
@@ -28,6 +28,35 @@
.group :where([role='radiogroup']) {
gap: var(--space-3);
}
+
+ /* The cards of any other choice share one width, the widest card's, so their edges line up. They shrink together
+ when the column is narrower. */
+ .matched [role='radiogroup'] {
+ display: grid;
+ grid-auto-columns: 1fr;
+ grid-auto-flow: column;
+ justify-self: start;
+ }
+
+ /* Equal steps, such as the percentages, get equal cards: 80 px each, the label centered, whatever its digits. */
+ .even {
+ container-type: inline-size;
+ }
+
+ .even [role='radio'] {
+ min-inline-size: calc(var(--space-9) * 2);
+ justify-items: center;
+ }
+
+ /* Eight steps, the text sizes, need 696 px in one row: eight 80 px cards and seven 8 px gaps. Narrower, they
+ split four and four, so no step is left alone on a second row. */
+ @container (inline-size < 696px) {
+ .even [role='radiogroup']:has(> :nth-child(8):last-child) {
+ display: grid;
+ grid-template-columns: repeat(4, auto);
+ justify-content: start;
+ }
+ }
}
/* A selected choice keeps a visible mark when Windows replaces the colors. */
diff --git a/app/src/features/theme/AppearanceSection.tsx b/app/src/features/theme/AppearanceSection.tsx
index 95fd3961..ad6f92e6 100644
--- a/app/src/features/theme/AppearanceSection.tsx
+++ b/app/src/features/theme/AppearanceSection.tsx
@@ -20,14 +20,17 @@ interface ChoiceProps {
help?: string;
value: string;
options: readonly { value: string; label: string }[];
+ /** Gives every option a fixed width, for a row of equal steps such as percentages. Otherwise the options share
+ * the widest one's width. */
+ even?: boolean;
onChange(value: string): void;
}
/** A labeled group of choices. Every option is a card in a radio group, so arrow keys move the selection. */
-function Choice({ label, help, value, options, onChange }: ChoiceProps) {
+function Choice({ label, help, value, options, even, onChange }: ChoiceProps) {
const id = useId();
return (
-
+
{label}
@@ -66,6 +69,7 @@ function SizeGroups({ appearance }: { appearance: Appearance }) {
help={t('theme.appearance.textSize.help')}
value={String(appearance.textSize)}
options={TEXT_SIZES.map((size) => ({ value: String(size), label: percent(size) }))}
+ even
onChange={(size) => setTextSize(Number(size) as TextSize)}
/>
({ value: String(scale), label: percent(scale) }))}
+ even
onChange={(scale) => save({ uiScale: Number(scale) })}
/>
>
diff --git a/app/src/features/theme/ThemeCards.module.css b/app/src/features/theme/ThemeCards.module.css
index f7aa1393..697c3b63 100644
--- a/app/src/features/theme/ThemeCards.module.css
+++ b/app/src/features/theme/ThemeCards.module.css
@@ -9,9 +9,12 @@
gap: var(--space-7);
}
+ /* The three cards share a row until the window is compact, shrinking from their full width together. Their height
+ is their content's, so the card's padding is the same above the preview and below the caption. */
.cards [role='radio'] {
- inline-size: var(--size-theme-card);
- min-block-size: var(--size-theme-card);
+ flex: 1 1 0;
+ min-inline-size: 0;
+ max-inline-size: var(--size-theme-card);
align-content: start;
}
@@ -21,8 +24,8 @@
}
:global(:root[data-size-class='compact']) .cards [role='radio'] {
- inline-size: auto;
- min-block-size: 0;
+ flex: none;
+ max-inline-size: none;
}
.note {
@@ -33,6 +36,7 @@
}
.mini {
+ position: relative;
display: grid;
grid-template-rows: var(--space-3) 1fr;
block-size: calc(var(--size-theme-card) / 2);
@@ -42,6 +46,22 @@
background: var(--color-surface-app);
}
+ /* A mark in a bottom corner, below the lines of the page and the sidebar, so it never sits on them. */
+ .glintEnd,
+ .glintStart {
+ position: absolute;
+ inset-block-end: var(--space-3);
+ opacity: 0.8;
+ }
+
+ .glintEnd {
+ inset-inline-end: var(--space-3);
+ }
+
+ .glintStart {
+ inset-inline-start: var(--space-3);
+ }
+
.bar {
background: var(--color-surface-raised);
border-block-end: 1px solid var(--color-border-subtle);
@@ -125,6 +145,11 @@
border-color: CanvasText;
}
+ .glintEnd,
+ .glintStart {
+ display: none;
+ }
+
.cards [role='radio'][aria-checked='true'] {
outline: 2px solid Highlight;
outline-offset: -2px;
diff --git a/app/src/features/theme/ThemeCards.test.tsx b/app/src/features/theme/ThemeCards.test.tsx
index 5fc0234d..b933ef04 100644
--- a/app/src/features/theme/ThemeCards.test.tsx
+++ b/app/src/features/theme/ThemeCards.test.tsx
@@ -49,6 +49,23 @@ describe('ThemeCards', () => {
});
});
+describe('the marks in the previews', () => {
+ it('give Light a sun, Dark a moon, and Match Windows both, all hidden from screen readers', () => {
+ renderUi( {}} />);
+ const marks = (name: string) => [...radio(name).querySelectorAll('svg[viewBox="0 0 16 16"]')];
+ expect(marks('Light')).toHaveLength(1);
+ expect(marks('Dark')).toHaveLength(1);
+ expect(marks('Match Windows')).toHaveLength(2);
+ for (const mark of marks('Light').concat(marks('Dark'), marks('Match Windows'))) {
+ expect(mark.getAttribute('aria-hidden')).toBe('true');
+ expect(mark.getAttribute('focusable')).toBe('false');
+ expect(mark.closest('[data-theme-scope]')).toBeTruthy();
+ }
+ expect(radio('Light').querySelector('svg circle')).toBeNull();
+ expect(radio('Dark').querySelectorAll('svg circle')).toHaveLength(2);
+ });
+});
+
describe('choosing a theme card', () => {
it('selects on a click and on an arrow key, and ignores a held key', async () => {
const onChange = vi.fn();
diff --git a/app/src/features/theme/ThemeCards.tsx b/app/src/features/theme/ThemeCards.tsx
index fb07e3c8..88f5c471 100644
--- a/app/src/features/theme/ThemeCards.tsx
+++ b/app/src/features/theme/ThemeCards.tsx
@@ -6,7 +6,7 @@
import type { ThemePreference } from '../../platform/types';
import { useOs } from '../../state/os';
import { t } from '../../strings/t';
-import { RadioCard, RadioGroup } from '../../ui';
+import { Glint, RadioCard, RadioGroup } from '../../ui';
import styles from './ThemeCards.module.css';
const CHOICES = [
@@ -22,10 +22,17 @@ export interface ThemeCardsProps {
systemCaption?: string;
}
-/** A small app window in one theme: a title bar, a sidebar, and a page with lines of text. */
-function Mini({ theme }: { theme: 'light' | 'dark' }) {
+/**
+ * A small app window in one theme: a title bar, a sidebar, and a page with lines of text, and in the corner a low
+ * sun for Light or a moon and stars for Dark. In Match Windows each half keeps its own mark at its own edge.
+ */
+function Mini({ theme, corner = 'end' }: { theme: 'light' | 'dark'; corner?: 'start' | 'end' }) {
return (
+
@@ -48,7 +55,7 @@ function Preview({ choice }: { choice: ThemePreference }) {
if (choice !== 'system') return ;
return (
-
+
);
diff --git a/app/src/features/theme/ThemeToggle.module.css b/app/src/features/theme/ThemeToggle.module.css
index 3dd04aaf..4c676653 100644
--- a/app/src/features/theme/ThemeToggle.module.css
+++ b/app/src/features/theme/ThemeToggle.module.css
@@ -8,6 +8,15 @@
user-select: none;
}
+ /* The sun is candlelight and the moon is night, so the toggle shows which sky is shown (BRAND.md section 4). */
+ .sun {
+ color: var(--color-accent-candle);
+ }
+
+ .moon {
+ color: var(--color-accent-night);
+ }
+
.hidden {
position: absolute;
inline-size: 1px;
diff --git a/app/src/features/theme/ThemeToggle.tsx b/app/src/features/theme/ThemeToggle.tsx
index c9088548..5b1584fa 100644
--- a/app/src/features/theme/ThemeToggle.tsx
+++ b/app/src/features/theme/ThemeToggle.tsx
@@ -90,7 +90,11 @@ export function ThemeToggle() {
keyShortcuts={keys.length ? ariaKeyShortcuts(keys) : undefined}
onChange={() => setThemePreference(toggledPreference(shown), 'toggle')}
>
- {dark ? : }
+ {dark ? (
+
+ ) : (
+
+ )}
diff --git a/app/src/features/trash/TrashView.module.css b/app/src/features/trash/TrashView.module.css
index 42f7cd7f..9773155e 100644
--- a/app/src/features/trash/TrashView.module.css
+++ b/app/src/features/trash/TrashView.module.css
@@ -1,16 +1,28 @@
-/* The Trash view: a list of deleted items in the page area, each with its Restore button. */
+/* The Trash view: a list of deleted items in the page area, each with its Restore button. Like a page, it sits on a
+ paper card over the desk. */
@layer components {
.trash {
inline-size: min(100% - 2 * var(--space-8), var(--size-reading-measure));
margin: var(--space-8) auto;
+ padding: var(--space-10);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-page);
+ box-shadow: var(--elevation-2);
}
+ /* The heading's focus ring hugs its text instead of running the width of the card. */
.trash h1 {
+ inline-size: fit-content;
+ max-inline-size: 100%;
margin: 0 0 var(--space-5);
font-size: var(--type-title1-size);
line-height: var(--type-title1-line);
}
+ .art {
+ margin-block-end: var(--space-4);
+ }
+
.list {
display: flex;
flex-direction: column;
@@ -44,10 +56,16 @@
white-space: nowrap;
}
- .note,
- .empty {
+ .note {
color: var(--color-text-secondary);
font-size: var(--type-small-size);
line-height: var(--type-small-line);
}
+
+ .empty {
+ margin: 0;
+ color: var(--color-text-secondary);
+ font-size: var(--type-note-size);
+ line-height: var(--type-note-line);
+ }
}
diff --git a/app/src/features/trash/TrashView.test.tsx b/app/src/features/trash/TrashView.test.tsx
index 37a2c382..05a361e0 100644
--- a/app/src/features/trash/TrashView.test.tsx
+++ b/app/src/features/trash/TrashView.test.tsx
@@ -31,9 +31,12 @@ describe('the Trash view', () => {
expect((await notes.get('p-mitosis' as NodeId))?.parentId).toBe('s-lectures');
});
- it('says so when Trash is empty', async () => {
- await renderApp({ boot: { state: { location: { view: 'trash' } } } });
- await screen.findByText('Trash is empty.');
+ it('says so when Trash is empty, with a candle and books above the sentence', async () => {
+ const { container } = await renderApp({ boot: { state: { location: { view: 'trash' } } } });
+ const sentence = await screen.findByText('Trash is empty.');
+ const art = container.querySelector('section svg[viewBox="0 0 110 54"]');
+ expect(art?.getAttribute('aria-hidden')).toBe('true');
+ expect((art as Node).compareDocumentPosition(sentence) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('restores from the context menu', async () => {
diff --git a/app/src/features/trash/TrashView.tsx b/app/src/features/trash/TrashView.tsx
index 0fb46ef3..110737c7 100644
--- a/app/src/features/trash/TrashView.tsx
+++ b/app/src/features/trash/TrashView.tsx
@@ -12,7 +12,7 @@ import { registerRegionMain } from '../../shell/regions';
import { useStore } from '../../state/store';
import { formatDate, formatTime } from '../../strings/format';
import { t } from '../../strings/t';
-import { Button, ProgressBar, useContextMenu, useDelayedFlag } from '../../ui';
+import { Button, EmptyArt, ProgressBar, useContextMenu, useDelayedFlag } from '../../ui';
import { titleOf } from '../tree';
import { restoreTrashItem, trashStore } from './restore';
import styles from './TrashView.module.css';
@@ -86,7 +86,12 @@ export function TrashView() {
{t('tree.trash.title')}
- {items?.length === 0 &&
{t('tree.trash.empty')}
}
+ {items?.length === 0 && (
+ <>
+
+
{t('tree.trash.empty')}
+ >
+ )}
{items && items.length > 0 && (
{items.map((item) => (
diff --git a/app/src/features/tree/MoveTo.module.css b/app/src/features/tree/MoveTo.module.css
index b24310d8..05339d82 100644
--- a/app/src/features/tree/MoveTo.module.css
+++ b/app/src/features/tree/MoveTo.module.css
@@ -3,7 +3,7 @@
.filter {
inline-size: 100%;
min-block-size: var(--size-row-pointer);
- padding-inline: var(--space-2);
+ padding-inline: var(--space-4);
border: 1px solid var(--color-border-control);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
diff --git a/app/src/features/tree/Panes.tsx b/app/src/features/tree/Panes.tsx
index 100cc478..3f40d72d 100644
--- a/app/src/features/tree/Panes.tsx
+++ b/app/src/features/tree/Panes.tsx
@@ -1,6 +1,6 @@
// The notebooks pane and the pages pane (ARCHITECTURE.md section 13.2). Both use TreeView: the notebooks tree holds
// notebooks, section groups, and sections, and the pages tree the current section's pages and subpages. Trash
-// sits at the bottom of the notebooks pane, behind trash.view.
+// sits at the bottom of the notebooks pane, behind trash.view, with a small plant opposite it when there is room.
import { TrashIcon } from '@phosphor-icons/react/dist/csr/Trash';
import { useEffect } from 'react';
@@ -9,7 +9,7 @@ import { formatChord, useKeysFor } from '../../commands/keymap';
import { executeCommand } from '../../commands/registry';
import { useStore } from '../../state/store';
import { t } from '../../strings/t';
-import { Button } from '../../ui';
+import { Button, EmptyArt, Plant } from '../../ui';
import { ensureChildren } from './load';
import {
useNotebookRows,
@@ -25,7 +25,8 @@ import { treeStore } from './store';
import styles from './Tree.module.css';
import { TreeView } from './TreeView';
-function TrashButton() {
+/** The plant keeps the footer company only when the pane has notebooks, because the empty pane has its own drawing. */
+function TrashButton({ plant }: { plant: boolean }) {
if (!useFlag('trash.view')) return null;
return (
}
+ empty={}
rowHeight={rowHeight}
openMenu={openMenu}
/>
-
+ 0} />
);
}
diff --git a/app/src/features/tree/SaveStatus.tsx b/app/src/features/tree/SaveStatus.tsx
index 3c6b20e6..dbe31fef 100644
--- a/app/src/features/tree/SaveStatus.tsx
+++ b/app/src/features/tree/SaveStatus.tsx
@@ -31,7 +31,7 @@ export function SaveStatus({ presentation }: { presentation: 'full' | 'icon' | '
const Icon = ICONS[status];
const text = t(`tree.save.${status}`);
return (
-
+
{presentation === 'icon' ? {text} : {text}}
diff --git a/app/src/features/tree/Tree.module.css b/app/src/features/tree/Tree.module.css
index df12517d..66f35913 100644
--- a/app/src/features/tree/Tree.module.css
+++ b/app/src/features/tree/Tree.module.css
@@ -122,9 +122,12 @@
border-radius: var(--radius-sm);
}
+ /* The 8 px dot sits in the same 12 px slot as the square, so sibling sections and section groups start their
+ names at one edge. */
.dot {
inline-size: var(--space-3);
block-size: var(--space-3);
+ margin-inline: var(--space-1);
border-radius: var(--radius-pill);
}
@@ -242,17 +245,49 @@
white-space: normal;
}
+ /* No browser margins: the pane's padding and the drawing's own margin set the space. */
.empty {
+ margin: 0;
color: var(--color-text-secondary);
}
+ .empty p {
+ margin: 0;
+ }
+
+ .emptyArt {
+ margin-block-end: var(--space-3);
+ }
+
.footer {
display: flex;
flex: none;
+ align-items: center;
+ justify-content: space-between;
+ container-type: inline-size;
border-block-start: 1px solid var(--color-border-subtle);
padding-block-start: var(--space-2);
}
+ /* A small plant at the end of the footer, opposite Trash. It needs room, so it hides when the footer is narrow, in
+ compact, and with the pane collapsed (a collapsed pane has no width). It sits in the fixed footer, so it never
+ covers a row. */
+ .footer .plant {
+ display: none;
+ }
+
+ @container (min-width: 200px) {
+ /* Inset from the end about as far as the Trash icon is from the start. */
+ .footer .plant {
+ display: block;
+ margin-inline-end: var(--space-2);
+ }
+ }
+
+ :global(:root[data-size-class='compact']) .footer .plant {
+ display: none;
+ }
+
.saveStatus {
display: inline-flex;
align-items: center;
@@ -262,6 +297,11 @@
line-height: var(--type-small-line);
}
+ /* Saved is the one good state, so its cloud is the success green; the rest stay as quiet as the text. */
+ .saveStatus[data-status='saved'] svg {
+ color: var(--color-status-success);
+ }
+
.saveStatus[data-status='error'] {
color: var(--color-status-danger);
}
diff --git a/app/src/features/tree/TreeView.test.tsx b/app/src/features/tree/TreeView.test.tsx
index ca7bee0b..c031b99f 100644
--- a/app/src/features/tree/TreeView.test.tsx
+++ b/app/src/features/tree/TreeView.test.tsx
@@ -179,6 +179,24 @@ describe('empty states and accessibility', () => {
await expect.poll(() => screen.queryByText('No notebooks yet. Choose New notebook to start one.')).toBeTruthy();
});
+ it('puts a bookshelf above the no-notebooks sentence, hidden from screen readers', async () => {
+ const { container } = await renderApp({ fixture: 'empty' });
+ const sentence = await screen.findByText('No notebooks yet. Choose New notebook to start one.');
+ const art = container.querySelector('svg[viewBox="0 0 110 52"]');
+ expect(art?.getAttribute('aria-hidden')).toBe('true');
+ expect((art as Node).compareDocumentPosition(sentence) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ // The empty pane has its own drawing, so the footer's plant leaves it alone.
+ expect(container.querySelector('svg[viewBox="0 0 56 66"]')).toBeNull();
+ });
+
+ it('keeps a small plant in the notebooks pane footer beside Trash, hidden from screen readers', async () => {
+ const { container } = await renderApp();
+ await screen.findByRole('button', { name: 'Trash' });
+ const plant = container.querySelector('svg[viewBox="0 0 56 66"]');
+ expect(plant?.getAttribute('aria-hidden')).toBe('true');
+ expect(plant?.closest('div')?.contains(screen.getByRole('button', { name: 'Trash' }))).toBe(true);
+ });
+
for (const theme of ['light', 'dark'] as const) {
for (const density of ['mouse', 'touch'] as const) {
it(`has no axe violations in the ${theme} theme with ${density} density`, async () => {
diff --git a/app/src/features/tree/remove.ts b/app/src/features/tree/remove.ts
index 3a3ca5ef..bc08c4c0 100644
--- a/app/src/features/tree/remove.ts
+++ b/app/src/features/tree/remove.ts
@@ -70,7 +70,7 @@ async function confirmNotebook(node: NodeSummary): Promise {
const count = await countPages(node.id);
return confirm({
title: t('tree.trash.confirmTitle', { title: titleOf(node) }),
- body: t('tree.trash.confirmBody', { count }),
+ body: count ? t('tree.trash.confirmBody', { count }) : t('tree.trash.confirmBodyEmpty'),
confirmLabel: t('tree.trash.confirm'),
danger: true,
});
diff --git a/app/src/features/updates/UpdateDetails.module.css b/app/src/features/updates/UpdateDetails.module.css
index 1e8f867f..daf7d1a9 100644
--- a/app/src/features/updates/UpdateDetails.module.css
+++ b/app/src/features/updates/UpdateDetails.module.css
@@ -44,10 +44,11 @@
color: var(--color-text-primary);
}
+ /* The 12 px of a dialog's buttons, the space BRAND.md asks for between related actions. */
.actions {
display: flex;
flex-wrap: wrap;
- gap: var(--space-2);
+ gap: var(--space-4);
}
.chip {
@@ -78,9 +79,12 @@
max-inline-size: var(--size-reading-measure);
}
+ /* The subtitle size of every other Settings section heading, not the browser's 1.5em. */
.section h2,
.section h3 {
margin: 0;
+ font-size: var(--type-subtitle-size);
+ line-height: var(--type-subtitle-line);
}
.card {
diff --git a/app/src/registries/types.ts b/app/src/registries/types.ts
index 8d6c70d1..c21d94d1 100644
--- a/app/src/registries/types.ts
+++ b/app/src/registries/types.ts
@@ -134,6 +134,8 @@ export interface PaletteResult {
group: 'commands' | 'notebooks' | 'sections' | 'pages' | (string & {});
title: string;
detail?: string;
+ /** The pen name of the notebook color to show as a small chip before the title, as the tree does. */
+ ink?: string;
keys?: readonly Chord[];
checked?: boolean;
/** Shown, but not run: a command whose `enabled` is false. */
diff --git a/app/src/shell/commandbar/BottomBar.tsx b/app/src/shell/commandbar/BottomBar.tsx
index 3fb4845c..812f1357 100644
--- a/app/src/shell/commandbar/BottomBar.tsx
+++ b/app/src/shell/commandbar/BottomBar.tsx
@@ -45,18 +45,24 @@ function ActionButton({ action }: { action: BottomAction }) {
const keys = useKeysFor(action.command);
const label = t(action.label);
const Icon = action.icon;
- return (
-
-
+ const button = (
+
+ );
+ // The button shows its label, so a tooltip only earns its place when it adds the shortcut.
+ return keys[0] ? (
+
+ {button}
+ ) : (
+ button
);
}
diff --git a/app/src/shell/commandbar/CommandBar.test.tsx b/app/src/shell/commandbar/CommandBar.test.tsx
index 09197e79..7be6a1fd 100644
--- a/app/src/shell/commandbar/CommandBar.test.tsx
+++ b/app/src/shell/commandbar/CommandBar.test.tsx
@@ -73,8 +73,9 @@ describe('the command bar tabs', () => {
await expectFocus(tools[0]);
await userEvent.keyboard('{End}');
expect(tools.map((button) => button.tabIndex)).toEqual([-1, -1, 0]);
- // A shown tooltip takes the first Escape, so the second one leaves for the page.
- await userEvent.keyboard('{Escape}{Escape}');
+ // These tools have no shortcut, so they show no tooltip to take the first Escape: it leaves for the page.
+ expect(screen.queryByRole('tooltip')).toBeNull();
+ await userEvent.keyboard('{Escape}');
await expectFocus(heading as HTMLElement);
});
});
diff --git a/app/src/shell/commandbar/Toolbar.tsx b/app/src/shell/commandbar/Toolbar.tsx
index ce372c46..deb4ff45 100644
--- a/app/src/shell/commandbar/Toolbar.tsx
+++ b/app/src/shell/commandbar/Toolbar.tsx
@@ -43,23 +43,29 @@ function Tool({ entry }: { entry: BarEntry }) {
event.preventDefault();
void showMenu(entry, event.currentTarget, setMenuOpen);
};
- return (
-
-
+ const button = (
+
+ );
+ // The button shows its label, so a tooltip only earns its place when it adds the shortcut.
+ return keys[0] ? (
+
+ {button}
+ ) : (
+ button
);
}
diff --git a/app/src/shell/layout/Workspace.module.css b/app/src/shell/layout/Workspace.module.css
index c740f7b2..56b08094 100644
--- a/app/src/shell/layout/Workspace.module.css
+++ b/app/src/shell/layout/Workspace.module.css
@@ -82,11 +82,31 @@
padding: var(--space-5);
}
+ /* The desk the page lies on. By day, warm window light pools from the top corner, and a soft patch of it falls on
+ the desk. The patch stops growing at 420 by 300 px, so a wide window doesn't stretch it. In the evening the sky
+ deepens from night indigo through dusk to a low sunset band at the horizon, and the stars sit above the page. Every
+ tone stays within 1.5:1 of the sunken canvas, and the light only ever shows around the page card, never behind
+ text. */
.page {
grid-area: page;
min-inline-size: 0;
overflow: auto;
- background: var(--color-surface-sunken);
+ isolation: isolate;
+ background:
+ /* The patch of window light on the desk, wider than it is tall, as light falls on a flat surface. */
+ radial-gradient(closest-side, var(--color-ambient-glow) 40%, transparent) 95.5% 84.5% / min(56%, 420px)
+ min(42%, 300px) no-repeat,
+ /* The pool of light from the window at the top corner. */
+ radial-gradient(ellipse 90% 80% at 100% 0%, var(--color-ambient-canvas-top), transparent 75%),
+ var(--color-surface-sunken);
+ }
+
+ :global(:root[data-theme='dark']) .page {
+ background:
+ /* The last of the sunset, low on the horizon. */
+ radial-gradient(ellipse 130% 28% at 50% 100%, var(--color-ambient-glow), transparent),
+ /* Night at the top, deepening through dusk toward the horizon. */
+ linear-gradient(to bottom, var(--color-ambient-canvas-top) 10%, var(--color-ambient-canvas-bottom) 88%);
}
.page:focus-visible {
@@ -171,8 +191,12 @@
outline-offset: calc(-1 * var(--size-focus-ring-width));
}
+ /* The scroller reaches 4 px into the drawer's padding and pads its content back, so a control at its edge, like
+ the Trash button, keeps its whole focus ring. */
.drawerBody {
min-inline-size: 0;
+ margin: calc(-1 * var(--space-2));
+ padding: var(--space-2);
overflow: auto;
}
@@ -211,3 +235,12 @@
outline-offset: calc(-1 * var(--size-focus-ring-width));
}
}
+
+/* A Windows contrast theme gets a flat canvas. */
+@layer forced {
+ @media (forced-colors: active) {
+ .page {
+ background-image: none;
+ }
+ }
+}
diff --git a/app/src/shell/layout/Workspace.tsx b/app/src/shell/layout/Workspace.tsx
index ef9a4943..2c4ea774 100644
--- a/app/src/shell/layout/Workspace.tsx
+++ b/app/src/shell/layout/Workspace.tsx
@@ -8,6 +8,7 @@ import type { CSSProperties, ReactNode } from 'react';
import { useLayout } from '../../state/layout';
import { sessionStore } from '../../state/session';
import { shallowEqual, useStore } from '../../state/store';
+import { StarField } from '../../ui';
import { useRegion } from '../regions';
import { NotebooksColumn, PagesColumn } from './Panes';
import { columnWidth, solvePanes } from './solvePanes';
@@ -65,6 +66,7 @@ export function Workspace(props: WorkspaceSlots) {
data-region={page['data-region']}
ref={pageRef}
>
+
{props.page}
{compact &&
{props.bottomBar}
}
diff --git a/app/src/shell/titlebar/Breadcrumb.tsx b/app/src/shell/titlebar/Breadcrumb.tsx
index 4011cbd4..c864ed6c 100644
--- a/app/src/shell/titlebar/Breadcrumb.tsx
+++ b/app/src/shell/titlebar/Breadcrumb.tsx
@@ -3,6 +3,7 @@
// narrow ('icon'), it shortens from the start on screen, "… › Lectures › Cell structure", while screen readers
// still hear the whole path.
+import type { CSSProperties } from 'react';
import { useLocation } from '../../app/location';
import { t } from '../../strings/t';
import { useNodes } from '../layout/useNode';
@@ -11,9 +12,10 @@ import styles from './TitleBar.module.css';
export function Breadcrumb({ presentation }: { presentation: 'full' | 'icon' | 'menuItem' }) {
const location = useLocation();
const ids = location.view === 'workspace' ? [location.notebookId, location.sectionId, location.pageId] : [];
- const titles = useNodes(ids)
- .map((node) => node?.title)
- .filter((title): title is string => Boolean(title));
+ const nodes = useNodes(ids).filter((node) => node?.title);
+ const titles = nodes.map((node) => node?.title ?? '');
+ // The notebook's color is a small dot before its name, as the tree shows it.
+ const ink = nodes[0]?.kind === 'notebook' ? nodes[0].color : null;
if (titles.length === 0) return null;
const hiddenCount = presentation !== 'full' && titles.length > 2 ? titles.length - 2 : 0;
return (
@@ -24,6 +26,8 @@ export function Breadcrumb({ presentation }: { presentation: 'full' | 'icon' | '
className={index < hiddenCount ? styles.visuallyHidden : styles.crumb}
data-after-ellipsis={hiddenCount > 0 && index === hiddenCount ? '' : undefined}
aria-current={index === titles.length - 1 ? 'location' : undefined}
+ data-ink={index === 0 && ink ? '' : undefined}
+ style={index === 0 && ink ? ({ '--crumb-ink': `var(--ink-${ink})` } as CSSProperties) : undefined}
>
{title}
diff --git a/app/src/shell/titlebar/TitleBar.module.css b/app/src/shell/titlebar/TitleBar.module.css
index bdc562d7..7b423d91 100644
--- a/app/src/shell/titlebar/TitleBar.module.css
+++ b/app/src/shell/titlebar/TitleBar.module.css
@@ -6,7 +6,8 @@
align-items: center;
gap: var(--space-3);
block-size: var(--size-title-bar);
- padding-inline-start: var(--space-4);
+ /* The end keeps the theme toggle's pressed fill off the window edge, as far in as it sits from the top. */
+ padding-inline: var(--space-4) var(--space-2);
overflow: hidden;
border-block-end: 1px solid var(--color-border-subtle);
background: var(--color-surface-app);
@@ -117,6 +118,18 @@
content: '\2026\00a0\203A' / '';
}
+ /* The notebook's color, a small dot before its name. */
+ .crumb[data-ink]::before {
+ content: '' / '';
+ display: inline-block;
+ inline-size: var(--space-3);
+ block-size: var(--space-3);
+ margin-inline: var(--space-2);
+ border-radius: var(--radius-pill);
+ background: var(--crumb-ink);
+ forced-color-adjust: none;
+ }
+
.crumb[aria-current] {
color: var(--color-text-primary);
}
diff --git a/app/src/shell/titlebar/TitleBar.test.tsx b/app/src/shell/titlebar/TitleBar.test.tsx
index 2c06c3f0..7f0d413d 100644
--- a/app/src/shell/titlebar/TitleBar.test.tsx
+++ b/app/src/shell/titlebar/TitleBar.test.tsx
@@ -63,6 +63,18 @@ describe('the title bar items', () => {
await expect.poll(() => document.title).toBe('Mitosis - OpenNote');
});
+ it('marks only the notebook in the breadcrumb with its color, as the tree does', async () => {
+ await renderApp();
+ act(() => navigate(mitosis));
+ const crumbs = await screen.findByRole('list', { name: 'Current location' });
+ await expect.poll(() => within(crumbs).queryAllByRole('listitem').length).toBe(3);
+ const items = within(crumbs).getAllByRole('listitem');
+ expect(items[0].hasAttribute('data-ink')).toBe(true);
+ expect((items[0] as HTMLElement).style.getPropertyValue('--crumb-ink')).toBe('var(--ink-fern)');
+ expect(items[1].hasAttribute('data-ink')).toBe(false);
+ expect(items[2].hasAttribute('data-ink')).toBe(false);
+ });
+
it('steps back and forward with the arrows, which say when there is nowhere to go', async () => {
await renderApp();
const back = () => within(banner()).getByRole('button', { name: 'Go back' });
@@ -76,6 +88,14 @@ describe('the title bar items', () => {
expect(back().getAttribute('aria-keyshortcuts')).toBe('Alt+ArrowLeft');
});
+ it('keeps the theme toggle off the window edge, as far in as it sits from the top', async () => {
+ await renderApp();
+ const bar = banner().getBoundingClientRect();
+ const toggle = within(banner()).getByRole('switch', { name: 'Dark mode' }).getBoundingClientRect();
+ expect(bar.right - toggle.right).toBeGreaterThan(0);
+ expect(Math.abs(bar.right - toggle.right - (toggle.top - bar.top))).toBeLessThan(1);
+ });
+
it('fits the narrowest medium window, with the arrows and the theme toggle still in the bar', async () => {
await setViewport(600, 700);
try {
diff --git a/app/src/strings/en/palette.ts b/app/src/strings/en/palette.ts
index 836729d0..090fd57f 100644
--- a/app/src/strings/en/palette.ts
+++ b/app/src/strings/en/palette.ts
@@ -23,7 +23,7 @@ export const palette = {
other: 'More results',
},
count: '{count, plural, one {# result} other {# results}}',
- none: 'No results',
+ none: 'No results.',
many: 'More than 100 results',
createPage: 'Create page "{title}"',
noMatchCreate: 'No pages match. Press Enter to create "{title}".',
diff --git a/app/src/strings/en/setup.ts b/app/src/strings/en/setup.ts
index a9f5db5a..076e62eb 100644
--- a/app/src/strings/en/setup.ts
+++ b/app/src/strings/en/setup.ts
@@ -52,7 +52,7 @@ export const setup = {
moveDetail: 'Moves OpenNote to your user folder. No administrator rights needed.',
keep: 'Keep it where it is',
keepDetail: '{path}',
- keepDetailReadOnly: "{path} OpenNote can't update itself in this folder.",
+ keepDetailReadOnly: "OpenNote can't update itself while it runs from {path}.",
inPlace: 'OpenNote already runs from your user folder, so there is nothing to move.',
development: 'This is a development build, so it stays where it is.',
},
diff --git a/app/src/strings/en/tree.ts b/app/src/strings/en/tree.ts
index 445f6e69..afb57683 100644
--- a/app/src/strings/en/tree.ts
+++ b/app/src/strings/en/tree.ts
@@ -119,7 +119,8 @@ export const tree = {
undo: 'Undo',
confirmTitle: 'Delete the notebook "{title}"?',
confirmBody:
- 'Its {count, plural, one {# page moves} other {# pages move}} to Trash, and you can restore them for 30 days.',
+ 'It moves to Trash with {count, plural, one {its # page} other {its # pages}}. You can restore it for 30 days.',
+ confirmBodyEmpty: 'It moves to Trash, and you can restore it for 30 days.',
confirm: 'Delete notebook',
},
undo: {
diff --git a/app/src/styles/layers.css b/app/src/styles/layers.css
index 1e77ea02..fa22b61a 100644
--- a/app/src/styles/layers.css
+++ b/app/src/styles/layers.css
@@ -1,5 +1,7 @@
/* The cascade layer order for every style sheet (ARCHITECTURE.md section 4.6). A later layer wins over an earlier
- one, whatever the selector's specificity. This file loads first, so the order is fixed before any rule.
+ one, whatever the selector's specificity. This file loads first in development. A build splits the CSS into
+ several files, and the layer-order plugin (app/scripts/layer-order.ts) starts each one with this statement.
+ The layers, lowest first:
- tokens: design values
- base: element defaults
- components: CSS modules
diff --git a/app/src/test/drawing.ts b/app/src/test/drawing.ts
new file mode 100644
index 00000000..a283e560
--- /dev/null
+++ b/app/src/test/drawing.ts
@@ -0,0 +1,43 @@
+// Geometry helpers for the drawing tests (docs/BRAND.md section 8). They find points along a shape in its drawing's
+// own coordinates and the top of a shelf or desk, and they check that things stand on their line.
+
+import { expect } from 'vitest';
+
+/** The drawing a test rendered. */
+export const drawn = (container: HTMLElement) => container.querySelector('svg') as SVGSVGElement;
+
+/** Points along a shape's outline in its own coordinates, before its own transform. */
+export const along = (shape: SVGGeometryElement, steps = 400) =>
+ Array.from({ length: steps + 1 }, (_, i) =>
+ DOMPoint.fromPoint(shape.getPointAtLength((i / steps) * shape.getTotalLength())),
+ );
+
+/** Points along a shape's outline in its drawing's own coordinates (the viewBox), after every transform. */
+export function outline(shape: SVGGeometryElement, steps = 400): DOMPoint[] {
+ const svg = shape.ownerSVGElement as SVGSVGElement;
+ const toDrawing = (svg.getScreenCTM() as DOMMatrix).inverse().multiply(shape.getScreenCTM() as DOMMatrix);
+ return along(shape, steps).map((p) => p.matrixTransform(toDrawing));
+}
+
+/** The outline of one shape, or of every shape in a group. */
+export const pointsOf = (thing: Element) =>
+ (thing instanceof SVGGeometryElement
+ ? [thing]
+ : [...thing.querySelectorAll('path, circle')]
+ ).flatMap((shape) => outline(shape));
+
+/** An empty state's shelf is the first line in its drawing. */
+export const shelfOf = (svg: SVGSVGElement) => svg.querySelector('g > path') as SVGGeometryElement;
+
+/** The top of a shelf or desk at a given x, from its outline. */
+export function surface(line: SVGGeometryElement): (x: number) => number {
+ const points = outline(line, 1000);
+ return (x) => Math.min(...points.filter((p) => Math.abs(p.x - x) <= 0.5).map((p) => p.y));
+}
+
+/** Things stand on a line: no point dips below it, and the lowest one is within a unit of it, so nothing floats. */
+export function expectStandsOn(points: DOMPoint[], at: (x: number) => number) {
+ const gaps = points.map((p) => at(p.x) - p.y);
+ expect(Math.min(...gaps)).toBeGreaterThanOrEqual(-0.01);
+ expect(Math.min(...gaps)).toBeLessThan(1);
+}
diff --git a/app/src/theme/tokens.css b/app/src/theme/tokens.css
index b2e89c7f..0367228d 100644
--- a/app/src/theme/tokens.css
+++ b/app/src/theme/tokens.css
@@ -22,12 +22,27 @@
--color-accent-primary-pressed: #2A4D38;
--color-accent-primary-subtle: #E2ECE1;
--color-accent-clay: #A9502F;
+ --color-accent-candle: #9A5410;
+ --color-accent-candle-subtle: #F8EBD3;
+ --color-accent-dusk: #9C4559;
+ --color-accent-dusk-subtle: #F6E4E4;
+ --color-accent-night: #4A5590;
+ --color-accent-night-subtle: #E8EAF3;
--color-status-success: #35704A;
--color-status-warning: #855B0C;
--color-status-danger: #A8342A;
--color-status-recording: #A8342A;
--color-focus-ring: #A9502F;
--color-selection-highlight: #F4DE93;
+ --color-art-moss: #C8DCC6;
+ --color-art-clay: #EDC3AF;
+ --color-art-candle: #F5D59C;
+ --color-art-dusk: #EDC0C9;
+ --color-art-night: #C7CEEA;
+ --color-ambient-canvas-top: #FFF0D4;
+ --color-ambient-canvas-bottom: #EAE3D8;
+ --color-ambient-glow: #FCE4B8;
+ --color-ambient-spark: #F2CF8A;
--color-caption-close-hover: #C42B1C;
--color-caption-close-hover-text: #FFFCF6;
--color-code-keyword: #6A4A9C;
@@ -122,7 +137,7 @@
--size-palette: 680px;
--size-palette-top: 90px;
--size-setup-card-width: 720px;
- --size-setup-card-height: 600px;
+ --size-setup-card-height: 672px;
--size-settings-nav: 240px;
--size-bottom-bar: 64px;
--size-menu-min-width: 208px;
@@ -194,12 +209,27 @@
--color-accent-primary-pressed: #7DB28C;
--color-accent-primary-subtle: #2E3B30;
--color-accent-clay: #E59372;
+ --color-accent-candle: #F0A860;
+ --color-accent-candle-subtle: #3A2C1C;
+ --color-accent-dusk: #EBA0B2;
+ --color-accent-dusk-subtle: #3A2A30;
+ --color-accent-night: #A9B4EC;
+ --color-accent-night-subtle: #1F2233;
--color-status-success: #8FC29D;
--color-status-warning: #E7BA62;
--color-status-danger: #F2907F;
--color-status-recording: #F2907F;
--color-focus-ring: #E7BA62;
--color-selection-highlight: #52451F;
+ --color-art-moss: #3E5A46;
+ --color-art-clay: #6A4332;
+ --color-art-candle: #6A4E27;
+ --color-art-dusk: #62404D;
+ --color-art-night: #383F6C;
+ --color-ambient-canvas-top: #1B2036;
+ --color-ambient-canvas-bottom: #33243A;
+ --color-ambient-glow: #45302C;
+ --color-ambient-spark: #E7D9B0;
--color-caption-close-hover: #C42B1C;
--color-caption-close-hover-text: #FFFCF6;
--color-code-keyword: #C2ABE8;
@@ -243,12 +273,27 @@
--color-accent-primary-pressed: #7DB28C;
--color-accent-primary-subtle: #2E3B30;
--color-accent-clay: #E59372;
+ --color-accent-candle: #F0A860;
+ --color-accent-candle-subtle: #3A2C1C;
+ --color-accent-dusk: #EBA0B2;
+ --color-accent-dusk-subtle: #3A2A30;
+ --color-accent-night: #A9B4EC;
+ --color-accent-night-subtle: #1F2233;
--color-status-success: #8FC29D;
--color-status-warning: #E7BA62;
--color-status-danger: #F2907F;
--color-status-recording: #F2907F;
--color-focus-ring: #E7BA62;
--color-selection-highlight: #52451F;
+ --color-art-moss: #3E5A46;
+ --color-art-clay: #6A4332;
+ --color-art-candle: #6A4E27;
+ --color-art-dusk: #62404D;
+ --color-art-night: #383F6C;
+ --color-ambient-canvas-top: #1B2036;
+ --color-ambient-canvas-bottom: #33243A;
+ --color-ambient-glow: #45302C;
+ --color-ambient-spark: #E7D9B0;
--color-caption-close-hover: #C42B1C;
--color-caption-close-hover-text: #FFFCF6;
--color-code-keyword: #C2ABE8;
@@ -293,12 +338,27 @@
--color-accent-primary-pressed: #2A4D38;
--color-accent-primary-subtle: #E2ECE1;
--color-accent-clay: #A9502F;
+ --color-accent-candle: #9A5410;
+ --color-accent-candle-subtle: #F8EBD3;
+ --color-accent-dusk: #9C4559;
+ --color-accent-dusk-subtle: #F6E4E4;
+ --color-accent-night: #4A5590;
+ --color-accent-night-subtle: #E8EAF3;
--color-status-success: #35704A;
--color-status-warning: #855B0C;
--color-status-danger: #A8342A;
--color-status-recording: #A8342A;
--color-focus-ring: #A9502F;
--color-selection-highlight: #F4DE93;
+ --color-art-moss: #C8DCC6;
+ --color-art-clay: #EDC3AF;
+ --color-art-candle: #F5D59C;
+ --color-art-dusk: #EDC0C9;
+ --color-art-night: #C7CEEA;
+ --color-ambient-canvas-top: #FFF0D4;
+ --color-ambient-canvas-bottom: #EAE3D8;
+ --color-ambient-glow: #FCE4B8;
+ --color-ambient-spark: #F2CF8A;
--color-caption-close-hover: #C42B1C;
--color-caption-close-hover-text: #FFFCF6;
--color-code-keyword: #6A4A9C;
@@ -340,12 +400,27 @@
--color-accent-primary-pressed: #7DB28C;
--color-accent-primary-subtle: #2E3B30;
--color-accent-clay: #E59372;
+ --color-accent-candle: #F0A860;
+ --color-accent-candle-subtle: #3A2C1C;
+ --color-accent-dusk: #EBA0B2;
+ --color-accent-dusk-subtle: #3A2A30;
+ --color-accent-night: #A9B4EC;
+ --color-accent-night-subtle: #1F2233;
--color-status-success: #8FC29D;
--color-status-warning: #E7BA62;
--color-status-danger: #F2907F;
--color-status-recording: #F2907F;
--color-focus-ring: #E7BA62;
--color-selection-highlight: #52451F;
+ --color-art-moss: #3E5A46;
+ --color-art-clay: #6A4332;
+ --color-art-candle: #6A4E27;
+ --color-art-dusk: #62404D;
+ --color-art-night: #383F6C;
+ --color-ambient-canvas-top: #1B2036;
+ --color-ambient-canvas-bottom: #33243A;
+ --color-ambient-glow: #45302C;
+ --color-ambient-spark: #E7D9B0;
--color-caption-close-hover: #C42B1C;
--color-caption-close-hover-text: #FFFCF6;
--color-code-keyword: #C2ABE8;
@@ -413,6 +488,21 @@
--color-accent-primary-pressed: Highlight;
--color-accent-primary-subtle: Canvas;
--color-accent-clay: CanvasText;
+ --color-accent-candle: CanvasText;
+ --color-accent-candle-subtle: Canvas;
+ --color-accent-dusk: CanvasText;
+ --color-accent-dusk-subtle: Canvas;
+ --color-accent-night: CanvasText;
+ --color-accent-night-subtle: Canvas;
+ --color-art-moss: Canvas;
+ --color-art-clay: Canvas;
+ --color-art-candle: Canvas;
+ --color-art-dusk: Canvas;
+ --color-art-night: Canvas;
+ --color-ambient-canvas-top: Canvas;
+ --color-ambient-canvas-bottom: Canvas;
+ --color-ambient-glow: Canvas;
+ --color-ambient-spark: Canvas;
--color-status-success: CanvasText;
--color-status-warning: CanvasText;
--color-status-danger: CanvasText;
diff --git a/app/src/theme/tokens.test.ts b/app/src/theme/tokens.test.ts
index dcc6d1dd..34cf2915 100644
--- a/app/src/theme/tokens.test.ts
+++ b/app/src/theme/tokens.test.ts
@@ -72,6 +72,19 @@ describe('page and ink tokens for Phases 4 and 5', () => {
kinds.forEach((kind) => expect(tokens.forcedColors).toHaveProperty([`code.${kind}`], 'CanvasText'));
});
+ it('defines the warm accents and the ambient sky in both themes, with forced colors for each', () => {
+ const accents = ['candle', 'candleSubtle', 'dusk', 'duskSubtle', 'night', 'nightSubtle'];
+ const ambient = ['canvasTop', 'canvasBottom', 'glow', 'spark'];
+ accents.forEach((key) => {
+ expect(tokens.color.light.accent).toHaveProperty(key);
+ expect(tokens.color.dark.accent).toHaveProperty(key);
+ expect(tokens.forcedColors).toHaveProperty([`accent.${key}`], key.endsWith('Subtle') ? 'Canvas' : 'CanvasText');
+ });
+ expect(Object.keys(tokens.color.light.ambient)).toEqual(ambient);
+ expect(Object.keys(tokens.color.dark.ambient)).toEqual(ambient);
+ ambient.forEach((key) => expect(tokens.forcedColors).toHaveProperty([`ambient.${key}`], 'Canvas'));
+ });
+
it('keeps palm rejection and shape timings in milliseconds for code', () => {
const { interaction } = tokens;
expect(interaction.palmWatchdogMs).toBeGreaterThan(interaction.palmGraceMs);
@@ -85,3 +98,28 @@ describe('cascade layers', () => {
expect(css).toContain('@layer tokens, base, components, states, forced;');
});
});
+
+describe('drawing tints', () => {
+ it('defines the five drawing tints in both themes, and flattens them to the canvas in forced colors', () => {
+ const tints = ['moss', 'clay', 'candle', 'dusk', 'night'];
+ expect(Object.keys(tokens.color.light.art)).toEqual(tints);
+ expect(Object.keys(tokens.color.dark.art)).toEqual(tints);
+ tints.forEach((key) => expect(tokens.forcedColors).toHaveProperty([`art.${key}`], 'Canvas'));
+ });
+});
+
+describe('the ambient canvas', () => {
+ it('keeps the ambient wash close to the canvas, so it never competes with the page', () => {
+ const ceilings = tokens.contrast.filter((pair) => 'max' in pair);
+ expect(ceilings.map((pair) => pair.fg)).toEqual(['ambient.canvasTop', 'ambient.canvasBottom', 'ambient.glow']);
+ // The sunset band is the strongest tone, and the ceiling sits just above it.
+ ceilings.forEach((pair) => expect(pair).toMatchObject({ bg: 'surface.sunken', max: 1.5 }));
+ });
+
+ it('holds text on every ambient tone to the same targets as on the canvas', () => {
+ const on = (bg: string) =>
+ tokens.contrast.flatMap((pair) => (pair.bg === bg && 'min' in pair ? [[pair.fg, pair.min]] : []));
+ expect(on('ambient.glow')).toEqual(on('ambient.canvasTop'));
+ expect(on('ambient.canvasBottom')).toEqual(on('ambient.canvasTop'));
+ });
+});
diff --git a/app/src/theme/tokens.ts b/app/src/theme/tokens.ts
index 0a51e3ad..db8af1b3 100644
--- a/app/src/theme/tokens.ts
+++ b/app/src/theme/tokens.ts
@@ -3,7 +3,7 @@
export const tokens = {
"meta": {
"name": "OpenNote design tokens",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Single source of truth for OpenNote's look and motion. See docs/BRAND.md for how each token is used. CHECKS validates contrast and motion limits."
},
"color": {
@@ -34,7 +34,13 @@ export const tokens = {
"primaryHover": "#325C42",
"primaryPressed": "#2A4D38",
"primarySubtle": "#E2ECE1",
- "clay": "#A9502F"
+ "clay": "#A9502F",
+ "candle": "#9A5410",
+ "candleSubtle": "#F8EBD3",
+ "dusk": "#9C4559",
+ "duskSubtle": "#F6E4E4",
+ "night": "#4A5590",
+ "nightSubtle": "#E8EAF3"
},
"status": {
"success": "#35704A",
@@ -48,6 +54,19 @@ export const tokens = {
"selection": {
"highlight": "#F4DE93"
},
+ "art": {
+ "moss": "#C8DCC6",
+ "clay": "#EDC3AF",
+ "candle": "#F5D59C",
+ "dusk": "#EDC0C9",
+ "night": "#C7CEEA"
+ },
+ "ambient": {
+ "canvasTop": "#FFF0D4",
+ "canvasBottom": "#EAE3D8",
+ "glow": "#FCE4B8",
+ "spark": "#F2CF8A"
+ },
"caption": {
"closeHover": "#C42B1C",
"closeHoverText": "#FFFCF6"
@@ -90,7 +109,13 @@ export const tokens = {
"primaryHover": "#A2CEAE",
"primaryPressed": "#7DB28C",
"primarySubtle": "#2E3B30",
- "clay": "#E59372"
+ "clay": "#E59372",
+ "candle": "#F0A860",
+ "candleSubtle": "#3A2C1C",
+ "dusk": "#EBA0B2",
+ "duskSubtle": "#3A2A30",
+ "night": "#A9B4EC",
+ "nightSubtle": "#1F2233"
},
"status": {
"success": "#8FC29D",
@@ -104,6 +129,19 @@ export const tokens = {
"selection": {
"highlight": "#52451F"
},
+ "art": {
+ "moss": "#3E5A46",
+ "clay": "#6A4332",
+ "candle": "#6A4E27",
+ "dusk": "#62404D",
+ "night": "#383F6C"
+ },
+ "ambient": {
+ "canvasTop": "#1B2036",
+ "canvasBottom": "#33243A",
+ "glow": "#45302C",
+ "spark": "#E7D9B0"
+ },
"caption": {
"closeHover": "#C42B1C",
"closeHoverText": "#FFFCF6"
@@ -140,6 +178,21 @@ export const tokens = {
"accent.primaryPressed": "Highlight",
"accent.primarySubtle": "Canvas",
"accent.clay": "CanvasText",
+ "accent.candle": "CanvasText",
+ "accent.candleSubtle": "Canvas",
+ "accent.dusk": "CanvasText",
+ "accent.duskSubtle": "Canvas",
+ "accent.night": "CanvasText",
+ "accent.nightSubtle": "Canvas",
+ "art.moss": "Canvas",
+ "art.clay": "Canvas",
+ "art.candle": "Canvas",
+ "art.dusk": "Canvas",
+ "art.night": "Canvas",
+ "ambient.canvasTop": "Canvas",
+ "ambient.canvasBottom": "Canvas",
+ "ambient.glow": "Canvas",
+ "ambient.spark": "Canvas",
"status.success": "CanvasText",
"status.warning": "CanvasText",
"status.danger": "CanvasText",
@@ -567,6 +620,236 @@ export const tokens = {
"bg": "ink.highlighters.Lilac",
"over": "surface.page",
"min": 7
+ },
+ {
+ "fg": "accent.candle",
+ "bg": "surface.page",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.candle",
+ "bg": "surface.app",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.candle",
+ "bg": "surface.raised",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.candle",
+ "bg": "surface.sunken",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.candle",
+ "bg": "accent.candleSubtle",
+ "min": 4.5
+ },
+ {
+ "fg": "text.primary",
+ "bg": "accent.candleSubtle",
+ "min": 7
+ },
+ {
+ "fg": "text.muted",
+ "bg": "accent.candleSubtle",
+ "min": 4.5
+ },
+ {
+ "fg": "border.control",
+ "bg": "accent.candleSubtle",
+ "min": 3
+ },
+ {
+ "fg": "accent.dusk",
+ "bg": "surface.page",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.dusk",
+ "bg": "surface.app",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.dusk",
+ "bg": "surface.raised",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.dusk",
+ "bg": "surface.sunken",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.dusk",
+ "bg": "accent.duskSubtle",
+ "min": 4.5
+ },
+ {
+ "fg": "text.primary",
+ "bg": "accent.duskSubtle",
+ "min": 7
+ },
+ {
+ "fg": "text.muted",
+ "bg": "accent.duskSubtle",
+ "min": 4.5
+ },
+ {
+ "fg": "border.control",
+ "bg": "accent.duskSubtle",
+ "min": 3
+ },
+ {
+ "fg": "accent.night",
+ "bg": "surface.page",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.night",
+ "bg": "surface.app",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.night",
+ "bg": "surface.raised",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.night",
+ "bg": "surface.sunken",
+ "min": 4.5
+ },
+ {
+ "fg": "accent.night",
+ "bg": "accent.nightSubtle",
+ "min": 4.5
+ },
+ {
+ "fg": "text.primary",
+ "bg": "accent.nightSubtle",
+ "min": 7
+ },
+ {
+ "fg": "text.muted",
+ "bg": "accent.nightSubtle",
+ "min": 4.5
+ },
+ {
+ "fg": "border.control",
+ "bg": "accent.nightSubtle",
+ "min": 3
+ },
+ {
+ "fg": "text.primary",
+ "bg": "ambient.canvasTop",
+ "min": 7
+ },
+ {
+ "fg": "text.secondary",
+ "bg": "ambient.canvasTop",
+ "min": 4.5
+ },
+ {
+ "fg": "text.muted",
+ "bg": "ambient.canvasTop",
+ "min": 4.5
+ },
+ {
+ "fg": "text.link",
+ "bg": "ambient.canvasTop",
+ "min": 4.5
+ },
+ {
+ "fg": "focus.ring",
+ "bg": "ambient.canvasTop",
+ "min": 3
+ },
+ {
+ "fg": "border.control",
+ "bg": "ambient.canvasTop",
+ "min": 3
+ },
+ {
+ "fg": "text.primary",
+ "bg": "ambient.canvasBottom",
+ "min": 7
+ },
+ {
+ "fg": "text.secondary",
+ "bg": "ambient.canvasBottom",
+ "min": 4.5
+ },
+ {
+ "fg": "text.muted",
+ "bg": "ambient.canvasBottom",
+ "min": 4.5
+ },
+ {
+ "fg": "text.link",
+ "bg": "ambient.canvasBottom",
+ "min": 4.5
+ },
+ {
+ "fg": "focus.ring",
+ "bg": "ambient.canvasBottom",
+ "min": 3
+ },
+ {
+ "fg": "border.control",
+ "bg": "ambient.canvasBottom",
+ "min": 3
+ },
+ {
+ "fg": "text.primary",
+ "bg": "ambient.glow",
+ "min": 7
+ },
+ {
+ "fg": "text.secondary",
+ "bg": "ambient.glow",
+ "min": 4.5
+ },
+ {
+ "fg": "text.muted",
+ "bg": "ambient.glow",
+ "min": 4.5
+ },
+ {
+ "fg": "text.link",
+ "bg": "ambient.glow",
+ "min": 4.5
+ },
+ {
+ "fg": "focus.ring",
+ "bg": "ambient.glow",
+ "min": 3
+ },
+ {
+ "fg": "border.control",
+ "bg": "ambient.glow",
+ "min": 3
+ },
+ {
+ "fg": "status.success",
+ "bg": "surface.app",
+ "min": 4.5
+ },
+ {
+ "fg": "ambient.canvasTop",
+ "bg": "surface.sunken",
+ "max": 1.5
+ },
+ {
+ "fg": "ambient.canvasBottom",
+ "bg": "surface.sunken",
+ "max": 1.5
+ },
+ {
+ "fg": "ambient.glow",
+ "bg": "surface.sunken",
+ "max": 1.5
}
],
"ink": {
@@ -741,7 +1024,7 @@ export const tokens = {
"palette": 680,
"paletteTop": 90,
"setupCardWidth": 720,
- "setupCardHeight": 600,
+ "setupCardHeight": 672,
"settingsNav": 240,
"bottomBar": 64,
"menuMinWidth": 208,
diff --git a/app/src/ui/Button.module.css b/app/src/ui/Button.module.css
index 38dbfc65..86b53944 100644
--- a/app/src/ui/Button.module.css
+++ b/app/src/ui/Button.module.css
@@ -104,11 +104,18 @@
.button[aria-disabled='true'],
.iconButton:disabled,
.iconButton[aria-disabled='true'] {
- border-color: var(--color-border-subtle);
background: transparent;
color: var(--color-text-muted);
cursor: default;
}
+
+ /* Only a button with a visible border keeps a faint one when disabled. A borderless button never gains a box,
+ or a disabled one would stand out more than the enabled one beside it. */
+ .secondary:is(:disabled, [aria-disabled='true']),
+ .primary:is(:disabled, [aria-disabled='true']),
+ .danger[data-fill]:is(:disabled, [aria-disabled='true']) {
+ border-color: var(--color-border-subtle);
+ }
}
@layer states {
diff --git a/app/src/ui/Button.test.tsx b/app/src/ui/Button.test.tsx
index e44407a7..20c0cb84 100644
--- a/app/src/ui/Button.test.tsx
+++ b/app/src/ui/Button.test.tsx
@@ -112,6 +112,19 @@ describe('IconButton', () => {
);
}
+ it('draws no box around a disabled icon button, as around an enabled one', () => {
+ renderUi(
+ <>
+
+
+ >,
+ );
+ const border = (name: string) => getComputedStyle(screen.getByRole('button', { name })).borderTopColor;
+ expect(border('Unavailable')).toBe(border('Settings'));
+ // A disabled secondary button keeps a faint border, so the enabled icon button's is the transparent one.
+ expect(border('Bordered')).not.toBe(border('Settings'));
+ });
+
it('is named by its label and gives its command’s shortcut to assistive technology', () => {
renderUi();
const button = screen.getByRole('button', { name: 'Settings' });
diff --git a/app/src/ui/illustrations/Art.tsx b/app/src/ui/illustrations/Art.tsx
new file mode 100644
index 00000000..38f38d61
--- /dev/null
+++ b/app/src/ui/illustrations/Art.tsx
@@ -0,0 +1,34 @@
+// The root of every drawing. It is hidden from screen readers, because an empty state's meaning stays in its text,
+// and it can't take focus. The box is the drawing's own coordinate space; width and height are the drawn size, so a
+// drawing can be shown smaller than it was drawn.
+
+import type { ReactNode } from 'react';
+import styles from './illustrations.module.css';
+
+export interface ArtProps {
+ /** Adds a class for the place, such as a corner that hides the drawing when it is too narrow. */
+ className?: string;
+}
+
+interface FrameProps extends ArtProps {
+ width: number;
+ height: number;
+ /** The drawing's own size, when it differs from the drawn size. */
+ box?: readonly [number, number];
+ children: ReactNode;
+}
+
+export function Art({ width, height, box = [width, height], className, children }: FrameProps) {
+ return (
+
+ );
+}
diff --git a/app/src/ui/illustrations/Books.tsx b/app/src/ui/illustrations/Books.tsx
new file mode 100644
index 00000000..6d4f04cc
--- /dev/null
+++ b/app/src/ui/illustrations/Books.tsx
@@ -0,0 +1,27 @@
+// A stack of four books, 64 x 40, each spine its own tint, with a clay bookmark ribbon. They lie flat, spines
+// alternating, each a little shorter than the one below, like a tidy pile.
+
+import { Art } from './Art';
+import type { ArtProps } from './Art';
+import styles from './illustrations.module.css';
+
+export function BooksArt() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+export function Books({ className }: ArtProps) {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/ui/illustrations/Candle.tsx b/app/src/ui/illustrations/Candle.tsx
new file mode 100644
index 00000000..89d0246a
--- /dev/null
+++ b/app/src/ui/illustrations/Candle.tsx
@@ -0,0 +1,37 @@
+// A lit candle in a clay dish, 28 x 58. The flame is the one place the candle color shows; the warm glow around it
+// is a flat radial gradient in the candle tint that fades to nothing. The flame never flickers.
+
+import { useId } from 'react';
+import { Art } from './Art';
+import type { ArtProps } from './Art';
+import styles from './illustrations.module.css';
+
+export function CandleArt() {
+ const glow = useId();
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export function Candle({ className }: ArtProps) {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/ui/illustrations/DeskScene.tsx b/app/src/ui/illustrations/DeskScene.tsx
new file mode 100644
index 00000000..33139ab0
--- /dev/null
+++ b/app/src/ui/illustrations/DeskScene.tsx
@@ -0,0 +1,40 @@
+// The desk by the window, drawn at 240 x 150 and shown at 360 x 225, for the welcome step. The window shows its
+// sunset or its stars, with a vine along its frame and a plant on the sill. On the warm desk below are a stack of
+// four books, an open notebook with the moss stroke, and two lit candles of different heights. It is built from the
+// same parts as the smaller drawings, and it is the only place the whole desk appears.
+
+import { Art } from './Art';
+import type { ArtProps } from './Art';
+import { BooksArt } from './Books';
+import { CandleArt } from './Candle';
+import styles from './illustrations.module.css';
+import { NotebookArt } from './Notebook';
+import { PlantArt } from './Plant';
+import { WindowArt } from './Window';
+import type { Sky } from './Window';
+
+export function DeskScene({ sky, className }: ArtProps & { sky: Sky }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/ui/illustrations/EmptyArt.tsx b/app/src/ui/illustrations/EmptyArt.tsx
new file mode 100644
index 00000000..e8bfefc0
--- /dev/null
+++ b/app/src/ui/illustrations/EmptyArt.tsx
@@ -0,0 +1,83 @@
+// The drawing above the sentence of an empty state: drawn 110 wide and shown at 176 px, about 85 px tall, and
+// different for each place. "No notebooks yet" gets a bookshelf with a leaning book and a small potted plant whose
+// tendril trails down to the shelf. "No page open" gets an open notebook with a candle beside it. "Trash is empty"
+// gets a candle beside a tidy stack of four books. The sentence keeps the meaning, and the drawing is only
+// company.
+
+import { Art } from './Art';
+import type { ArtProps } from './Art';
+import { BooksArt } from './Books';
+import { CandleArt } from './Candle';
+import { NotebookArt } from './Notebook';
+import styles from './illustrations.module.css';
+
+export type EmptyKind = 'notebooks' | 'page' | 'trash';
+
+const SHELF = 'M6 70.5C40 71.2 80 71.2 114 70.4';
+
+function Shelf() {
+ return (
+ <>
+
+
+ {/* The leaning book stands on its bottom-left corner, (48, 70.4), with its bottom-right corner lifted, and its
+ left edge rests on the moss book's top-right corner, (39, 41.4): a 17.7 degree lean. The tests check that
+ no part of it dips below the shelf or into the moss book. */}
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export function EmptyArt({ kind, className }: ArtProps & { kind: EmptyKind }) {
+ // Each drawing is cropped to its content, so there is no empty paper above it, and its shelf starts where the
+ // sentence below it starts.
+ const shelf = kind === 'notebooks';
+ const height = shelf ? 52 : 54;
+ return (
+
+
+
+ {shelf && }
+ {kind === 'page' && (
+
+
+
+ )}
+ {kind === 'trash' && (
+
+
+
+ )}
+ {!shelf && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/src/ui/illustrations/Glint.tsx b/app/src/ui/illustrations/Glint.tsx
new file mode 100644
index 00000000..36c056e5
--- /dev/null
+++ b/app/src/ui/illustrations/Glint.tsx
@@ -0,0 +1,25 @@
+// The small mark in a theme card's preview, 16 x 16: a low sun for Light, and a crescent moon with two stars for
+// Dark. It is decoration inside a preview that screen readers and Tab already skip.
+
+import { Art } from './Art';
+import type { ArtProps } from './Art';
+import styles from './illustrations.module.css';
+
+export function Glint({ kind, className }: ArtProps & { kind: 'sun' | 'moon' }) {
+ return (
+
+ {kind === 'sun' ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/app/src/ui/illustrations/InkStroke.tsx b/app/src/ui/illustrations/InkStroke.tsx
new file mode 100644
index 00000000..b16d4ced
--- /dev/null
+++ b/app/src/ui/illustrations/InkStroke.tsx
@@ -0,0 +1,21 @@
+// The signature stroke, 56 x 8: moss ink ending in a clay dot, like the logo's pen. It goes under the setup
+// headings and the About heading, and it is always paired with text.
+
+import styles from './illustrations.module.css';
+import type { ArtProps } from './Art';
+
+export function InkStroke({ className }: ArtProps) {
+ return (
+
+ );
+}
diff --git a/app/src/ui/illustrations/Notebook.tsx b/app/src/ui/illustrations/Notebook.tsx
new file mode 100644
index 00000000..2da2d841
--- /dev/null
+++ b/app/src/ui/illustrations/Notebook.tsx
@@ -0,0 +1,28 @@
+// An open notebook, 64 x 40, with warm paper pages in a moss cover that shows at the edges. On the right-hand page
+// is the logo's ink: a moss stroke ending in a clay dot. Lying open, it rests on its spine, the lowest point of the
+// cover, and its outer edges lift a little.
+
+import { Art } from './Art';
+import type { ArtProps } from './Art';
+import styles from './illustrations.module.css';
+
+export function NotebookArt() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+export function Notebook({ className }: ArtProps) {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/ui/illustrations/Plant.tsx b/app/src/ui/illustrations/Plant.tsx
new file mode 100644
index 00000000..a07bb352
--- /dev/null
+++ b/app/src/ui/illustrations/Plant.tsx
@@ -0,0 +1,42 @@
+// A potted plant with a trailing vine, 56 x 66. Its leaves are moss and its pot is clay. The vine spills over the
+// rim and trails along the surface to the left with three small leaves. It is drawn to be shown smaller, in the
+// notebooks pane footer and on the window sill of the desk scene. No part of it hangs below the pot's base.
+
+import { Art } from './Art';
+import type { ArtProps } from './Art';
+import styles from './illustrations.module.css';
+
+/** The three small leaves on the trailing vine, as one path: each leaf is two curves from its stem to its tip. */
+const TRAILING_LEAVES = [
+ 'M16.6 51.6Q14.9 47.6 11 49.5Q12.7 53.6 16.6 51.6Z',
+ 'M10.4 60.4Q14 58.2 11.4 55Q7.7 57.1 10.4 60.4Z',
+ 'M4.6 62.4Q5.8 58.5 1.7 58.3Q0.5 62.2 4.6 62.4Z',
+].join('');
+
+export function PlantArt() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+/** The plant at the size of the notebooks pane footer. */
+export function Plant({ className }: ArtProps) {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/ui/illustrations/StarField.tsx b/app/src/ui/illustrations/StarField.tsx
new file mode 100644
index 00000000..0d06e292
--- /dev/null
+++ b/app/src/ui/illustrations/StarField.tsx
@@ -0,0 +1,52 @@
+// Twenty-five stars on the evening sky, pinned to the top 160 px of the page canvas and hidden behind the page. Most
+// dots are 0.75 to 1.25 px in radius at 45 to 70 percent opacity, and three larger ones, 1.7 to 1.8 px, shine at
+// 75 to 80 percent. They don't twinkle. The page card starts 32 px down, so every star stays above its top edge, at
+// least 5 px clear of it. The card's side edges move with the canvas width, so no star sits lower, where one could
+// land on a side edge.
+
+import styles from './illustrations.module.css';
+import type { ArtProps } from './Art';
+
+const STARS = [
+ [44, 18, 1.1, 0.55],
+ [131, 9, 0.9, 0.5],
+ [205, 22, 1.7, 0.75],
+ [262, 11, 1.1, 0.6],
+ [308, 24, 0.9, 0.5],
+ [352, 17, 1.25, 0.7],
+ [398, 8, 0.8, 0.5],
+ [418, 22, 0.9, 0.55],
+ [462, 14, 1.1, 0.6],
+ [507, 12, 1.1, 0.6],
+ [556, 23, 0.8, 0.5],
+ [611, 25, 1.25, 0.7],
+ [655, 8, 1.8, 0.8],
+ [703, 9, 0.8, 0.5],
+ [748, 19, 1, 0.55],
+ [790, 22, 1.1, 0.65],
+ [836, 10, 0.9, 0.55],
+ [882, 14, 1.25, 0.7],
+ [947, 24, 0.9, 0.55],
+ [984, 9, 1.1, 0.6],
+ [1044, 13, 1.7, 0.75],
+ [1088, 23, 0.75, 0.45],
+ [1121, 21, 1, 0.55],
+ [1203, 8, 0.9, 0.5],
+ [1251, 19, 1.1, 0.55],
+] as const;
+
+export function StarField({ className }: ArtProps) {
+ return (
+
+ );
+}
diff --git a/app/src/ui/illustrations/Window.tsx b/app/src/ui/illustrations/Window.tsx
new file mode 100644
index 00000000..5174ad07
--- /dev/null
+++ b/app/src/ui/illustrations/Window.tsx
@@ -0,0 +1,90 @@
+// An arched window, 100 x 103: a sunset sky by day, and stars with a crescent moon in the evening. The sill and the
+// panes are lines. The sky is a soft gradient from the tints. By day, paper light turns to a candle and dusk band
+// at the hills. In the evening, deep night lightens to indigo at the horizon.
+//
+// The hills, the sun, and the moon are flat fills from the tokens, and the stars are dots. A moss vine twines in
+// and out of the frame down the left of the arch, with seven small leaves.
+
+import { useId } from 'react';
+import { Art } from './Art';
+import type { ArtProps } from './Art';
+import styles from './illustrations.module.css';
+
+export type Sky = 'day' | 'night';
+
+/** Six stars, each a zero-length stroke that the round cap turns into a dot. */
+const STARS = 'M20 36h.1M37 21h.1M63 17h.1M88 47h.1M40 70h.1M66 66h.1';
+
+/** The sky's color stops, top to bottom: the sunset band sits behind the sun, and the night lightens at the hills. */
+const STOPS = {
+ day: [
+ [0, styles.stopPaper],
+ [0.35, styles.stopPaper],
+ [0.6, styles.stopCandle],
+ [0.85, styles.stopDusk],
+ ],
+ night: [
+ [0, styles.stopDeep],
+ [0.45, styles.stopDeep],
+ [0.9, styles.stopNight],
+ ],
+} as const;
+
+/** The vine along the arch, and its leaves as one path: each leaf is two curves from its stem to its tip. */
+const VINE = [
+ 'M37.5 3.8C35.9 5.2 31.7 10.2 28 12.3 24.3 14.4 18 13.6 15.4 16.4 12.9 19.2 14.6 25.5 12.7 29.3',
+ '10.8 33.1 5 35.9 4.2 39.4 3.4 42.9 7.9 47.5 8.1 50.4 8.3 53.4 5.9 55.9 5.5 57',
+].join(' ');
+const LEAVES = [
+ 'M37.5 3.8Q40.5 6.7 42.4 2.9Q39.3 0 37.5 3.8Z',
+ 'M28 12.3Q26.7 16.7 31.2 17.4Q32.5 13 28 12.3Z',
+ 'M15.4 16.4Q15.5 11.9 11 12.4Q10.9 16.9 15.4 16.4Z',
+ 'M12.7 29.3Q16.7 31.5 18.3 27.3Q14.4 25.1 12.7 29.3Z',
+ 'M4.2 39.4Q8.5 38.7 7 34.7Q2.6 35.3 4.2 39.4Z',
+ 'M8.1 50.4Q9.5 54.6 13.3 52.3Q11.8 48.2 8.1 50.4Z',
+ 'M5.5 57Q2.6 60.3 6.4 62.4Q9.3 59.1 5.5 57Z',
+].join('');
+
+function Heavens({ sky }: { sky: Sky }) {
+ if (sky === 'day') return ;
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export function WindowArt({ sky }: { sky: Sky }) {
+ const gradient = useId();
+ return (
+ <>
+
+
+ {STOPS[sky].map(([offset, className]) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+/** The window at its drawn size, or at a given height with the same proportions. */
+export function Window({ sky, className, height = 103 }: ArtProps & { sky: Sky; height?: number }) {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/ui/illustrations/geometry.test.tsx b/app/src/ui/illustrations/geometry.test.tsx
new file mode 100644
index 00000000..c8c77e9e
--- /dev/null
+++ b/app/src/ui/illustrations/geometry.test.tsx
@@ -0,0 +1,147 @@
+// Where the drawings' things stand and meet, and that every line stays inside its box (docs/BRAND.md section 8).
+
+import { afterEach, describe, expect, it } from 'vitest';
+import { renderUi } from '../../test';
+import { along, drawn, expectStandsOn, outline, pointsOf, shelfOf, surface } from '../../test/drawing';
+import { Books, Candle, DeskScene, EmptyArt, StarField, Window } from './index';
+
+afterEach(() => {
+ document.documentElement.removeAttribute('data-theme');
+});
+
+describe('how books rest', () => {
+ it('stacks each book on the one below it, neither floating above it nor sinking into it', () => {
+ const books = [...drawn(renderUi().container).querySelectorAll('path[class]')];
+ const stack = books.slice(0, 4);
+ for (let i = 1; i < stack.length; i++) {
+ const below = surface(stack[i - 1]);
+ const gaps = outline(stack[i]).map((p) => below(p.x) - p.y);
+ // The two edges share a line, so they may meet within half a 1.5 unit stroke of each other.
+ expect(Math.min(...gaps)).toBeGreaterThanOrEqual(-0.75);
+ expect(Math.min(...gaps)).toBeLessThan(0.75);
+ }
+ });
+
+ it('leans the third book on its bottom-left corner against the moss book, above the shelf', () => {
+ const svg = drawn(renderUi().container);
+ const leaning = svg.querySelector('path[transform]') as SVGGeometryElement;
+ const upright = leaning.previousElementSibling as SVGGeometryElement;
+ const shelf = surface(shelfOf(svg));
+ const lean = outline(leaning);
+ expectStandsOn(lean, shelf);
+ // Its lowest point is the bottom-left corner, and the bottom-right corner is lifted off the shelf.
+ const lowest = lean.reduce((a, b) => (b.y > a.y ? b : a));
+ expect([lowest.x, lowest.y]).toEqual([lean[0].x, lean[0].y]);
+ // It rests on the moss book without passing into it: no outline point of one is inside the other. Both are
+ // compared in their shared parent's coordinates, where the moss book has no transform of its own.
+ const tilt = (leaning.transform.baseVal.consolidate() as SVGTransform).matrix;
+ const leaningEdge = along(leaning).map((p) => p.matrixTransform(tilt));
+ const uprightEdge = along(upright);
+ expect(leaningEdge.filter((p) => upright.isPointInFill(p))).toEqual([]);
+ expect(uprightEdge.filter((p) => leaning.isPointInFill(p.matrixTransform(tilt.inverse())))).toEqual([]);
+ let touch = Infinity;
+ for (const a of leaningEdge) for (const b of uprightEdge) touch = Math.min(touch, Math.hypot(a.x - b.x, a.y - b.y));
+ expect(touch).toBeLessThan(0.3);
+ });
+});
+
+describe('where things stand', () => {
+ it.each(['notebooks', 'page', 'trash'] as const)('stands everything in the %s drawing on the shelf', (kind) => {
+ const svg = drawn(renderUi().container);
+ const shelf = surface(shelfOf(svg));
+ // The shelf drawing's objects are single shapes; the others are a drawing and a candle, each in a group.
+ const things = svg.querySelectorAll(kind === 'notebooks' ? 'g > path:not(:first-child)' : 'g g');
+ // Each object near the shelf (a book, the pot, the plant's tendril, the notebook, the stack, the candle) stands
+ // on it, never below it.
+ const near = [...things].map(pointsOf).filter((points) => points.some((p) => shelf(p.x) - p.y < 1));
+ for (const points of near) expectStandsOn(points, shelf);
+ expect(near).toHaveLength(kind === 'notebooks' ? 5 : 2);
+ });
+
+ it('stands the books, notebook and both candles on the desk, and the plant on the window sill', () => {
+ const svg = drawn(renderUi().container);
+ const [frame, plant, books, notebook, candle, shortCandle] = [...svg.querySelectorAll(':scope > g')];
+ const desk = surface(svg.querySelector(':scope > path') as SVGGeometryElement);
+ for (const thing of [books, notebook, candle, shortCandle]) expectStandsOn(pointsOf(thing), desk);
+ // The second candle is the shorter one, and the two dishes don't overlap.
+ const extent = (thing: Element) => {
+ const points = pointsOf(thing.querySelector('path:nth-of-type(5)') as Element);
+ return [Math.min(...points.map((p) => p.x)), Math.max(...points.map((p) => p.x))];
+ };
+ expect(shortCandle.getBoundingClientRect().height).toBeLessThan(candle.getBoundingClientRect().height);
+ expect(extent(shortCandle)[0]).toBeGreaterThan(extent(candle)[1]);
+ const sill = outline(frame.querySelector('path:last-child') as SVGGeometryElement);
+ const sillTop = Math.min(...sill.map((p) => p.y));
+ // Every part of the plant stands on the sill without passing into it. That includes the vine's tail.
+ expectStandsOn(pointsOf(plant), () => sillTop);
+ });
+});
+
+describe('where things meet', () => {
+ it("keeps the plant's left leaf clear of the window's middle bar", () => {
+ const svg = drawn(renderUi().container);
+ const [frame, plant] = [...svg.querySelectorAll(':scope > g')];
+ const bars = outline(frame.querySelector('path[d^="M50"]') as SVGGeometryElement, 1000);
+ const leftLeaf = outline(plant.querySelectorAll('path')[4]);
+ let gap = Infinity;
+ for (const a of leftLeaf) for (const b of bars) gap = Math.min(gap, Math.hypot(a.x - b.x, a.y - b.y));
+ // Two 1.5 px strokes meet at 1.5 units apart; 3 leaves a clear line of sky between them.
+ expect(gap).toBeGreaterThan(3);
+ });
+
+ it("keeps the window vine's leaves clear of the stars and the moon", () => {
+ const paths = [...drawn(renderUi().container).querySelectorAll('path')];
+ const [stars, moon, vine, leaves] = [paths[1], paths[2], paths[5], paths[6]];
+ const sky = [...outline(stars, 600), ...outline(moon)];
+ let gap = Infinity;
+ for (const a of [...outline(vine), ...outline(leaves, 800)]) {
+ for (const b of sky) gap = Math.min(gap, Math.hypot(a.x - b.x, a.y - b.y));
+ }
+ expect(gap).toBeGreaterThan(4);
+ });
+
+ it("sets the candle's body down in its dish, not above it", () => {
+ const paths = drawn(renderUi().container).querySelectorAll('path');
+ const [body, saucer] = [paths[2], paths[4]];
+ expectStandsOn(outline(body), surface(saucer));
+ });
+});
+
+describe('inside their boxes', () => {
+ it.each([64, 103])('keeps every window line, stroke and all, inside the drawing at %i px tall', (height) => {
+ const svg = drawn(renderUi().container);
+ const box = svg.getBoundingClientRect();
+ for (const shape of svg.querySelectorAll('path, circle')) {
+ const style = getComputedStyle(shape);
+ // Lines don't scale, so half of each one's width reaches past its path by the same number of pixels.
+ const half = style.stroke === 'none' ? 0 : parseFloat(style.strokeWidth) / 2;
+ const edge = shape.getBoundingClientRect();
+ expect(edge.left - half).toBeGreaterThanOrEqual(box.left);
+ expect(edge.top - half).toBeGreaterThanOrEqual(box.top);
+ expect(edge.right + half).toBeLessThanOrEqual(box.right);
+ expect(edge.bottom + half).toBeLessThanOrEqual(box.bottom);
+ }
+ });
+
+ it("keeps every star above the page card's top edge by at least 5 px, however wide the canvas", () => {
+ document.documentElement.dataset.theme = 'dark';
+ // The page card starts one --space-8 margin down the canvas.
+ const card = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--space-8'));
+ expect(card).toBe(32);
+ for (const width of [600, 1280, 1920, 2560]) {
+ const { container, unmount } = renderUi(
+
+
+
,
+ );
+ const top = drawn(container).getBoundingClientRect().top;
+ for (const star of container.querySelectorAll('circle')) {
+ const dot = star.getBoundingClientRect();
+ // Above the edge. A lower star could land on one of the card's side edges, which move with the canvas width.
+ const clear = card - (dot.bottom - top);
+ expect(clear, `the star at x ${star.getAttribute('cx')} on a ${width} px canvas`).toBeGreaterThanOrEqual(5);
+ }
+ unmount();
+ }
+ });
+});
diff --git a/app/src/ui/illustrations/illustrations.module.css b/app/src/ui/illustrations/illustrations.module.css
new file mode 100644
index 00000000..70119f19
--- /dev/null
+++ b/app/src/ui/illustrations/illustrations.module.css
@@ -0,0 +1,202 @@
+/* The drawings (docs/BRAND.md section 8): small, quiet line art in the 1.5 px style of the icons. Every color is a
+ token. Lines are border.control, so they read below the text. Fills come from the five soft drawing tints, the paper
+ tint, and the ambient spark. Nothing here loops, and nothing sits behind text. */
+@layer components {
+ .art {
+ display: block;
+ flex: none;
+ /* A drawing shrinks with a narrow window, and keeps its proportions. */
+ max-inline-size: 100%;
+ block-size: auto;
+ fill: none;
+ stroke: var(--color-border-control);
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ /* One quiet fade-in when it appears, and no more (BRAND.md section 9). */
+ animation: arrive var(--motion-duration-fast) var(--motion-easing-enter) both;
+ }
+
+ /* The line stays 1.5 CSS px at every size, so a drawing scaled to a corner matches the icons. */
+ .art :is(path, circle, ellipse, rect, line, polyline) {
+ vector-effect: non-scaling-stroke;
+ }
+
+ /* Soft fills from the five drawing tints (art.*), so a drawing has color as well as lines. Moss is for leaves and
+ covers, clay for a pot or dish, candle for warm light, dusk for rose spines and hills, and night for indigo.
+ Paper is the pale warm page of a notebook. Stroke only where the drawing needs an outline. */
+ .fillMoss {
+ fill: var(--color-art-moss);
+ }
+
+ .fillClay {
+ fill: var(--color-art-clay);
+ }
+
+ .fillCandle {
+ fill: var(--color-art-candle);
+ }
+
+ .fillDusk {
+ fill: var(--color-art-dusk);
+ }
+
+ .fillNight {
+ fill: var(--color-art-night);
+ }
+
+ .fillPaper {
+ fill: var(--color-accent-candle-subtle);
+ }
+
+ /* The window sky: paper light into a candle and dusk sunset band by day, deep night into indigo in the evening. */
+ .stopPaper {
+ stop-color: var(--color-accent-candle-subtle);
+ }
+
+ .stopCandle {
+ stop-color: var(--color-art-candle);
+ }
+
+ .stopDusk {
+ stop-color: var(--color-art-dusk);
+ }
+
+ .stopDeep {
+ stop-color: var(--color-accent-night-subtle);
+ }
+
+ .stopNight {
+ stop-color: var(--color-art-night);
+ }
+
+ .spark {
+ fill: var(--color-ambient-spark);
+ stroke: none;
+ }
+
+ /* The sun and the flame are warm light with a candle-colored edge; the moon has a night-colored one. */
+ .sun {
+ fill: var(--color-ambient-spark);
+ stroke: var(--color-accent-candle);
+ }
+
+ .moon {
+ fill: var(--color-ambient-spark);
+ stroke: var(--color-accent-night);
+ }
+
+ /* Zero-length strokes: the round cap makes each a dot, which is how the window's stars are drawn. */
+ .dots {
+ stroke: var(--color-ambient-spark);
+ stroke-width: 2.4;
+ }
+
+ .dot {
+ fill: var(--color-accent-clay);
+ stroke: none;
+ }
+
+ /* Accent lines: the moss of the logo's ink, its clay pen tip, and the light of a candle. */
+ .moss {
+ stroke: var(--color-accent-primary);
+ }
+
+ .clay {
+ stroke: var(--color-accent-clay);
+ }
+
+ .candle {
+ stroke: var(--color-accent-candle);
+ }
+
+ .dusk {
+ stroke: var(--color-accent-dusk);
+ }
+
+ .night {
+ stroke: var(--color-accent-night);
+ }
+
+ /* The warm glow around a flame is a flat radial gradient in the candle tint inside the drawing, never a blur. */
+ .glow stop {
+ stop-color: var(--color-art-candle);
+ }
+
+ .halo {
+ stroke: none;
+ }
+
+ /* The sign-off stroke under a heading: moss ink with a clay dot, like the logo. */
+ .stroke {
+ display: block;
+ fill: none;
+ stroke: var(--color-accent-primary);
+ stroke-width: 2.5;
+ stroke-linecap: round;
+ animation: arrive var(--motion-duration-fast) var(--motion-easing-enter) both;
+ }
+
+ .stroke :is(path, circle) {
+ vector-effect: non-scaling-stroke;
+ }
+
+ /* Twenty-five stars on the evening sky. They are pinned to the top of the scrolling canvas, above the page card. */
+ .stars {
+ position: sticky;
+ inset-block-start: 0;
+ z-index: calc(var(--layer-base) - 1);
+ display: none;
+ inline-size: 100%;
+ /* Drawn at 1:1 at any width. A wider canvas centers the field instead of scaling it. The page card's top edge
+ stays 32 px down at any width, so no star drifts onto it. */
+ max-inline-size: 1280px;
+ block-size: 160px;
+ margin-inline: auto;
+ margin-block-end: -160px;
+ pointer-events: none;
+ fill: var(--color-ambient-spark);
+ stroke: none;
+ animation: arrive var(--motion-duration-fast) var(--motion-easing-enter) both;
+ }
+
+ :global(:root[data-theme='dark']) .stars {
+ display: block;
+ }
+
+ /* "Always paper white" lights the whole page area, so there is no night sky to put them in. */
+ :global(:root[data-page-color='paper']) .stars {
+ display: none;
+ }
+
+ @keyframes arrive {
+ from {
+ opacity: 0;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ :is(.art, .stroke, .stars) {
+ animation: none;
+ }
+ }
+
+ :global(:root[data-motion='reduce']) :is(.art, .stroke, .stars) {
+ animation: none;
+ }
+}
+
+/* A Windows contrast theme gets plain outlines in the system text color: no fills, no glow, and no stars. */
+@layer forced {
+ @media (forced-colors: active) {
+ .art :is(path, circle, ellipse, rect, line, polyline),
+ .stroke :is(path, circle) {
+ fill: none;
+ stroke: CanvasText;
+ }
+
+ :is(.stars, .dots, .halo) {
+ display: none;
+ }
+ }
+}
diff --git a/app/src/ui/illustrations/illustrations.test.tsx b/app/src/ui/illustrations/illustrations.test.tsx
new file mode 100644
index 00000000..411617f0
--- /dev/null
+++ b/app/src/ui/illustrations/illustrations.test.tsx
@@ -0,0 +1,248 @@
+import type { ReactElement } from 'react';
+import { cdp } from 'vitest/browser';
+import { afterEach, describe, expect, it } from 'vitest';
+import { renderUi } from '../../test';
+import { drawn } from '../../test/drawing';
+import { Books, Candle, DeskScene, EmptyArt, Glint, InkStroke, Notebook, Plant, StarField, Window } from './index';
+
+const MOTIFS = {
+ 'Window by day': ,
+ 'Window by night': ,
+ Plant: ,
+ Candle: ,
+ Books: ,
+ Notebook: ,
+ 'Ink stroke': ,
+ 'Star field': ,
+ 'Desk scene by day': ,
+ 'Desk scene by night': ,
+ 'Notebooks shelf': ,
+ 'Open notebook with a candle': ,
+ 'Candle beside a stack': ,
+ 'Sun glint': ,
+ 'Moon glint': ,
+} as const;
+
+/** A color as the browser computes it, from a token or a system color, for comparing with what a drawing paints. */
+function colorOf(value: string): string {
+ const probe = document.body.appendChild(document.createElement('div'));
+ probe.style.color = value.startsWith('--') ? `var(${value})` : value;
+ const computed = getComputedStyle(probe).color;
+ probe.remove();
+ return computed;
+}
+
+afterEach(() => {
+ document.documentElement.removeAttribute('data-theme');
+});
+
+describe('every drawing', () => {
+ for (const [name, element] of Object.entries(MOTIFS)) {
+ it(`${name} is hidden from screen readers, can't take focus, and uses only tokens`, () => {
+ const { container } = renderUi(element);
+ const svg = drawn(container);
+ expect(svg.getAttribute('aria-hidden')).toBe('true');
+ expect(svg.getAttribute('focusable')).toBe('false');
+ expect(svg.textContent).toBe('');
+ const markup = svg.outerHTML;
+ expect(markup).not.toMatch(/#[0-9a-f]{3,8}\b(?![^<]*>.*url)/i);
+ expect(markup.replace(/url\(#[^)]*\)/g, '')).not.toMatch(/#[0-9a-f]{3,8}\b/i);
+ expect(markup).not.toMatch(/rgba?\(|hsla?\(/i);
+ expect(markup).not.toContain('style=');
+ expect(markup).not.toMatch(/<(filter|animate|animateTransform|image|foreignObject)/i);
+ });
+
+ it(`${name} is under 6 KB of markup`, () => {
+ const { container } = renderUi(element);
+ expect(drawn(container).outerHTML.length).toBeLessThan(6144);
+ });
+ }
+
+ it('shows the desk at one and a half times its drawn size, the empty states at 176 px, and a 50 px plant', () => {
+ const size = (element: ReactElement) => {
+ const { container, unmount } = renderUi(element);
+ const box = drawn(container).getBoundingClientRect();
+ unmount();
+ return [box.width, box.height];
+ };
+ expect(size()).toEqual([360, 225]);
+ for (const kind of ['notebooks', 'page', 'trash'] as const) expect(size()[0]).toBe(176);
+ expect(size()).toEqual([42, 50]);
+ });
+
+ for (const [name, element] of Object.entries(MOTIFS).filter(([name]) => name !== 'Star field')) {
+ it(`${name} is never wider than the desk scene or taller than it`, () => {
+ const box = drawn(renderUi(element).container).getBoundingClientRect();
+ expect(box.width).toBeLessThanOrEqual(360);
+ expect(box.height).toBeLessThanOrEqual(225);
+ });
+ }
+
+ it('draws its lines 1.5 px wide at any size', () => {
+ const { container } = renderUi();
+ const line = container.querySelector('path') as SVGPathElement;
+ expect(getComputedStyle(line).strokeWidth).toBe('1.5px');
+ expect(getComputedStyle(line).vectorEffect).toBe('non-scaling-stroke');
+ });
+});
+
+describe('in both themes', () => {
+ it.each(['light', 'dark'] as const)('paints the window with the %s theme tokens', (theme) => {
+ document.documentElement.dataset.theme = theme;
+ const stops = (container: HTMLElement) =>
+ [...container.querySelectorAll('stop')].map((stop) => getComputedStyle(stop).stopColor);
+ const day = renderUi();
+ const [sky, hills] = [...day.container.querySelectorAll('path')];
+ const sun = day.container.querySelector('circle') as Element;
+ // A sunset band: paper light at the top, then candle amber behind the sun and dusk rose at the hills.
+ expect(getComputedStyle(sky).fill).toMatch(/^url\(/);
+ expect(stops(day.container)).toEqual(
+ ['--color-accent-candle-subtle', '--color-accent-candle-subtle', '--color-art-candle', '--color-art-dusk'].map(
+ colorOf,
+ ),
+ );
+ expect(getComputedStyle(sun).fill).toBe(colorOf('--color-ambient-spark'));
+ expect(getComputedStyle(sun).stroke).toBe(colorOf('--color-accent-candle'));
+ expect(getComputedStyle(hills).fill).toBe(colorOf('--color-art-moss'));
+ day.unmount();
+ const night = renderUi();
+ const [, stars, moon, dusk] = [...night.container.querySelectorAll('path')];
+ expect(stops(night.container)).toEqual(
+ ['--color-accent-night-subtle', '--color-accent-night-subtle', '--color-art-night'].map(colorOf),
+ );
+ expect(getComputedStyle(stars).stroke).toBe(colorOf('--color-ambient-spark'));
+ expect(getComputedStyle(moon).fill).toBe(colorOf('--color-ambient-spark'));
+ expect(getComputedStyle(moon).stroke).toBe(colorOf('--color-accent-night'));
+ expect(getComputedStyle(dusk).fill).toBe(colorOf('--color-art-dusk'));
+ });
+
+ it('draws lines in the control border color, so they read below the text', () => {
+ for (const theme of ['light', 'dark'] as const) {
+ document.documentElement.dataset.theme = theme;
+ const { container, unmount } = renderUi();
+ expect(getComputedStyle(container.querySelector('path') as Element).stroke).toBe(
+ colorOf('--color-border-control'),
+ );
+ unmount();
+ }
+ });
+
+ it('gives the two themes different sky colors', () => {
+ document.documentElement.dataset.theme = 'light';
+ const light = colorOf('--color-ambient-canvas-top');
+ document.documentElement.dataset.theme = 'dark';
+ expect(colorOf('--color-ambient-canvas-top')).not.toBe(light);
+ });
+});
+
+describe('in color', () => {
+ it.each(['light', 'dark'] as const)('fills the spines, the pot, and the glow with the %s tints', (theme) => {
+ document.documentElement.dataset.theme = theme;
+ const fills = (element: ReactElement, selector: string) => {
+ const { container, unmount } = renderUi(element);
+ const found = [...container.querySelectorAll(selector)].map((shape) => getComputedStyle(shape).fill);
+ unmount();
+ return found;
+ };
+ // Every book has its own colored spine, never the paper behind it.
+ const spines = fills(, 'path[class]').slice(0, 4);
+ expect(spines).toEqual(
+ ['--color-art-dusk', '--color-art-moss', '--color-art-night', '--color-art-candle'].map(colorOf),
+ );
+ // The pot and the candle dish are clay, and the leaves are moss.
+ expect(fills(, 'path')[0]).toBe(colorOf('--color-art-clay'));
+ expect(fills(, 'g g path')).toContain(colorOf('--color-art-moss'));
+ const candle = renderUi();
+ const dish = candle.container.querySelectorAll('path')[4];
+ expect(getComputedStyle(dish).fill).toBe(colorOf('--color-art-clay'));
+ // The glow is the candle tint at the flame, fading to nothing at its edge.
+ const glow = [...candle.container.querySelectorAll('radialGradient stop')].map((stop) => getComputedStyle(stop));
+ expect(glow[0].stopColor).toBe(colorOf('--color-art-candle'));
+ expect(glow.at(-1)?.stopOpacity).toBe('0');
+ candle.unmount();
+ });
+
+ it('keeps every drawing tint distinct from the paper it sits on', () => {
+ for (const theme of ['light', 'dark'] as const) {
+ document.documentElement.dataset.theme = theme;
+ const paper = colorOf('--color-surface-page');
+ const tints = ['moss', 'clay', 'candle', 'dusk', 'night'].map((tint) => colorOf(`--color-art-${tint}`));
+ expect(new Set(tints).size).toBe(5);
+ expect(tints).not.toContain(paper);
+ }
+ });
+});
+
+describe('the evening sky', () => {
+ it('lights twenty-five stars, three of them larger, none brighter than 80 percent', () => {
+ const stars = [...renderUi().container.querySelectorAll('circle')];
+ const radii = stars.map((star) => Number(star.getAttribute('r')));
+ const opacities = stars.map((star) => Number(star.getAttribute('opacity')));
+ expect(stars).toHaveLength(25);
+ expect(radii.filter((r) => r >= 1.7)).toHaveLength(3);
+ expect(Math.max(...radii)).toBeLessThanOrEqual(1.8);
+ expect(Math.min(...radii)).toBeGreaterThanOrEqual(0.75);
+ expect(Math.min(...opacities)).toBeGreaterThanOrEqual(0.45);
+ expect(Math.max(...opacities)).toBeLessThanOrEqual(0.8);
+ // The larger stars are the brightest, so the field has a few points of light and no clutter.
+ const big = stars.filter((_, i) => radii[i] >= 1.7).map((star) => Number(star.getAttribute('opacity')));
+ expect(Math.min(...big)).toBeGreaterThanOrEqual(0.75);
+ });
+});
+
+describe('motion', () => {
+ it('fades in once over the fast duration and never loops', () => {
+ const { container } = renderUi();
+ const style = getComputedStyle(drawn(container));
+ expect(style.animationIterationCount).toBe('1');
+ expect(style.animationDuration).toBe('0.15s');
+ expect(style.animationName).not.toBe('none');
+ });
+
+ it('does not fade in with reduced motion', () => {
+ document.documentElement.dataset.motion = 'reduce';
+ const { container } = renderUi();
+ expect(getComputedStyle(drawn(container)).animationName).toBe('none');
+ delete document.documentElement.dataset.motion;
+ });
+
+ it('stays behind the page and out of the way of the pointer', () => {
+ const { container } = renderUi();
+ const style = getComputedStyle(drawn(container));
+ expect(style.pointerEvents).toBe('none');
+ expect(Number(style.zIndex)).toBeLessThan(0);
+ });
+});
+
+describe('under a Windows contrast theme', () => {
+ const emulate = (value: 'active' | 'none') =>
+ cdp().send('Emulation.setEmulatedMedia', { features: [{ name: 'forced-colors', value }] });
+
+ afterEach(() => emulate('none'));
+
+ it('draws plain outlines in the text color, with no fills', async () => {
+ await emulate('active');
+ const { container } = renderUi();
+ const shapes = [...container.querySelectorAll('path, circle')];
+ const text = colorOf('CanvasText');
+ expect(shapes.length).toBeGreaterThan(10);
+ for (const shape of shapes) {
+ expect(getComputedStyle(shape).fill).toBe('none');
+ expect(getComputedStyle(shape).stroke).toBe(text);
+ }
+ // A glow would be an outlined circle around each flame, so it goes.
+ const halos = [...container.querySelectorAll('circle[fill^="url"]')];
+ expect(halos.length).toBeGreaterThan(0);
+ for (const halo of halos) expect(getComputedStyle(halo).display).toBe('none');
+ });
+
+ it('hides the stars, which the evening theme otherwise shows', async () => {
+ document.documentElement.dataset.theme = 'dark';
+ const shown = renderUi();
+ expect(getComputedStyle(drawn(shown.container)).display).toBe('block');
+ shown.unmount();
+ await emulate('active');
+ const hidden = renderUi();
+ expect(getComputedStyle(drawn(hidden.container)).display).toBe('none');
+ });
+});
diff --git a/app/src/ui/illustrations/index.ts b/app/src/ui/illustrations/index.ts
new file mode 100644
index 00000000..9f79e0ce
--- /dev/null
+++ b/app/src/ui/illustrations/index.ts
@@ -0,0 +1,17 @@
+// The drawings (docs/BRAND.md section 8): a window, a plant with a vine, a candle, books, a sunset, and stars with
+// a moon, as small inline SVG components. They are hidden from screen readers and never carry meaning that text
+// doesn't.
+
+export type { ArtProps } from './Art';
+export { Books } from './Books';
+export { Candle } from './Candle';
+export { DeskScene } from './DeskScene';
+export { EmptyArt } from './EmptyArt';
+export type { EmptyKind } from './EmptyArt';
+export { Glint } from './Glint';
+export { InkStroke } from './InkStroke';
+export { Notebook } from './Notebook';
+export { Plant } from './Plant';
+export { StarField } from './StarField';
+export { Window } from './Window';
+export type { Sky } from './Window';
diff --git a/app/src/ui/index.ts b/app/src/ui/index.ts
index 4caf7b55..f04b74ff 100644
--- a/app/src/ui/index.ts
+++ b/app/src/ui/index.ts
@@ -11,6 +11,19 @@ export { Dialog, confirm } from './Dialog';
export { useDismiss } from './dismiss';
export type { DialogAction, DialogProps } from './Dialog';
export { FocusScope } from './FocusScope';
+export {
+ Books,
+ Candle,
+ DeskScene,
+ EmptyArt,
+ Glint,
+ InkStroke,
+ Notebook,
+ Plant,
+ StarField,
+ Window,
+} from './illustrations';
+export type { ArtProps, EmptyKind, Sky } from './illustrations';
export { useDelayedFlag, useLayer, useLongPress } from './hooks';
export type { PointerHandlers, PressEvent } from './hooks';
export type { IconName, IconProps } from './icons';
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 41bef688..ecb5e96c 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
import type { ConfigEnv, UserConfig } from 'vite';
import react from '@vitejs/plugin-react';
+import { layerOrder } from './scripts/layer-order.ts';
import { phosphorWeights } from './scripts/phosphor-weights.ts';
export type PlatformKind = 'tauri' | 'web';
@@ -23,7 +24,7 @@ export function viteConfig(env: Pick): UserConfig
const platform = platformFor(env);
return {
root: import.meta.dirname,
- plugins: [react(), phosphorWeights()],
+ plugins: [react(), phosphorWeights(), layerOrder()],
clearScreen: false,
define: { 'import.meta.env.VITE_PLATFORM': JSON.stringify(platform) },
// Tauri expects a fixed dev server port and serves the built files from app/dist.
diff --git a/brand/tokens.json b/brand/tokens.json
index 74a60451..596e4b2b 100644
--- a/brand/tokens.json
+++ b/brand/tokens.json
@@ -1,7 +1,7 @@
{
"meta": {
"name": "OpenNote design tokens",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Single source of truth for OpenNote's look and motion. See docs/BRAND.md for how each token is used. CHECKS validates contrast and motion limits."
},
"color": {
@@ -32,7 +32,13 @@
"primaryHover": "#325C42",
"primaryPressed": "#2A4D38",
"primarySubtle": "#E2ECE1",
- "clay": "#A9502F"
+ "clay": "#A9502F",
+ "candle": "#9A5410",
+ "candleSubtle": "#F8EBD3",
+ "dusk": "#9C4559",
+ "duskSubtle": "#F6E4E4",
+ "night": "#4A5590",
+ "nightSubtle": "#E8EAF3"
},
"status": {
"success": "#35704A",
@@ -46,6 +52,19 @@
"selection": {
"highlight": "#F4DE93"
},
+ "art": {
+ "moss": "#C8DCC6",
+ "clay": "#EDC3AF",
+ "candle": "#F5D59C",
+ "dusk": "#EDC0C9",
+ "night": "#C7CEEA"
+ },
+ "ambient": {
+ "canvasTop": "#FFF0D4",
+ "canvasBottom": "#EAE3D8",
+ "glow": "#FCE4B8",
+ "spark": "#F2CF8A"
+ },
"caption": {
"closeHover": "#C42B1C",
"closeHoverText": "#FFFCF6"
@@ -88,7 +107,13 @@
"primaryHover": "#A2CEAE",
"primaryPressed": "#7DB28C",
"primarySubtle": "#2E3B30",
- "clay": "#E59372"
+ "clay": "#E59372",
+ "candle": "#F0A860",
+ "candleSubtle": "#3A2C1C",
+ "dusk": "#EBA0B2",
+ "duskSubtle": "#3A2A30",
+ "night": "#A9B4EC",
+ "nightSubtle": "#1F2233"
},
"status": {
"success": "#8FC29D",
@@ -102,6 +127,19 @@
"selection": {
"highlight": "#52451F"
},
+ "art": {
+ "moss": "#3E5A46",
+ "clay": "#6A4332",
+ "candle": "#6A4E27",
+ "dusk": "#62404D",
+ "night": "#383F6C"
+ },
+ "ambient": {
+ "canvasTop": "#1B2036",
+ "canvasBottom": "#33243A",
+ "glow": "#45302C",
+ "spark": "#E7D9B0"
+ },
"caption": {
"closeHover": "#C42B1C",
"closeHoverText": "#FFFCF6"
@@ -138,6 +176,21 @@
"accent.primaryPressed": "Highlight",
"accent.primarySubtle": "Canvas",
"accent.clay": "CanvasText",
+ "accent.candle": "CanvasText",
+ "accent.candleSubtle": "Canvas",
+ "accent.dusk": "CanvasText",
+ "accent.duskSubtle": "Canvas",
+ "accent.night": "CanvasText",
+ "accent.nightSubtle": "Canvas",
+ "art.moss": "Canvas",
+ "art.clay": "Canvas",
+ "art.candle": "Canvas",
+ "art.dusk": "Canvas",
+ "art.night": "Canvas",
+ "ambient.canvasTop": "Canvas",
+ "ambient.canvasBottom": "Canvas",
+ "ambient.glow": "Canvas",
+ "ambient.spark": "Canvas",
"status.success": "CanvasText",
"status.warning": "CanvasText",
"status.danger": "CanvasText",
@@ -236,7 +289,53 @@
{ "fg": "text.primary", "bg": "ink.highlighters.Mint", "over": "surface.page", "min": 7 },
{ "fg": "text.primary", "bg": "ink.highlighters.Rose", "over": "surface.page", "min": 7 },
{ "fg": "text.primary", "bg": "ink.highlighters.Apricot", "over": "surface.page", "min": 7 },
- { "fg": "text.primary", "bg": "ink.highlighters.Lilac", "over": "surface.page", "min": 7 }
+ { "fg": "text.primary", "bg": "ink.highlighters.Lilac", "over": "surface.page", "min": 7 },
+ { "fg": "accent.candle", "bg": "surface.page", "min": 4.5 },
+ { "fg": "accent.candle", "bg": "surface.app", "min": 4.5 },
+ { "fg": "accent.candle", "bg": "surface.raised", "min": 4.5 },
+ { "fg": "accent.candle", "bg": "surface.sunken", "min": 4.5 },
+ { "fg": "accent.candle", "bg": "accent.candleSubtle", "min": 4.5 },
+ { "fg": "text.primary", "bg": "accent.candleSubtle", "min": 7 },
+ { "fg": "text.muted", "bg": "accent.candleSubtle", "min": 4.5 },
+ { "fg": "border.control", "bg": "accent.candleSubtle", "min": 3 },
+ { "fg": "accent.dusk", "bg": "surface.page", "min": 4.5 },
+ { "fg": "accent.dusk", "bg": "surface.app", "min": 4.5 },
+ { "fg": "accent.dusk", "bg": "surface.raised", "min": 4.5 },
+ { "fg": "accent.dusk", "bg": "surface.sunken", "min": 4.5 },
+ { "fg": "accent.dusk", "bg": "accent.duskSubtle", "min": 4.5 },
+ { "fg": "text.primary", "bg": "accent.duskSubtle", "min": 7 },
+ { "fg": "text.muted", "bg": "accent.duskSubtle", "min": 4.5 },
+ { "fg": "border.control", "bg": "accent.duskSubtle", "min": 3 },
+ { "fg": "accent.night", "bg": "surface.page", "min": 4.5 },
+ { "fg": "accent.night", "bg": "surface.app", "min": 4.5 },
+ { "fg": "accent.night", "bg": "surface.raised", "min": 4.5 },
+ { "fg": "accent.night", "bg": "surface.sunken", "min": 4.5 },
+ { "fg": "accent.night", "bg": "accent.nightSubtle", "min": 4.5 },
+ { "fg": "text.primary", "bg": "accent.nightSubtle", "min": 7 },
+ { "fg": "text.muted", "bg": "accent.nightSubtle", "min": 4.5 },
+ { "fg": "border.control", "bg": "accent.nightSubtle", "min": 3 },
+ { "fg": "text.primary", "bg": "ambient.canvasTop", "min": 7 },
+ { "fg": "text.secondary", "bg": "ambient.canvasTop", "min": 4.5 },
+ { "fg": "text.muted", "bg": "ambient.canvasTop", "min": 4.5 },
+ { "fg": "text.link", "bg": "ambient.canvasTop", "min": 4.5 },
+ { "fg": "focus.ring", "bg": "ambient.canvasTop", "min": 3 },
+ { "fg": "border.control", "bg": "ambient.canvasTop", "min": 3 },
+ { "fg": "text.primary", "bg": "ambient.canvasBottom", "min": 7 },
+ { "fg": "text.secondary", "bg": "ambient.canvasBottom", "min": 4.5 },
+ { "fg": "text.muted", "bg": "ambient.canvasBottom", "min": 4.5 },
+ { "fg": "text.link", "bg": "ambient.canvasBottom", "min": 4.5 },
+ { "fg": "focus.ring", "bg": "ambient.canvasBottom", "min": 3 },
+ { "fg": "border.control", "bg": "ambient.canvasBottom", "min": 3 },
+ { "fg": "text.primary", "bg": "ambient.glow", "min": 7 },
+ { "fg": "text.secondary", "bg": "ambient.glow", "min": 4.5 },
+ { "fg": "text.muted", "bg": "ambient.glow", "min": 4.5 },
+ { "fg": "text.link", "bg": "ambient.glow", "min": 4.5 },
+ { "fg": "focus.ring", "bg": "ambient.glow", "min": 3 },
+ { "fg": "border.control", "bg": "ambient.glow", "min": 3 },
+ { "fg": "status.success", "bg": "surface.app", "min": 4.5 },
+ { "fg": "ambient.canvasTop", "bg": "surface.sunken", "max": 1.5 },
+ { "fg": "ambient.canvasBottom", "bg": "surface.sunken", "max": 1.5 },
+ { "fg": "ambient.glow", "bg": "surface.sunken", "max": 1.5 }
],
"ink": {
"pens": [
@@ -311,7 +410,7 @@
"palette": 680,
"paletteTop": 90,
"setupCardWidth": 720,
- "setupCardHeight": 600,
+ "setupCardHeight": 672,
"settingsNav": 240,
"bottomBar": 64,
"menuMinWidth": 208,
diff --git a/checks/rules/brand-tokens.ts b/checks/rules/brand-tokens.ts
index 6aa9d9d2..5b2e028b 100644
--- a/checks/rules/brand-tokens.ts
+++ b/checks/rules/brand-tokens.ts
@@ -2,6 +2,8 @@
// theme. The forced-colors map names system colors for existing tokens, and motion stays within the limits set in
// docs/BRAND.md. Contrast pairs may name pens and highlighters (ink.pens., ink.highlighters.), which
// resolve to their light or dark value per theme. A pair may also composite a translucent background over a color.
+// A pair has a floor (`min`), a ceiling (`max`), or both: a ceiling keeps an ambient wash close to the surface it
+// sits next to, so it never competes with the writing.
import type { Finding, Rule, SourceFile } from '../types.ts';
import { numberSetting } from '../config.ts';
@@ -10,7 +12,10 @@ import { reporter } from './helpers.ts';
interface ContrastPair {
fg: string;
bg: string;
- min: number;
+ /** The lowest ratio allowed, such as 7 for body text. */
+ min?: number;
+ /** The highest ratio allowed, such as 1.3 for a wash that should stay close to its surface. */
+ max?: number;
/** Composites a translucent `bg`, such as a highlighter, over this color first. */
over?: string;
}
@@ -124,11 +129,15 @@ function contrastFindings(file: SourceFile, tokens: Tokens): Finding[] {
findings.push(report(line, `Contrast pair ${describe(pair)} doesn't exist in theme "${theme}".`));
continue;
}
+ if (pair.min === undefined && pair.max === undefined) {
+ findings.push(report(line, `Contrast pair ${describe(pair)} needs a "min", a "max", or both.`));
+ continue;
+ }
const ratio = contrastRatio(fg, bg, under);
- if (ratio < pair.min)
- findings.push(
- report(line, `${theme}: ${describe(pair)} has contrast ${ratio.toFixed(2)}:1, needs ${pair.min}:1.`),
- );
+ const shown = `${theme}: ${describe(pair)} has contrast ${ratio.toFixed(2)}:1`;
+ if (pair.min !== undefined && ratio < pair.min) findings.push(report(line, `${shown}, needs ${pair.min}:1.`));
+ if (pair.max !== undefined && ratio > pair.max)
+ findings.push(report(line, `${shown}, which is over the ${pair.max}:1 ceiling.`));
}
}
return findings;
diff --git a/checks/test/style-rules.test.ts b/checks/test/style-rules.test.ts
index e6ac0808..dcb60a11 100644
--- a/checks/test/style-rules.test.ts
+++ b/checks/test/style-rules.test.ts
@@ -60,6 +60,36 @@ test('brand-tokens composites a highlighter over the page before measuring text
assert.ok(contrastRatio('#F0E9DE', '#A88A2C66', '#24201C') >= 7);
});
+test('brand-tokens enforces a ceiling on contrast, for washes that must stay close to a surface', async () => {
+ const tokens = baseTokens();
+ // The selected row and the page are 1.2:1 apart in light and 1.4:1 in dark.
+ const ceiling = (max: number) => ({ ...tokens, contrast: [{ fg: 'surface.selected', bg: 'surface.page', max }] });
+ assert.deepEqual(await tokenMessages(ceiling(1.5)), []);
+ const found = await tokenMessages(ceiling(1.05));
+ assert.equal(found.length, 2);
+ assert.match(
+ found[0],
+ /^light: surface.selected on surface.page has contrast 1.\d\d:1, which is over the 1.05:1 ceiling/,
+ );
+ assert.match(
+ found[1],
+ /^dark: surface.selected on surface.page has contrast 1.\d\d:1, which is over the 1.05:1 ceiling/,
+ );
+});
+
+test('brand-tokens checks a floor and a ceiling together, and asks for at least one', async () => {
+ const tokens = baseTokens();
+ const both = { fg: 'text.primary', bg: 'surface.page', min: 7, max: 12 };
+ const found = await tokenMessages({ ...tokens, contrast: [both] });
+ assert.equal(found.length, 2);
+ assert.match(
+ found[0],
+ /^light: text.primary on surface.page has contrast 1\d.\d\d:1, which is over the 12:1 ceiling/,
+ );
+ const neither = await tokenMessages({ ...tokens, contrast: [{ fg: 'text.primary', bg: 'surface.page' }] });
+ assert.match(neither[0], /text.primary on surface.page needs a "min", a "max", or both/);
+});
+
test('logical-properties flags physical properties and values in style sheets', async () => {
const css = [
'.a { margin-left: 4px; inset-inline-start: 0; }',
diff --git a/docs/BRAND.md b/docs/BRAND.md
index fb329f8d..ea8aa313 100644
--- a/docs/BRAND.md
+++ b/docs/BRAND.md
@@ -21,7 +21,11 @@ This guide defines how OpenNote looks, moves, sounds, and behaves. The [screen w
## 1. Personality
-OpenNote should feel like a good desk by a window: warm paper, a pen that writes well, and nothing shouting for attention. People spend hours in a note app, often while listening to a lecture or a meeting. The interface stays calm and gets out of the way.
+OpenNote should feel like a good desk by a window: warm paper, a pen that writes well, and nothing shouting for attention.
+
+By day, soft light falls across the desk; in the evening a candle is lit and the window fills with dusk and stars. A few small drawings and warm accents carry that feeling, and they always sit beside the work, never on it.
+
+People spend hours in a note app, often while listening to a lecture or a meeting. The interface stays calm and gets out of the way.
| We are | We are not |
|---|---|
@@ -62,7 +66,7 @@ Rules of thumb:
## 4. Color
-The palette is warm paper, walnut ink, a moss green accent, and a clay secondary accent. It deliberately avoids the light-blue-on-black look of developer tools. Every pairing below is checked by CHECKS: body text reaches at least 7:1 contrast and secondary text at least 4.5:1.
+The palette is warm paper, walnut ink, moss, and clay, with candlelight, dusk and night-sky tones for ambience. It deliberately avoids the light-blue-on-black look of developer tools. Every pairing below is checked by CHECKS: body text reaches at least 7:1 contrast and secondary text at least 4.5:1.
### Surfaces and text
@@ -93,6 +97,41 @@ The palette is warm paper, walnut ink, a moss green accent, and a clay secondary
| `status.success` | `#35704A` | `#8FC29D` | Saved, synced |
| `selection.highlight` | `#F4DE93` honey | `#52451F` | Selected text |
+### Warm accents and ambient light
+
+Three small accent families and one ambient group give the shell its warmth. Each color has one job, so the extra color doesn't turn into noise:
+
+| Token | Light | Dark | Use |
+|---|---|---|---|
+| `accent.candle` | `#9A5410` | `#F0A860` | The sun icon, a candle flame, lamplight |
+| `accent.candleSubtle` | `#F8EBD3` | `#3A2C1C` | Warm paper: notebook pages in drawings and the top of the day window's sky |
+| `accent.dusk` | `#9C4559` | `#EBA0B2` | Sunset line art, dusk accents |
+| `accent.duskSubtle` | `#F6E4E4` | `#3A2A30` | A soft rose surface behind a dusk accent; drawings use `art.dusk` |
+| `accent.night` | `#4A5590` | `#A9B4EC` | The moon icon, night line art |
+| `accent.nightSubtle` | `#E8EAF3` | `#1F2233` | The upper night sky in the evening window |
+| `ambient.canvasTop` | `#FFF0D4` window light | `#1B2036` night sky | The pool of light from the window's corner by day; the top of the evening sky |
+| `ambient.canvasBottom` | `#EAE3D8` (the sunken color) | `#33243A` dusk | Bottom of the evening sky, above the sunset band |
+| `ambient.glow` | `#FCE4B8` window light on the desk | `#45302C` sunset | The warmest light on the canvas: the patch of window light by day, the low sunset band in the evening |
+| `ambient.spark` | `#F2CF8A` sun disc | `#E7D9B0` starlight | The sun disc and stars only, never text |
+
+Candle, dusk, and night are never status colors and never fill a button, so candle can't be mistaken for `status.warning`. They also never stand alone as a signal.
+
+The ambient washes stay calm: CHECKS holds `ambient.canvasTop`, `ambient.canvasBottom`, and `ambient.glow` to at most 1.5:1 against `surface.sunken` (a `max` ceiling on a contrast pair). The ceiling was 1.3 until the evening sky gained its sunset band, which sits just under 1.5. Every text pairing on these tones keeps the same targets as on the canvas, and the washes only ever show around the page card, never behind text. Under Windows contrast themes the accents become the system text color, and the fills and ambient tokens become the system canvas, so the gradients flatten and the stars disappear.
+
+### Drawing tints
+
+The drawings get their color from five soft tints, one for each motif color, so a drawing is never outlines alone. They are fills only: never text, never a button, and never a signal.
+
+| Token | Light | Dark | Use |
+|---|---|---|---|
+| `art.moss` | `#C8DCC6` | `#3E5A46` | Leaves, vines, the day window's hills, a moss spine |
+| `art.clay` | `#EDC3AF` | `#6A4332` | Pots and the candle dish |
+| `art.candle` | `#F5D59C` | `#6A4E27` | The glow around a flame, the sunset band, an amber spine |
+| `art.dusk` | `#EDC0C9` | `#62404D` | The rose of the sunset band, the evening hills, a rose spine |
+| `art.night` | `#C7CEEA` | `#383F6C` | The night window's horizon, an indigo spine |
+
+A notebook's pages use `accent.candleSubtle` as warm paper, and the night window's upper sky uses `accent.nightSubtle`. The lines on top stay `border.control`, so a fill never hides an edge. Under Windows contrast themes the tints become the system canvas, and the drawings go back to plain outlines.
+
### Ink colors
Ink is content, not interface, so it has its own palette. Each pen has a light and a dark value, and notes store the pen name. That way handwriting stays readable when the theme changes. The default pens are Ink, Indigo, Brick, Fern, Plum, Amber and Walnut, and each is at least 4.2:1 against the page in both themes. Highlighters (Honey, Mint, Rose, Apricot, Lilac) are 40% transparent and draw behind ink. Custom colors are allowed; in dark mode the app lightens them until they reach 3:1.
@@ -182,15 +221,50 @@ Panes resize by dragging, and collapse with a button or keyboard shortcut. Pagin
Corners are gently rounded: 4 for small elements, 8 for buttons and inputs, 12 for cards, and panels, 16 for dialogs. Pills use a full radius.
-Depth comes from warm, soft shadows in three steps (`elevation.1` to `elevation.3`), never from glowing edges. Menus use step 2, dialogs step 3. Dark theme relies on lighter surfaces rather than shadows. Blur and frosted-glass effects are not used because they cost battery and frame time.
+Depth comes from warm, soft shadows in three steps (`elevation.1` to `elevation.3`), never from glowing edges. Menus use step 2, dialogs step 3. Dark theme relies on lighter surfaces rather than shadows. Blur and frosted-glass effects are not used because they cost battery and frame time. Glows are drawn as flat radial gradients, never blur.
## 8. Icons, logo, and illustration
-Icons come from Phosphor Icons (MIT license) in the Regular weight at 20 pixels, switching to Fill for an active toggle. Custom icons, such as the pen tools, follow the same 1.5-pixel line style. Menu items pair icons with text; icon-only buttons always have a tooltip with the name and shortcut.
+### Icons
+
+Icons come from Phosphor Icons (MIT license) in the Regular weight at 20 pixels, switching to Fill for an active toggle. Custom icons, such as the pen tools, follow the same 1.5-pixel line style. Menu items pair icons with text; icon-only buttons always have a tooltip with the name and shortcut. A button that shows its name gets a tooltip only to add its shortcut, never one that repeats its label.
+
+### Logo
The logo is a folded page with a moss-green ink stroke ending in a clay pen tip: see [`brand/logo-mark.svg`](../brand/logo-mark.svg) and [`brand/app-icon.svg`](../brand/app-icon.svg). Keep clear space of half the mark's width around it, and don't recolor, stretch or add effects.
-Illustrations appear only in empty states, onboarding and error screens. Draw them with OpenNote's own pen engine, so they look handwritten, in two or three token colors with plenty of empty paper. No 3D renders, gradient blobs, stock photos, or mascots.
+### Illustrations
+
+Illustrations appear in onboarding, empty states, error screens, the theme previews, Settings > About and the notebooks pane footer. A pane or card has at most one, and none is larger than the welcome desk at 360 by 225 pixels. The motif set is a window with a vine along its frame, a plant and vine, candles, books, a sunset, and stars with a moon.
+
+| Drawing | Shown at | Holds |
+|---|---|---|
+| Welcome desk | 360 by 225 (drawn at 240 by 150) | The window with its vine, a plant on the sill, four stacked books, an open notebook, and two candles of different heights |
+| Empty states | 176 pixels wide | A shelf of books with a small potted plant; an open notebook and a candle; four books and a candle |
+| About | The window, 112 pixels tall | The sunset or the moon and stars, and the vine |
+| Notebooks pane footer | The plant, 42 by 50 | A clay pot with a short vine trailing to the left |
+
+A drawing never moves to a pane that has none, and the extra motifs live inside the drawings above, never as new decoration.
+
+- They are inline SVG in the 1.5-pixel line style, drawn with a slight wobble so they look handwritten, using only token colors.
+- Lines are `border.control`. Fills come from the five drawing tints and warm paper, so every book has a colored spine, every pot is filled, and every flame has a warm glow.
+- The day window holds a sunset band behind the sun, and the evening window a crescent moon with a few stars, by theme.
+- Each is under 6 KB of markup, hidden from screen readers, and never sits behind text.
+
+### Drawing geometry
+
+- Things in a drawing obey gravity. Books, pots, the notebook, and the candles stand on their shelf, desk, or sill line. None sinks into its line or floats above it. A leaning book stands on its lower corner and rests against its neighbor. Each book in a stack rests on the one below it, and an open notebook rests on its spine. A trailing vine lies on its surface, never below it. The component tests check all of these.
+- Nothing crowds another shape, such as a star against a leaf or the sun against a window bar. The window vine keeps at least 4 units from the stars and the moon, and the two candles' dishes don't overlap.
+- A drawing set above left-aligned text is cropped to its content, so its shelf starts where the text starts.
+- Every line stays inside its drawing's box at any drawn size, stroke width included, so no edge is clipped.
+
+### The ambient canvas
+
+- The page canvas carries window light in Daylight and a dusk-to-stars sky in Evening, around the page card, and never behind the writing. The card itself stays plain paper.
+- By day, warm light pools from the window's top corner, and a soft patch of it, wider than it is tall, lies on the desk. The patch stops growing at 420 by 300 pixels, so a wide window doesn't stretch it. The setup backdrop keeps only the pool, so the steps stay calm.
+- In the evening, the sky deepens from night indigo through dusk to a low sunset band at the horizon. Twenty-five stars sit at the top, 0.75 to 1.25 pixels in radius at 45 to 70 percent opacity, with three larger ones, up to 1.8 pixels, at up to 80 percent. They are drawn at 1:1 at any width, and each stays above the card's top edge, at least 5 pixels clear of it, so none looks like a speck on the card.
+
+No 3D renders, gradient blobs, stock photos, or mascots.
## 9. Motion
@@ -216,6 +290,7 @@ Hard rules:
- Animations never block input. Clicking during a transition acts at once. The one exception is the theme crossfade: for its 200 ms, clicks don't reach the page, though keyboard shortcuts still work.
- Loading shows nothing for the first 300 ms, then a quiet progress bar. Skeleton screens don't shimmer.
- The recording dot pulses slowly (2-second cycle). It is the only looping animation.
+- Illustrations fade in once over `fast` and never loop. Candles don't flicker and stars don't twinkle.
When Windows "Animation effects" is off, or the operating system asks for reduced motion, movement becomes a 100 ms crossfade and the recording dot stops pulsing.
@@ -249,12 +324,15 @@ Heavy work, such as handwriting recognition, transcription, search indexing, exp
| Page canvas | Paper backgrounds (plain, lined, dot grid, graph, Cornell) drawn with `border.subtle`. Page breaks are dashed lines with the page number in `text.muted`. |
| Recording bar | Pinned above the page, with a pulsing dot, elapsed time, and pause and stop buttons. Timestamps link to ink and text. |
| Theme toggle | Sun and moon icon in the title bar with the tooltip "Dark mode (Ctrl+Shift+D)". Shows the current state and exposes it to screen readers as a switch, with a hidden description of how to reach Light, Dark, and Match Windows. |
+| Choice cards | A radio group of cards. The cards of one choice share one width, and a row that wraps splits evenly, never leaving one card alone. |
| Theme cards | Three cards in a radio group named "Theme", each with a small live preview that screen readers and Tab skip. Holding an arrow key moves one step. |
+| Empty states | One small drawing above one plain sentence, on a page card. The sentence carries the meaning; the drawing is only company. |
+| Ambient canvas | The desk behind the page card: window light in Daylight, a dusk sky with a few stars in Evening. Static, painted once, and kept within 1.3:1 of `surface.sunken`. Flat under Windows contrast themes. |
| Sync status | A small icon with a text tooltip in the title bar: saved, syncing, offline, or needs attention. Never a blocking dialog. |
| Dialogs | Title, one-sentence explanation, then actions (primary on the right). Escape closes. Focus returns to where it was. |
| Update notice | A small "Update ready" chip in the title bar, with release notes on hover or tap. "Restart to update" applies it; otherwise it applies the next time the app closes. Never a pop-up, never mid-task. |
| Toasts | Bottom center, one at a time, with an Undo action for destructive changes. |
-| Command palette | Ctrl+K opens a searchable list of every command, with shortcuts. |
+| Command palette | Ctrl+K opens a searchable list of every command, with shortcuts. With no matches it says "No results." where the results would be. |
## 12. Accessibility
@@ -278,6 +356,7 @@ Every pull request that changes UI goes through automated accessibility tests an
- Bouncy or elastic easing, parallax, auto-playing media, and confetti.
- Spinners that appear instantly, skeleton shimmer and fake progress.
- Icon-only toolbars without tooltips; gray text that fails contrast.
+- Wallpapers, textures, or decoration behind text or the writing surface; twinkling, flickering, or drifting decoration; illustrations in menus, dialogs, or toasts.
- Dark patterns: pre-checked upsells, hidden "No thanks" links, nagging sign-in prompts.
- AI features that start on their own or send data without saying so.
diff --git a/docs/CHECKS.md b/docs/CHECKS.md
index 2e72dbc2..e3e3988f 100644
--- a/docs/CHECKS.md
+++ b/docs/CHECKS.md
@@ -43,7 +43,7 @@ Other options: `--staged`, `--base `, `--rules spelling,grammar`, `--strict
| `logical-properties` | Warns about physical CSS properties and values, such as `left`, `margin-right`, `width`, and `text-align: left`, so a right-to-left language later needs only `dir="rtl"` | Style sheets and inline styles in `app/src` |
| `ui-voice` | Interface text follows the voice in BRAND.md: sentence case, no exclamation marks, no "Oops", no emoji, digits for numbers, and shortcuts written as Ctrl+Shift+D. The spelling, grammar, and AI marker rules also run on each string. ICU messages are checked branch by branch | The string files in `app/src/strings/en` |
| `layout` | Renders each SVG in a headless browser and checks overlapping text, padding inside containers, centering, alignment, edge spacing, minimum text size, safe margins, scaling, and sentence case | SVG graphics |
-| `brand-tokens` | `brand/tokens.json` themes match, colors are valid, text meets contrast targets, motion stays under 400 ms. The `forcedColors` map for Windows contrast themes must name existing color tokens and CSS system colors such as `Canvas` or `Highlight`. Contrast pairs can name pens and highlighters (`ink.pens.Fern`), checked with each theme's value, and `over` composites a translucent background, such as a highlighter, over a surface first | Design tokens |
+| `brand-tokens` | `brand/tokens.json` themes match, colors are valid, text meets contrast targets, motion stays under 400 ms. The `forcedColors` map for Windows contrast themes must name existing color tokens and CSS system colors such as `Canvas` or `Highlight`. Contrast pairs can name pens and highlighters (`ink.pens.Fern`), checked with each theme's value, and `over` composites a translucent background, such as a highlighter, over a surface first. A pair can set a floor (`min`), a ceiling (`max`), or both: ambient washes use a ceiling so they stay close to the surface beside them | Design tokens |
The word lists live in `checks/data/` as JSON, so you can extend them without touching code.
diff --git a/docs/design/SCREENS.md b/docs/design/SCREENS.md
index 1819d723..dd4ec9f8 100644
--- a/docs/design/SCREENS.md
+++ b/docs/design/SCREENS.md
@@ -37,7 +37,7 @@ Keep-out zones that apply to every desktop screen:

-Setup has five short steps in a centered 720 × 600 card:
+Setup has five short steps in a centered 720 × 672 card:
1. **Welcome:** what OpenNote is, in one sentence.
2. **Choose your look:** Light, Dark, or Match Windows (shown above). Match Windows is selected in advance, with a caption that names the Windows setting, and clicking a card repaints the whole screen at once.
diff --git a/docs/design/images/01-first-run-look.svg b/docs/design/images/01-first-run-look.svg
index a0b64864..1973288a 100644
--- a/docs/design/images/01-first-run-look.svg
+++ b/docs/design/images/01-first-run-look.svg
@@ -9,28 +9,28 @@
OpenNote—▢✕
-
-
-
-
-
-
-Step 2 of 5
-Choose your look
-Change it any time with the ☾ button or Ctrl+Shift+D.
-LightAlways light
-DarkAlways dark
-Match WindowsFollows Windows✓
-Preselected because Windows is set to Light.
-Back
+
+
+
+
+
+
+Step 2 of 5
+Choose your look
+Change it any time with the ☾ button or Ctrl+Shift+D.
+LightAlways light
+DarkAlways dark
+Match WindowsFollows Windows✓
+Preselected because Windows is set to Light.
+Back
-
-Continue
+
+Continue
-Setup card 720×600, centered
-Theme cards 200×200, gap 24
-Clicking a card repaints the whole screen
-Focus starts on the preselected card
+Setup card 720×672, centered
+Theme cards 200×200, gap 24
+Clicking a card repaints the whole screen
+Focus starts on the preselected cardWindow buttons 138×40Keep-out zone: nothing interactive or important goes hereLayout region with its size in pixels (at 100% scaling)OpenNote wireframe · colors from brand/tokens.json
diff --git a/docs/design/images/02-first-run-storage.svg b/docs/design/images/02-first-run-storage.svg
index cc3dab99..9f47892f 100644
--- a/docs/design/images/02-first-run-storage.svg
+++ b/docs/design/images/02-first-run-storage.svg
@@ -9,44 +9,44 @@
OpenNote—▢✕
-
-
-
-
-
-
-Step 3 of 5
-Where should OpenNote keep things?
-Your notes are plain files you own. Updates never touch them.
-Your notes
-
-C:\Users\you\Documents\OpenNote
-
-Change…
-The app
-
-
-Add OpenNote to the Start menu (recommended)
-Moves OpenNote.exe to your user folder. No administrator rights needed.
-
-Keep it where it is
-Downloads\OpenNote.exe
-Your first notebook
-
-My notebook
-
-
-
-
-
-Back
+
+
+
+
+
+
+Step 3 of 5
+Where should OpenNote keep things?
+Your notes are plain files you own. Updates never touch them.
+Your notes
+
+C:\Users\you\Documents\OpenNote
+
+Change…
+The app
+
+
+Add OpenNote to the Start menu (recommended)
+Moves OpenNote.exe to your user folder. No administrator rights needed.
+
+Keep it where it is
+Downloads\OpenNote.exe
+Your first notebook
+
+My notebook
+
+
+
+
+
+Back
-
-Continue
+
+Continue
-
-Full path, wraps instead of cutting
+
+Full path, wraps instead of cuttingWindow buttons 138×40
-Step 4 (optional): import from OneNote or Evernote
+Step 4 (optional): import from OneNote or EvernoteKeep-out zone: nothing interactive or important goes hereLayout region with its size in pixels (at 100% scaling)OpenNote wireframe · colors from brand/tokens.json
diff --git a/docs/design/images/02b-first-run-smart.svg b/docs/design/images/02b-first-run-smart.svg
index e631d107..708f7e6d 100644
--- a/docs/design/images/02b-first-run-smart.svg
+++ b/docs/design/images/02b-first-run-smart.svg
@@ -9,37 +9,37 @@
OpenNote—▢✕
-
-
-
-
-
-
-Step 4 of 5
-Smart features
-Everything runs on this device. Nothing leaves it.
-
-
-
-Recommended
-Transcription, handwriting, text in images, and summaries. About 600 MB.
-
-
-Custom
-Choose each feature, and a local model or your own cloud key.
-
-
-Not now
-Everything stays off. Turn features on later in Settings.
-Models download in the background. You can start taking notes right away.
-Back
+
+
+
+
+
+
+Step 4 of 5
+Smart features
+Everything runs on this device. Nothing leaves it.
+
+
+
+Recommended
+Transcription, handwriting, text in images, and summaries. About 600 MB.
+
+
+Custom
+Choose each feature, and a local model or your own cloud key.
+
+
+Not now
+Everything stays off. Turn features on later in Settings.
+Models download in the background. You can start taking notes right away.
+Back
-
-Continue
+
+Continue
-
-Option cards 652×84, whole card clickable
-Sizes shown before any download
+
+Option cards 652×84, whole card clickable
+Sizes shown before any downloadWindow buttons 138×40Keep-out zone: nothing interactive or important goes hereLayout region with its size in pixels (at 100% scaling)OpenNote wireframe · colors from brand/tokens.json
diff --git a/docs/design/screens/first-run.ts b/docs/design/screens/first-run.ts
index 918b8444..c239bf9e 100644
--- a/docs/design/screens/first-run.ts
+++ b/docs/design/screens/first-run.ts
@@ -4,7 +4,7 @@ import { type Box, type Palette, captionKeepOut, circle, palette, rect, region,
import { WIDE, miniApp, titleBar } from '../lib/chrome.ts';
import { type Screen, makeScreen } from './screen.ts';
-const CARD: Box = { x: 360, y: 130, w: 720, h: 600 };
+const CARD: Box = { x: 360, y: 114, w: 720, h: 672 };
function setupCard(p: Palette, step: number, title: string, subtitle: string): string[] {
const center = CARD.x + CARD.w / 2;
@@ -83,7 +83,7 @@ export function firstRunLook(): Screen {
anchor: 'middle',
}),
...buttons(p, 'Continue'),
- region(CARD, 'Setup card 720×600, centered'),
+ region(CARD, 'Setup card 720×672, centered'),
region({ x: CARD.x + 30, y: cardsY - 6, w: 660, h: 212 }, 'Theme cards 200×200, gap 24'),
tag(CARD.x + CARD.w + 16, cardsY + 100, 'Clicking a card repaints the whole screen', NOTE.region),
tag(CARD.x + CARD.w + 16, cardsY + 124, 'Focus starts on the preselected card', NOTE.region),
diff --git a/tests/ui/behavior/ambient.spec.ts b/tests/ui/behavior/ambient.spec.ts
new file mode 100644
index 00000000..62e4066d
--- /dev/null
+++ b/tests/ui/behavior/ambient.spec.ts
@@ -0,0 +1,159 @@
+// The desk-by-the-window look in the production build (docs/BRAND.md sections 4, 8, and 9): the drawings never sit on
+// text, nothing loops, reduced motion removes the fade-in, and the ambient canvas lies behind the opaque page card.
+
+import type { Page } from '@playwright/test';
+import { expect, test } from '../fixtures';
+import { loadScreens, openScreen, settle } from '../screens';
+import type { ScreenState, SizeClass, Theme } from '../screens';
+
+const screens = await loadScreens();
+const byId = (id: string): ScreenState => {
+ const screen = screens.find((candidate) => candidate.id === id);
+ if (!screen) throw new Error(`No screen state ${id}`);
+ return screen;
+};
+
+const THEMES: readonly Theme[] = ['light', 'dark'];
+const SIZES: readonly SizeClass[] = ['compact', 'wide'];
+const DRAWN = [
+ 'setup.welcome',
+ 'setup.look',
+ 'setup.storage',
+ 'workspace.sample',
+ 'workspace.empty',
+ 'trash.empty',
+ 'settings.appearance',
+ 'settings.about',
+];
+
+/**
+ * The drawings and marks: every hidden svg that cannot take focus. The logo is one, beside the app name. The star
+ * field is left out because it is the sky itself, under the opaque page card; its own test checks that.
+ */
+const STARS = 'svg[viewBox="0 0 1280 160"]';
+const DRAWINGS = `svg[aria-hidden="true"][focusable="false"]:not(${STARS})`;
+
+/** The text of every text node whose box meets a drawing's box. */
+async function overlaps(page: Page): Promise {
+ return page.evaluate((selector) => {
+ const clash: string[] = [];
+ const boxes: { text: string; rect: DOMRect }[] = [];
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent?.trim();
+ const parent = node.parentElement;
+ if (!text || !parent || parent.closest('svg, script, style')) continue;
+ if (getComputedStyle(parent).visibility === 'hidden') continue;
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ for (const rect of range.getClientRects()) if (rect.width > 0 && rect.height > 0) boxes.push({ text, rect });
+ }
+ for (const art of document.querySelectorAll(selector)) {
+ const a = art.getBoundingClientRect();
+ if (a.width === 0 || a.height === 0) continue;
+ for (const { text, rect: t } of boxes) {
+ const apart =
+ a.right <= t.left + 0.5 || t.right <= a.left + 0.5 || a.bottom <= t.top + 0.5 || t.bottom <= a.top + 0.5;
+ if (!apart) clash.push(text);
+ }
+ }
+ return clash;
+ }, DRAWINGS);
+}
+
+for (const id of DRAWN) {
+ for (const theme of THEMES) {
+ for (const size of SIZES) {
+ test(`${id} draws nothing over its text at ${size} in ${theme}`, async ({ page }) => {
+ await openScreen(page, byId(id), { size, theme });
+ // A settings section loads after its heading, so wait for the first drawing to arrive.
+ await expect(page.locator(DRAWINGS).first()).toBeAttached();
+ await settle(page);
+ expect(await overlaps(page)).toEqual([]);
+ });
+ }
+ }
+}
+
+for (const theme of THEMES) {
+ test(`loops nothing once the page has settled, in ${theme}`, async ({ page }) => {
+ await openScreen(page, byId('workspace.sample'), { size: 'wide', theme });
+ const looping = await page.evaluate(
+ () =>
+ document.getAnimations().filter((animation) => animation.effect?.getComputedTiming().iterations === Infinity)
+ .length,
+ );
+ expect(looping).toBe(0);
+ });
+
+ test(`lays the ambient canvas behind an opaque page card, in ${theme}`, async ({ page }) => {
+ await openScreen(page, byId('workspace.sample'), { size: 'wide', theme });
+ const found = await page.evaluate((starSelector) => {
+ const card = document.querySelector('article') as HTMLElement;
+ const stars = document.querySelector(starSelector);
+ let canvas: HTMLElement | null = card.parentElement;
+ while (canvas && getComputedStyle(canvas).backgroundImage === 'none') canvas = canvas.parentElement;
+ const box = card.getBoundingClientRect();
+ const top = document.elementFromPoint(box.left + box.width / 2, box.top + 24);
+ return {
+ canvas: Boolean(canvas),
+ canvasHoldsCard: Boolean(canvas?.contains(card)),
+ canvasIsolated: canvas ? getComputedStyle(canvas).isolation : null,
+ cardBackground: getComputedStyle(card).backgroundColor,
+ cardOnTop: Boolean(top && card.contains(top)),
+ starsDisplay: stars ? getComputedStyle(stars).display : null,
+ starsLayer: stars ? getComputedStyle(stars).zIndex : null,
+ };
+ }, STARS);
+ expect(found.canvas).toBe(true);
+ expect(found.canvasHoldsCard).toBe(true);
+ expect(found.canvasIsolated).toBe('isolate');
+ expect(found.cardBackground).toMatch(/^rgb\(/);
+ expect(found.cardOnTop).toBe(true);
+ // The stars are only in the evening sky, and always under the card.
+ expect(found.starsDisplay).toBe(theme === 'dark' ? 'block' : 'none');
+ expect(Number(found.starsLayer)).toBeLessThan(0);
+ });
+
+ test(`paints window light by day or a dusk sky at night around a plain card, in ${theme}`, async ({ page }) => {
+ await openScreen(page, byId('workspace.sample'), { size: 'wide', theme });
+ const seen = await page.evaluate(() => {
+ const card = document.querySelector('article') as HTMLElement;
+ let canvas: HTMLElement | null = card.parentElement;
+ while (canvas && getComputedStyle(canvas).backgroundImage === 'none') canvas = canvas.parentElement;
+ const image = canvas ? getComputedStyle(canvas).backgroundImage : '';
+ return { layers: image.match(/(radial|linear)-gradient/g) ?? [], card: getComputedStyle(card).backgroundImage };
+ });
+ // The page card itself stays plain paper.
+ expect(seen.card).toBe('none');
+ // By day: the patch of light and the pool from the corner. At night: the sunset band low on the horizon over the
+ // night-to-dusk sky.
+ expect(seen.layers).toEqual(
+ theme === 'light' ? ['radial-gradient', 'radial-gradient'] : ['radial-gradient', 'linear-gradient'],
+ );
+ });
+}
+
+test('fades the drawings in once, and not at all with reduced motion', async ({ page }) => {
+ const fade = () =>
+ page.evaluate(() => {
+ const art = document.querySelector('svg[aria-hidden="true"][focusable="false"][viewBox="0 0 240 150"]');
+ return art ? getComputedStyle(art).animationName : null;
+ });
+ await openScreen(page, byId('setup.welcome'), { size: 'wide', theme: 'light' });
+ expect(await fade()).not.toBe('none');
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.reload();
+ await expect(page.getByRole('heading', { level: 1, name: 'Welcome to OpenNote' })).toBeVisible();
+ expect(await fade()).toBe('none');
+});
+
+test('keeps the plant out of the way in the compact layout', async ({ page }) => {
+ await openScreen(page, byId('workspace.sample'), { size: 'compact', theme: 'light' });
+ const shown = await page.evaluate(
+ () =>
+ [...document.querySelectorAll('svg[viewBox="0 0 56 66"]')].filter((svg) => svg.getBoundingClientRect().width > 0)
+ .length,
+ );
+ expect(shown).toBe(0);
+});
diff --git a/tests/ui/behavior/forced-colors.spec.ts b/tests/ui/behavior/forced-colors.spec.ts
index bfbf3e09..8a23cd3e 100644
--- a/tests/ui/behavior/forced-colors.spec.ts
+++ b/tests/ui/behavior/forced-colors.spec.ts
@@ -26,3 +26,31 @@ for (const colorScheme of ['light', 'dark'] as const) {
expect(colors.toggle).toBe(colors.highlight);
});
}
+
+// A Windows contrast theme gets a flat canvas, no stars, and plain outlines in the text color (BRAND.md section 4).
+for (const colorScheme of ['light', 'dark'] as const) {
+ test(`draws plain outlines on a flat canvas in forced colors, ${colorScheme}`, async ({ page }) => {
+ await page.emulateMedia({ forcedColors: 'active', colorScheme });
+ await page.goto('/');
+ await expect(page.getByRole('heading', { level: 1, name: 'No page open' })).toBeVisible();
+ const seen = await page.evaluate(() => {
+ const probe = document.body.appendChild(document.createElement('div'));
+ probe.style.color = 'CanvasText';
+ const text = getComputedStyle(probe).color;
+ const art = document.querySelector('article svg[aria-hidden="true"]') as SVGElement;
+ const line = art.querySelector('path') as SVGPathElement;
+ const stars = document.querySelector('svg[viewBox="0 0 1280 160"]') as SVGElement;
+ return {
+ text,
+ stroke: getComputedStyle(line).stroke,
+ fill: getComputedStyle(line).fill,
+ canvas: getComputedStyle(stars.parentElement as HTMLElement).backgroundImage,
+ stars: getComputedStyle(stars).display,
+ };
+ });
+ expect(seen.stroke).toBe(seen.text);
+ expect(seen.fill).toBe('none');
+ expect(seen.canvas).toBe('none');
+ expect(seen.stars).toBe('none');
+ });
+}
diff --git a/tests/ui/behavior/geometry.spec.ts b/tests/ui/behavior/geometry.spec.ts
new file mode 100644
index 00000000..f5b226f9
--- /dev/null
+++ b/tests/ui/behavior/geometry.spec.ts
@@ -0,0 +1,284 @@
+// Geometry on every registered screen state (tests/ui/screens/*.ts). Nothing spills past the window, the window
+// itself never scrolls, and a dialog never scrolls around its own content. Every focus ring that Tab shows is
+// whole. A centered button's or card's content sits within 1 px of its middle.
+//
+// Side by side, no table cell's content runs past the cell, a wrapped choice leaves no card alone on its last row,
+// and the cards of one choice share one width. Screenshot reviews found these defects by eye; the checks keep
+// them fixed.
+
+import type { Page } from '@playwright/test';
+import { expect, test } from '../fixtures';
+import { loadScreens, openScreen, settle } from '../screens';
+import type { ScreenState, SizeClass } from '../screens';
+
+const screens = await loadScreens();
+const SIZES: readonly SizeClass[] = ['expanded', 'wide'];
+
+/** A short window, 820x620, where tall dialogs and scrollers ran out of room. */
+const SHORT = { width: 820, height: 620 };
+
+/** The most Tab presses a focus walk takes on one screen. */
+const TAB_STOPS = 40;
+
+// A long screen, such as the shortcut list, takes about 25 s for its 40 Tab stops and its measures.
+test.describe.configure({ timeout: 60_000 });
+
+interface Geometry {
+ sideways: number;
+ /** How far the document itself scrolls down. The app scrolls inside its panes, never the whole window. */
+ downward: number;
+ dialogs: { name: string; overflow: number; bottomGap: number; drawer: boolean }[];
+ offCenter: string[];
+}
+
+/** Measures the page as it is: sideways scroll, each dialog's own scroll and room, and off-center buttons. */
+function measure(page: Page): Promise {
+ return page.evaluate(() => {
+ const visible = (el: Element) => {
+ const rect = el.getBoundingClientRect();
+ return rect.width > 1 && rect.height > 1 && getComputedStyle(el).visibility === 'visible';
+ };
+ const name = (el: Element) =>
+ el.getAttribute('aria-label') ?? el.textContent?.trim().slice(0, 40) ?? el.tagName.toLowerCase();
+ const dialogs = [...document.querySelectorAll('[role="dialog"], dialog[open]')].filter(visible).map((el) => {
+ const dialog = el as HTMLElement;
+ return {
+ name: name(dialog),
+ overflow: dialog.scrollHeight - dialog.clientHeight,
+ bottomGap: window.innerHeight - dialog.getBoundingClientRect().bottom,
+ drawer: dialog.closest('[data-placement="start"]') !== null,
+ };
+ });
+ const offCenter: string[] = [];
+ for (const button of document.querySelectorAll('button, [role="button"]')) {
+ if (!visible(button)) continue;
+ const style = getComputedStyle(button);
+ if (!style.display.includes('flex') && !style.display.includes('grid')) continue;
+ const range = document.createRange();
+ range.selectNodeContents(button);
+ const content = range.getBoundingClientRect();
+ if (content.width === 0) continue;
+ const box = button.getBoundingClientRect();
+ const left = box.left + button.clientLeft + parseFloat(style.paddingLeft);
+ const right = box.right - (box.width - button.clientLeft - button.clientWidth) - parseFloat(style.paddingRight);
+ const top = box.top + button.clientTop + parseFloat(style.paddingTop);
+ const bottom =
+ box.bottom - (box.height - button.clientTop - button.clientHeight) - parseFloat(style.paddingBottom);
+ const dx = (content.left + content.right) / 2 - (left + right) / 2;
+ const dy = (content.top + content.bottom) / 2 - (top + bottom) / 2;
+ // A grid card, such as a text size choice, centers its label with justify-items.
+ const centersX =
+ style.justifyContent === 'center' || (style.display.includes('grid') && style.justifyItems === 'center');
+ const centersY = style.alignItems === 'center';
+ if ((centersX && Math.abs(dx) > 1) || (centersY && Math.abs(dy) > 1)) {
+ offCenter.push(`${name(button)}: ${dx.toFixed(1)}, ${dy.toFixed(1)} px off center`);
+ }
+ }
+ const root = document.documentElement;
+ const sideways = root.scrollWidth - window.innerWidth;
+ return { sideways, downward: root.scrollHeight - window.innerHeight, dialogs, offCenter };
+ });
+}
+
+interface SideBySide {
+ /** Table cell contents that run past their cell, such as a key cap under the next column's button. */
+ spills: string[];
+ /** Choices that wrap and leave a last row less than half as full as the first, such as 175% and 200% alone. */
+ lonely: string[];
+ /** Choices in the main content whose cards differ in width, so their edges don't line up. */
+ ragged: string[];
+}
+
+/** Measures what sits side by side: each table cell's contents, and the cards of each choice. */
+function measureSideBySide(page: Page): Promise {
+ return page.evaluate(() => {
+ const shown = (el: Element) => el.getBoundingClientRect().width > 1 && el.getBoundingClientRect().height > 1;
+ const spills: string[] = [];
+ for (const cell of [...document.querySelectorAll('td, th')].filter(shown)) {
+ const box = cell.getBoundingClientRect();
+ const over = Math.max(
+ 0,
+ ...[...cell.querySelectorAll('*')].map((child) => {
+ const rect = child.getBoundingClientRect();
+ return rect.width ? Math.max(rect.right - box.right, box.left - rect.left) : 0;
+ }),
+ );
+ if (over > 0.5) spills.push(`${cell.textContent?.trim().slice(0, 40)}: ${over.toFixed(1)} px past its cell`);
+ }
+ const lonely: string[] = [];
+ const ragged: string[] = [];
+ for (const group of document.querySelectorAll('[role="radiogroup"]')) {
+ const cards = [...group.querySelectorAll('[role="radio"]')].filter(shown).map((c) => c.getBoundingClientRect());
+ const label = group.getAttribute('aria-label') ?? '';
+ const tops = [...new Set(cards.map((card) => Math.round(card.top)))];
+ const rows = tops.map((top) => cards.filter((card) => Math.round(card.top) === top).length);
+ if (rows.length > 1 && rows[rows.length - 1] * 2 < rows[0]) lonely.push(`${label}: rows of ${rows.join(' + ')}`);
+ const widths = cards.map((card) => card.width);
+ if (group.closest('main') && Math.max(...widths) - Math.min(...widths) > 1) {
+ ragged.push(`${label}: ${widths.map((width) => width.toFixed(0)).join(', ')} px`);
+ }
+ }
+ return { spills, lonely, ragged };
+ });
+}
+
+/**
+ * Where the focused element's ring is cut: by the window or by an ancestor that clips its overflow and contains it.
+ * Returns nothing when the ring is whole or the element draws none.
+ */
+function ringCut(page: Page): Promise {
+ return page.evaluate(() => {
+ const el = document.activeElement as HTMLElement | null;
+ if (!el || el === document.body || el.isContentEditable) return null;
+ const style = getComputedStyle(el);
+ const width = parseFloat(style.outlineWidth);
+ if (style.outlineStyle === 'none' || !width) return null;
+ const rect = el.getBoundingClientRect();
+ if (rect.width <= 1 || rect.height <= 1) return null;
+ const grow = parseFloat(style.outlineOffset) + width;
+ const ring = { left: rect.left - grow, top: rect.top - grow, right: rect.right + grow, bottom: rect.bottom + grow };
+ const label = el.getAttribute('aria-label') ?? el.textContent?.trim().slice(0, 40) ?? el.tagName;
+ const cut = (box: { left: number; top: number; right: number; bottom: number }, by: string) => {
+ const sides = [
+ ['left', box.left - ring.left],
+ ['top', box.top - ring.top],
+ ['right', ring.right - box.right],
+ ['bottom', ring.bottom - box.bottom],
+ ].filter(([, amount]) => (amount as number) > 0.5);
+ return sides.length
+ ? `${el.getAttribute('role') ?? el.tagName.toLowerCase()} "${label}": cut ${sides
+ .map(([side, amount]) => `${side} ${(amount as number).toFixed(1)}`)
+ .join(', ')} by ${by}`
+ : null;
+ };
+ const byWindow = cut({ left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }, 'the window');
+ if (byWindow) return byWindow;
+ // Walk the containing blocks: an ancestor clips a fixed or absolute descendant only when it contains it.
+ let mode = style.position;
+ for (let node = el.parentElement; node; node = node.parentElement) {
+ const s = getComputedStyle(node);
+ if (s.display === 'contents') continue;
+ const transformed =
+ s.transform !== 'none' || s.filter !== 'none' || /paint|layout|strict|content/.test(s.contain);
+ if (mode === 'fixed' && !transformed) continue;
+ if (mode === 'absolute' && s.position === 'static' && !transformed) continue;
+ mode = s.position === 'fixed' || s.position === 'absolute' ? s.position : 'static';
+ if (s.overflowX === 'visible' && s.overflowY === 'visible') continue;
+ const box = node.getBoundingClientRect();
+ const left = box.left + node.clientLeft;
+ const top = box.top + node.clientTop;
+ const result = cut(
+ { left, top, right: left + node.clientWidth, bottom: top + node.clientHeight },
+ `${node.tagName.toLowerCase()}.${[...node.classList].join('.')}`,
+ );
+ if (result) return result;
+ }
+ return null;
+ });
+}
+
+/** Presses Tab through the screen and lists every focus ring that is cut. Stops when focus comes round again. */
+async function focusWalk(page: Page): Promise {
+ const cuts: string[] = [];
+ const seen = new Set();
+ for (let stop = 0; stop < TAB_STOPS; stop += 1) {
+ await page.keyboard.press('Tab');
+ const id = await page.evaluate(() => {
+ const el = document.activeElement;
+ if (!el || el === document.body) return 'body';
+ if ((el as HTMLElement).isContentEditable) return 'editor';
+ const path: string[] = [];
+ for (let node: Element | null = el; node; node = node.parentElement) {
+ path.push(`${node.tagName}${node.parentElement ? [...node.parentElement.children].indexOf(node) : ''}`);
+ }
+ return path.join('<');
+ });
+ // The editor takes Tab for itself, and a second visit means the walk has gone round.
+ if (id === 'editor' || seen.has(id)) break;
+ seen.add(id);
+ const cut = await ringCut(page);
+ if (cut) cuts.push(cut);
+ }
+ return cuts;
+}
+
+async function expectSound(page: Page, screen: ScreenState) {
+ const geometry = await measure(page);
+ expect(geometry.sideways, 'the page scrolls sideways').toBeLessThanOrEqual(0);
+ expect(geometry.downward, 'the whole window scrolls, title bar and all').toBeLessThanOrEqual(0);
+ for (const dialog of geometry.dialogs) {
+ expect(dialog.overflow, `the dialog "${dialog.name}" scrolls around its own content`).toBeLessThanOrEqual(1);
+ if (!dialog.drawer) {
+ expect(dialog.bottomGap, `the dialog "${dialog.name}" reaches the window's bottom edge`).toBeGreaterThan(8);
+ }
+ }
+ expect(geometry.offCenter, `off-center button content on ${screen.id}`).toEqual([]);
+ const sideBySide = await measureSideBySide(page);
+ expect(sideBySide.spills, `table cell contents past their cells on ${screen.id}`).toEqual([]);
+ expect(sideBySide.lonely, `choices wrapping to a near-empty last row on ${screen.id}`).toEqual([]);
+ expect(sideBySide.ragged, `choices with cards of different widths on ${screen.id}`).toEqual([]);
+ expect(await focusWalk(page), `cut focus rings on ${screen.id}`).toEqual([]);
+}
+
+for (const screen of screens) {
+ for (const size of SIZES.filter((each) => !screen.sizes || screen.sizes.includes(each))) {
+ test(`${screen.id} keeps its geometry at ${size}`, async ({ page }) => {
+ await openScreen(page, screen, { size, theme: 'light' });
+ await expectSound(page, screen);
+ });
+ }
+
+ test(`${screen.id} keeps its geometry in a short window`, async ({ page }) => {
+ await openScreen(page, screen, { size: screen.sizes?.[0] ?? 'expanded', theme: 'light' });
+ await page.setViewportSize(SHORT);
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ await expectSound(page, screen);
+ });
+}
+
+test('the setup theme cards center under the step heading within 1 px', async ({ page }) => {
+ const look = screens.find((screen) => screen.id === 'setup.look');
+ if (!look) throw new Error('The setup.look screen is not registered.');
+ for (const size of ['expanded', 'wide'] as const) {
+ await openScreen(page, look, { size, theme: 'light' });
+ const offset = await page.evaluate(() => {
+ const group = document.querySelector('[role="radiogroup"]');
+ const heading = document.querySelector('h1, h2');
+ if (!group || !heading) return Number.NaN;
+ const cards = [...group.querySelectorAll('[role="radio"]')].map((card) => card.getBoundingClientRect());
+ const middle = (Math.min(...cards.map((card) => card.left)) + Math.max(...cards.map((card) => card.right))) / 2;
+ const title = heading.getBoundingClientRect();
+ return middle - (title.left + title.right) / 2;
+ });
+ expect(Math.abs(offset)).toBeLessThanOrEqual(1);
+ }
+});
+
+test('the setup theme cards meet the footer edges and keep their height when the choice changes', async ({ page }) => {
+ const look = screens.find((screen) => screen.id === 'setup.look');
+ if (!look) throw new Error('The setup.look screen is not registered.');
+ const row = () =>
+ page.evaluate(() => {
+ const cards = [...document.querySelectorAll('[role="radiogroup"] [role="radio"]')].map((card) =>
+ card.getBoundingClientRect(),
+ );
+ const footer = [...document.querySelectorAll('button')]
+ .filter((button) => ['Back', 'Continue'].includes(button.textContent?.trim() ?? ''))
+ .map((button) => button.getBoundingClientRect());
+ return {
+ start: Math.min(...cards.map((card) => card.left)) - Math.min(...footer.map((button) => button.left)),
+ end: Math.max(...cards.map((card) => card.right)) - Math.max(...footer.map((button) => button.right)),
+ height: Math.max(...cards.map((card) => card.height)),
+ };
+ });
+ for (const size of ['expanded', 'wide'] as const) {
+ await openScreen(page, look, { size, theme: 'light' });
+ const before = await row();
+ expect(Math.abs(before.start), 'the cards start where Back does').toBeLessThanOrEqual(1);
+ expect(Math.abs(before.end), 'the cards end where Continue does').toBeLessThanOrEqual(1);
+ // The Match Windows caption that says why it was preselected gives way to a one-line caption.
+ await page.getByRole('radio', { name: /Dark/ }).click();
+ await settle(page);
+ expect(Math.abs((await row()).height - before.height), 'the row jumps').toBeLessThanOrEqual(0.5);
+ }
+});
diff --git a/tests/ui/behavior/layout.spec.ts b/tests/ui/behavior/layout.spec.ts
index e6fbeeb5..e95e0a0f 100644
--- a/tests/ui/behavior/layout.spec.ts
+++ b/tests/ui/behavior/layout.spec.ts
@@ -84,6 +84,28 @@ test.describe('geometry', () => {
});
});
+// The built app splits its CSS into several files. The layer order (styles/layers.css) has to hold whichever file
+// loads first, or base rules beat the component and state rules.
+test.describe('cascade layers in the built app', () => {
+ test('a keyboard-focused tree row draws its focus ring inside the row', async ({ page }) => {
+ await open(page, 'wide');
+ await page.getByRole('treeitem', { name: 'Lectures' }).focus();
+ await page.keyboard.press('ArrowDown');
+ const offset = await page.evaluate(() => {
+ const row = document.activeElement;
+ return row?.getAttribute('role') === 'treeitem' ? getComputedStyle(row).outlineOffset : 'not a tree row';
+ });
+ expect(offset).toBe('-2px');
+ });
+
+ test('touch density makes the title bar buttons 44 px targets, like the tree rows', async ({ page }) => {
+ await open(page, 'wide');
+ await page.evaluate(() => (document.documentElement.dataset.density = 'touch'));
+ const box = await page.getByRole('button', { name: 'Go back' }).boundingBox();
+ expect(box?.height).toBe(44);
+ });
+});
+
test.describe('without a pointer', () => {
test('a splitter resizes with the arrow keys and collapses with Enter', async ({ page }) => {
await open(page, 'wide');
diff --git a/tests/ui/behavior/settings.spec.ts b/tests/ui/behavior/settings.spec.ts
index e4cf6c1d..9b54d59a 100644
--- a/tests/ui/behavior/settings.spec.ts
+++ b/tests/ui/behavior/settings.spec.ts
@@ -1,5 +1,6 @@
// Settings and the shortcut list in the production build. Ctrl+, opens Settings on the section heading, and a
-// section link moves focus to its heading. Escape leaves. Ctrl+/ lists every shortcut in tables.
+// section link moves focus to its heading. Escape leaves. The section list stays in view while a long section
+// scrolls. Ctrl+/ lists every shortcut in tables.
import { expect, test } from '../fixtures';
@@ -17,6 +18,22 @@ test('Ctrl+, opens Settings on its heading, and Escape leaves it', async ({ page
await expect(page.getByRole('heading', { level: 1, name: 'About' })).toHaveCount(0);
});
+test('the section list stays in view while a long section scrolls', async ({ page }) => {
+ await page.setViewportSize({ width: 820, height: 620 });
+ await page.goto('/');
+ await expect(page.locator('[data-workspace]')).toBeVisible();
+ await page.keyboard.press('Control+Comma');
+ const nav = page.getByRole('navigation', { name: 'Settings sections' });
+ await nav.getByRole('link', { name: 'Shortcuts' }).click();
+ await expect(page.getByRole('heading', { level: 1, name: 'Shortcuts' })).toBeFocused();
+ // The heading is there before the section is: the section loads on its own, and with nothing under the heading
+ // there is nothing to scroll. Its tables are the long part.
+ await expect(page.getByRole('main').getByRole('table').first()).toBeVisible();
+ await page.getByRole('main').evaluate((main) => (main.scrollTop = main.scrollHeight));
+ await expect(page.getByRole('heading', { level: 1, name: 'Shortcuts' })).not.toBeInViewport();
+ await expect(nav.getByRole('link', { name: 'About' })).toBeInViewport();
+});
+
test('Ctrl+/ lists the shortcuts in tables, and Escape closes the list', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Control+Slash');
diff --git a/tests/ui/screens/brand.ts b/tests/ui/screens/brand.ts
new file mode 100644
index 00000000..9692d64b
--- /dev/null
+++ b/tests/ui/screens/brand.ts
@@ -0,0 +1,59 @@
+// The screens that carry the desk-by-the-window look (docs/BRAND.md sections 4 and 8): the three setup steps, the
+// empty Trash, and Settings, Appearance. The focus walk, axe, and the screenshot matrix visit them with their
+// drawings, ambient canvas, and theme-card marks in place. workspace.empty and settings.about are in their own files.
+
+import type { BootOverrides } from '../../../app/src/boot/defaults';
+import { defineScreens } from '../screens';
+
+const firstRun = { firstRun: true, state: { setup: { status: 'notStarted' } } } as BootOverrides;
+const trash = { state: { location: { view: 'trash' } } } as BootOverrides;
+
+export default defineScreens([
+ {
+ id: 'setup.welcome',
+ description: 'First-run setup, the welcome step with the desk',
+ boot: firstRun,
+ prepare: async (page) => {
+ await page.getByRole('heading', { level: 1, name: 'Welcome to OpenNote' }).waitFor();
+ },
+ },
+ {
+ id: 'setup.look',
+ description: 'First-run setup, Choose your look, with the theme cards',
+ boot: firstRun,
+ prepare: async (page) => {
+ await page.getByRole('button', { name: 'Get started' }).click();
+ await page.getByRole('heading', { level: 1, name: 'Choose your look' }).waitFor();
+ },
+ },
+ {
+ id: 'setup.storage',
+ description: 'First-run setup, where to keep things, with the notebook color chips',
+ boot: firstRun,
+ prepare: async (page) => {
+ await page.getByRole('button', { name: 'Get started' }).click();
+ await page.getByRole('button', { name: 'Continue' }).click();
+ await page.getByRole('heading', { level: 1, name: 'Where to keep things' }).waitFor();
+ },
+ },
+ {
+ id: 'trash.empty',
+ description: 'The Trash view with nothing in it, on its page card with the candle and books',
+ boot: trash,
+ prepare: async (page) => {
+ await page.getByText('Trash is empty.').waitFor();
+ },
+ },
+ {
+ id: 'settings.appearance',
+ description: 'Settings, Appearance, with the three theme cards in a row',
+ prepare: async (page) => {
+ await page.keyboard.press('Control+Comma');
+ await page.getByRole('heading', { level: 1 }).first().waitFor();
+ const link = page.getByRole('link', { name: 'Appearance', exact: true });
+ if (await link.isVisible()) await link.click();
+ await page.getByRole('heading', { level: 1, name: 'Appearance', exact: true }).waitFor();
+ await page.getByRole('radiogroup', { name: 'Theme' }).waitFor();
+ },
+ },
+]);