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 && ( + {first && {formatChord(first)}} {result.detail && (