From 8e3eeedb112635c184b3bb38a7a124e1a3f5b12f Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Fri, 2 Oct 2026 19:33:20 -0700 Subject: [PATCH 01/69] Add candle, dusk, night and ambient sky tokens The shell is two colors, oat or walnut with moss, and it needs a few more tones to feel like a desk by a window. Candle, dusk and night are small accent families (a text-safe line color and a subtle fill each) for the sun and moon icons and the drawings that follow. The ambient group holds the window-light and evening-sky stops for the desk canvas, plus a spark color for the sun disc and stars. Every new pairing has a contrast pair in tokens.json: the accents on the four surfaces and on their own fills, text on each fill and on both ambient stops, and the focus ring. The ambient stops also get a ceiling (at most 1.3:1 against the sunken canvas) so the wash stays quiet. To express that, brand-tokens now accepts an optional max next to min. Under forced colors the accents become CanvasText and the fills and ambient tokens become Canvas, so gradients flatten and stars vanish. Co-Authored-By: Claude Fable 5.1 --- app/scripts/build-tokens.test.ts | 21 +++ app/src/theme/tokens.css | 54 +++++++ app/src/theme/tokens.test.ts | 19 +++ app/src/theme/tokens.ts | 232 ++++++++++++++++++++++++++++++- brand/tokens.json | 78 ++++++++++- checks/rules/brand-tokens.ts | 19 ++- checks/test/style-rules.test.ts | 30 ++++ docs/CHECKS.md | 2 +- 8 files changed, 442 insertions(+), 13 deletions(-) diff --git a/app/scripts/build-tokens.test.ts b/app/scripts/build-tokens.test.ts index 513eb03e4..3650e0b52 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: #FFF4DF;'); + expect(blockOf(':root[data-theme="dark"]')).toContain('--color-accent-dusk: #EBA0B2;'); + expect(blockOf(':root[data-theme="dark"]')).toContain('--color-ambient-canvas-bottom: #2E2430;'); + 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', '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/src/theme/tokens.css b/app/src/theme/tokens.css index b2e89c7f6..c3ca1b84f 100644 --- a/app/src/theme/tokens.css +++ b/app/src/theme/tokens.css @@ -22,12 +22,21 @@ --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-ambient-canvas-top: #FFF4DF; + --color-ambient-canvas-bottom: #EAE3D8; + --color-ambient-spark: #F2CF8A; --color-caption-close-hover: #C42B1C; --color-caption-close-hover-text: #FFFCF6; --color-code-keyword: #6A4A9C; @@ -194,12 +203,21 @@ --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-ambient-canvas-top: #1D1F2C; + --color-ambient-canvas-bottom: #2E2430; + --color-ambient-spark: #E7D9B0; --color-caption-close-hover: #C42B1C; --color-caption-close-hover-text: #FFFCF6; --color-code-keyword: #C2ABE8; @@ -243,12 +261,21 @@ --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-ambient-canvas-top: #1D1F2C; + --color-ambient-canvas-bottom: #2E2430; + --color-ambient-spark: #E7D9B0; --color-caption-close-hover: #C42B1C; --color-caption-close-hover-text: #FFFCF6; --color-code-keyword: #C2ABE8; @@ -293,12 +320,21 @@ --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-ambient-canvas-top: #FFF4DF; + --color-ambient-canvas-bottom: #EAE3D8; + --color-ambient-spark: #F2CF8A; --color-caption-close-hover: #C42B1C; --color-caption-close-hover-text: #FFFCF6; --color-code-keyword: #6A4A9C; @@ -340,12 +376,21 @@ --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-ambient-canvas-top: #1D1F2C; + --color-ambient-canvas-bottom: #2E2430; + --color-ambient-spark: #E7D9B0; --color-caption-close-hover: #C42B1C; --color-caption-close-hover-text: #FFFCF6; --color-code-keyword: #C2ABE8; @@ -413,6 +458,15 @@ --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-ambient-canvas-top: Canvas; + --color-ambient-canvas-bottom: 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 dcc6d1dd8..46cff9e45 100644 --- a/app/src/theme/tokens.test.ts +++ b/app/src/theme/tokens.test.ts @@ -72,6 +72,25 @@ 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', '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 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']); + ceilings.forEach((pair) => expect(pair).toMatchObject({ bg: 'surface.sunken', max: 1.3 })); + }); + it('keeps palm rejection and shape timings in milliseconds for code', () => { const { interaction } = tokens; expect(interaction.palmWatchdogMs).toBeGreaterThan(interaction.palmGraceMs); diff --git a/app/src/theme/tokens.ts b/app/src/theme/tokens.ts index 0a51e3ade..564a3accd 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,11 @@ export const tokens = { "selection": { "highlight": "#F4DE93" }, + "ambient": { + "canvasTop": "#FFF4DF", + "canvasBottom": "#EAE3D8", + "spark": "#F2CF8A" + }, "caption": { "closeHover": "#C42B1C", "closeHoverText": "#FFFCF6" @@ -90,7 +101,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 +121,11 @@ export const tokens = { "selection": { "highlight": "#52451F" }, + "ambient": { + "canvasTop": "#1D1F2C", + "canvasBottom": "#2E2430", + "spark": "#E7D9B0" + }, "caption": { "closeHover": "#C42B1C", "closeHoverText": "#FFFCF6" @@ -140,6 +162,15 @@ 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", + "ambient.canvasTop": "Canvas", + "ambient.canvasBottom": "Canvas", + "ambient.spark": "Canvas", "status.success": "CanvasText", "status.warning": "CanvasText", "status.danger": "CanvasText", @@ -567,6 +598,201 @@ 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": "status.success", + "bg": "surface.app", + "min": 4.5 + }, + { + "fg": "ambient.canvasTop", + "bg": "surface.sunken", + "max": 1.3 + }, + { + "fg": "ambient.canvasBottom", + "bg": "surface.sunken", + "max": 1.3 } ], "ink": { diff --git a/brand/tokens.json b/brand/tokens.json index 74a604511..f861a5523 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,11 @@ "selection": { "highlight": "#F4DE93" }, + "ambient": { + "canvasTop": "#FFF4DF", + "canvasBottom": "#EAE3D8", + "spark": "#F2CF8A" + }, "caption": { "closeHover": "#C42B1C", "closeHoverText": "#FFFCF6" @@ -88,7 +99,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 +119,11 @@ "selection": { "highlight": "#52451F" }, + "ambient": { + "canvasTop": "#1D1F2C", + "canvasBottom": "#2E2430", + "spark": "#E7D9B0" + }, "caption": { "closeHover": "#C42B1C", "closeHoverText": "#FFFCF6" @@ -138,6 +160,15 @@ "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", + "ambient.canvasTop": "Canvas", + "ambient.canvasBottom": "Canvas", + "ambient.spark": "Canvas", "status.success": "CanvasText", "status.warning": "CanvasText", "status.danger": "CanvasText", @@ -236,7 +267,46 @@ { "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": "status.success", "bg": "surface.app", "min": 4.5 }, + { "fg": "ambient.canvasTop", "bg": "surface.sunken", "max": 1.3 }, + { "fg": "ambient.canvasBottom", "bg": "surface.sunken", "max": 1.3 } ], "ink": { "pens": [ diff --git a/checks/rules/brand-tokens.ts b/checks/rules/brand-tokens.ts index 6aa9d9d22..5b2e028bf 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 e6ac08085..dcb60a118 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/CHECKS.md b/docs/CHECKS.md index 2e72dbc23..e3e3988ff 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. From 305b8ccda72ab564579286cdc671dae53b4e4096 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Fri, 2 Oct 2026 21:05:24 -0700 Subject: [PATCH 02/69] Add small line drawings for the desk-by-the-window look The shell has no pictures, so it reads as flat. These inline SVG components are the set the brand direction calls for: an arched window (a sunset by day, stars and a crescent moon at night), a potted plant with a trailing vine, a lit candle, a stack of books, an open notebook with the logo's moss stroke, the signature ink stroke, a field of twelve stars, a desk scene built from the parts, three empty-state compositions, and a small sun and moon mark for the theme previews. Each drawing is hidden from screen readers and cannot take focus, uses only token colors through CSS classes, draws 1.5 px lines that stay that wide at any size, and stays under 3 KB of markup. Glows are flat radial gradients, never blur. Each fades in once over the fast duration, and not at all with reduced motion. Under a Windows contrast theme every drawing becomes plain outlines in the text color and the stars are hidden. Component tests cover all of that in both themes. Co-Authored-By: Claude Fable 5.1 --- app/src/ui/illustrations/Art.tsx | 34 ++++ app/src/ui/illustrations/Books.tsx | 26 +++ app/src/ui/illustrations/Candle.tsx | 36 ++++ app/src/ui/illustrations/DeskScene.tsx | 35 ++++ app/src/ui/illustrations/EmptyArt.tsx | 72 ++++++++ app/src/ui/illustrations/Glint.tsx | 25 +++ app/src/ui/illustrations/InkStroke.tsx | 21 +++ app/src/ui/illustrations/Notebook.tsx | 25 +++ app/src/ui/illustrations/Plant.tsx | 33 ++++ app/src/ui/illustrations/StarField.tsx | 36 ++++ app/src/ui/illustrations/Window.tsx | 46 +++++ .../ui/illustrations/illustrations.module.css | 163 ++++++++++++++++++ .../ui/illustrations/illustrations.test.tsx | 163 ++++++++++++++++++ app/src/ui/illustrations/index.ts | 17 ++ app/src/ui/index.ts | 13 ++ 15 files changed, 745 insertions(+) create mode 100644 app/src/ui/illustrations/Art.tsx create mode 100644 app/src/ui/illustrations/Books.tsx create mode 100644 app/src/ui/illustrations/Candle.tsx create mode 100644 app/src/ui/illustrations/DeskScene.tsx create mode 100644 app/src/ui/illustrations/EmptyArt.tsx create mode 100644 app/src/ui/illustrations/Glint.tsx create mode 100644 app/src/ui/illustrations/InkStroke.tsx create mode 100644 app/src/ui/illustrations/Notebook.tsx create mode 100644 app/src/ui/illustrations/Plant.tsx create mode 100644 app/src/ui/illustrations/StarField.tsx create mode 100644 app/src/ui/illustrations/Window.tsx create mode 100644 app/src/ui/illustrations/illustrations.module.css create mode 100644 app/src/ui/illustrations/illustrations.test.tsx create mode 100644 app/src/ui/illustrations/index.ts diff --git a/app/src/ui/illustrations/Art.tsx b/app/src/ui/illustrations/Art.tsx new file mode 100644 index 000000000..38f38d61f --- /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 000000000..895c942fa --- /dev/null +++ b/app/src/ui/illustrations/Books.tsx @@ -0,0 +1,26 @@ +// A stack of three books, 64 x 40, each tinted differently, with a clay bookmark ribbon. They lie flat, spines +// alternating, 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 000000000..10cbf9363 --- /dev/null +++ b/app/src/ui/illustrations/Candle.tsx @@ -0,0 +1,36 @@ +// A lit candle in a dish, 28 x 58. The flame is the one place the candle color shows; the glow around it is a flat +// radial gradient in the candle tint. 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 000000000..5580eb449 --- /dev/null +++ b/app/src/ui/illustrations/DeskScene.tsx @@ -0,0 +1,35 @@ +// The desk by the window, 240 x 150, for the welcome step. It has the window with its sunset or its stars, a plant +// on the sill, a stack of books, an open notebook with the moss stroke, and a lit candle. 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 { 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 000000000..e46dba396 --- /dev/null +++ b/app/src/ui/illustrations/EmptyArt.tsx @@ -0,0 +1,72 @@ +// The drawing above the sentence of an empty state: 120 px wide, about 50 px tall, and different for each place. +// "No notebooks yet" gets a bookshelf with a leaning book and a sprig. "No page open" gets an open notebook with a +// candle beside it. "Trash is empty" gets a candle beside a tidy stack of 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 ( + <> + + + + + + + + + + + ); +} + +export function EmptyArt({ kind, className }: ArtProps & { kind: EmptyKind }) { + // Each drawing is cropped to its content, so there is no empty paper above it. + const shelf = kind === 'notebooks'; + 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 000000000..36c056e5c --- /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 000000000..b16d4ceda --- /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 000000000..f1b64ff2d --- /dev/null +++ b/app/src/ui/illustrations/Notebook.tsx @@ -0,0 +1,25 @@ +// An open notebook, 64 x 40. On the right-hand page is the logo's ink: a moss stroke ending in a clay dot. + +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 000000000..6bb6ffbac --- /dev/null +++ b/app/src/ui/illustrations/Plant.tsx @@ -0,0 +1,33 @@ +// A potted plant with a trailing vine, 48 x 66: leaves in the moss tint, a dusk-tinted pot, and a vine that +// spills over the rim. It is small enough to sit in a corner, and it is drawn to be shown at about half size. + +import { Art } from './Art'; +import type { ArtProps } from './Art'; +import styles from './illustrations.module.css'; + +export function PlantArt() { + return ( + <> + + + + + + + + + + + + + + ); +} + +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 000000000..43c83948c --- /dev/null +++ b/app/src/ui/illustrations/StarField.tsx @@ -0,0 +1,36 @@ +// Twelve stars on the evening sky, pinned to the top 160 px of the page canvas and hidden behind the page. The dots +// are 0.75 to 1.25 px in radius at 35 to 60 percent opacity. They don't twinkle. + +import styles from './illustrations.module.css'; +import type { ArtProps } from './Art'; + +const STARS = [ + [291, 52, 1.1, 0.5], + [313, 120, 0.9, 0.4], + [352, 17, 1.25, 0.6], + [418, 31, 0.9, 0.45], + [507, 12, 1.1, 0.5], + [611, 25, 1.25, 0.6], + [703, 9, 0.8, 0.4], + [790, 28, 1.1, 0.55], + [882, 14, 1.25, 0.6], + [947, 33, 0.9, 0.45], + [976, 88, 1.1, 0.5], + [990, 142, 0.75, 0.35], +] 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 000000000..eec52d844 --- /dev/null +++ b/app/src/ui/illustrations/Window.tsx @@ -0,0 +1,46 @@ +// An arched window, 100 x 102: a sunset sky by day, and stars with a crescent moon in the evening. The sill and the +// panes are lines. The sky, the dusk hills, the sun, and the moon are flat fills from the tokens, and the stars +// are dots. + +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 = 'M22 40h.1M37 21h.1M63 17h.1M84 37h.1M17 67h.1M66 66h.1'; + +function Heavens({ sky }: { sky: Sky }) { + if (sky === 'day') return ; + return ( + <> + + + + ); +} + +export function WindowArt({ sky }: { sky: Sky }) { + return ( + <> + + + + + + + ); +} + +/** The window at its drawn size, or at a given height with the same proportions. */ +export function Window({ sky, className, height = 102 }: ArtProps & { sky: Sky; height?: number }) { + return ( + + + + ); +} diff --git a/app/src/ui/illustrations/illustrations.module.css b/app/src/ui/illustrations/illustrations.module.css new file mode 100644 index 000000000..d401c22e5 --- /dev/null +++ b/app/src/ui/illustrations/illustrations.module.css @@ -0,0 +1,163 @@ +/* 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 subtle accents and the ambient + spark, at most three to a motif. 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; + } + + /* Flat fills from the tokens. Stroke only where the drawing needs an outline. */ + .fillCandle { + fill: var(--color-accent-candle-subtle); + } + + .fillDusk { + fill: var(--color-accent-dusk-subtle); + } + + .fillNight { + fill: var(--color-accent-night-subtle); + } + + .fillMoss { + fill: var(--color-accent-primary-subtle); + } + + .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 glow around a flame is a flat radial gradient inside the drawing, never a blur. */ + .glow stop { + stop-color: var(--color-accent-candle-subtle); + } + + /* 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; + } + + /* Twelve stars on the evening sky. They are pinned to the top of the scrolling canvas and sit behind the page. */ + .stars { + position: sticky; + inset-block-start: 0; + z-index: calc(var(--layer-base) - 1); + display: none; + inline-size: 100%; + block-size: 160px; + 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) { + 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 000000000..19ff70964 --- /dev/null +++ b/app/src/ui/illustrations/illustrations.test.tsx @@ -0,0 +1,163 @@ +import { cdp } from 'vitest/browser'; +import { afterEach, describe, expect, it } from 'vitest'; +import { renderUi } from '../../test'; +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; +} + +const drawn = (container: HTMLElement) => container.querySelector('svg') as SVGSVGElement; + +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 3 KB of markup`, () => { + const { container } = renderUi(element); + expect(drawn(container).outerHTML.length).toBeLessThan(3072); + }); + } + + it('is never wider than the desk scene or taller than it', () => { + const { container } = renderUi(); + const box = drawn(container).getBoundingClientRect(); + expect(box.width).toBeLessThanOrEqual(240); + expect(box.height).toBeLessThanOrEqual(150); + }); + + 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 day = renderUi(); + const [sky, sun] = [day.container.querySelector('path'), day.container.querySelector('circle')] as Element[]; + expect(getComputedStyle(sky).fill).toBe(colorOf('--color-accent-candle-subtle')); + expect(getComputedStyle(sun).fill).toBe(colorOf('--color-ambient-spark')); + expect(getComputedStyle(sun).stroke).toBe(colorOf('--color-accent-candle')); + day.unmount(); + const night = renderUi(); + const [pane, stars, moon] = [...night.container.querySelectorAll('path')]; + expect(getComputedStyle(pane).fill).toBe(colorOf('--color-accent-night-subtle')); + 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')); + }); + + 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('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); + } + }); + + 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 000000000..9f79e0cea --- /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 4caf7b55f..f04b74fff 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'; From c99300959eb34b905ae7f96b8b8fb2656c7f2dc7 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Fri, 2 Oct 2026 21:22:47 -0700 Subject: [PATCH 03/69] Paint window light and an evening sky behind the page The page canvas was a flat sunken rectangle, which is the natural place for the time of day to show. By day a warm wash falls from the top corner, as if from a window. In the evening the canvas becomes a sky that deepens from night through the canvas color to a dusk horizon, with twelve faint stars pinned to its top, behind the page card. The washes stay within 1.3:1 of the sunken canvas and only show around the card, never behind text: the card is opaque and the star field sits under it. The sky follows the theme on the root element, so it changes with the crossfade and never lags a frame behind. Always paper white lights the whole page area, so the stars are left out there, and a Windows contrast theme gets a flat canvas. Co-Authored-By: Claude Fable 5.1 --- app/src/shell/layout/Workspace.module.css | 26 ++++++++++++++++++++++- app/src/shell/layout/Workspace.tsx | 2 ++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/shell/layout/Workspace.module.css b/app/src/shell/layout/Workspace.module.css index c740f7b27..4ebc7b262 100644 --- a/app/src/shell/layout/Workspace.module.css +++ b/app/src/shell/layout/Workspace.module.css @@ -82,11 +82,26 @@ padding: var(--space-5); } + /* The desk the page lies on. By day, window light falls from the top corner; in the evening the sky deepens + toward dusk, and the stars sit behind the page. Both stay within 1.3:1 of the sunken canvas, and the washes + only ever show 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: + radial-gradient(ellipse 70% 55% at top right, var(--color-ambient-canvas-top), transparent 70%), + var(--color-surface-sunken); + } + + :global(:root[data-theme='dark']) .page { + background: linear-gradient( + to bottom, + var(--color-ambient-canvas-top), + var(--color-surface-sunken) 55%, + var(--color-ambient-canvas-bottom) + ); } .page:focus-visible { @@ -211,3 +226,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 ef9a4943e..2c4ea7740 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}
} From f6e3d8e76a3532ed47fffc9ba1edcbece326c91a Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 3 Oct 2026 01:39:50 -0700 Subject: [PATCH 04/69] Give the setup steps the desk, an ink stroke and even chips The first-run setup was three plain cards on a flat backdrop, and the welcome step left about 360 px of empty card between its text and the button. The welcome step now shows the desk by the window between its sentence and its note, with its text centered to match the title. Each step title signs off with the ink stroke, and the backdrop carries the window light by day or the evening sky at night, always around the card and never behind text. The notebook color chips in step 3 share the width evenly, so they are the same size. Co-Authored-By: Claude Fable 5.1 --- app/src/features/setup/SetupView.module.css | 44 +++++++++++++++++++- app/src/features/setup/SetupView.test.tsx | 14 +++++++ app/src/features/setup/StepHeader.tsx | 2 + app/src/features/setup/steps/ColorChips.tsx | 22 +++++----- app/src/features/setup/steps/WelcomeStep.tsx | 8 +++- 5 files changed, 77 insertions(+), 13 deletions(-) diff --git a/app/src/features/setup/SetupView.module.css b/app/src/features/setup/SetupView.module.css index aa63dde47..aba4fd7b3 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: a warm wash from the top corner by day, and a sky that darkens toward dusk in the evening. It + paints the backdrop, never the card, and stays within a hair of the surface (BRAND.md section 4). */ + .screen { + background: + radial-gradient(ellipse 70% 55% at top right, var(--color-ambient-canvas-top), transparent 70%), + var(--color-surface-app); + } + + :global(:root[data-theme='dark']) .screen { + background: linear-gradient( + to bottom, + var(--color-ambient-canvas-top), + var(--color-surface-sunken) 55%, + var(--color-ambient-canvas-bottom) + ); + } + .card { display: grid; grid-template-rows: minmax(0, 1fr) auto; @@ -57,6 +75,18 @@ color: var(--color-text-secondary); } + /* The welcome step: everything centered under the title, with the desk between the sentence and the note. */ + .welcome { + min-block-size: 100%; + align-content: center; + text-align: center; + } + + .scene { + display: grid; + justify-items: center; + } + .note { margin: 0; color: var(--color-text-secondary); @@ -115,6 +145,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 +171,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 eb226717c..132d15cbf 100644 --- a/app/src/features/setup/SetupView.test.tsx +++ b/app/src/features/setup/SetupView.test.tsx @@ -48,6 +48,20 @@ 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('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 fc95bd820..fb406b667 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

{title}

+ {subtitle &&

{subtitle}

} ); diff --git a/app/src/features/setup/steps/ColorChips.tsx b/app/src/features/setup/steps/ColorChips.tsx index 8ee181da8..c44ce7c92 100644 --- a/app/src/features/setup/steps/ColorChips.tsx +++ b/app/src/features/setup/steps/ColorChips.tsx @@ -16,15 +16,17 @@ interface ColorChipsProps { export function ColorChips({ label, colors, value, onChange }: ColorChipsProps) { return ( - - {colors.map((color) => ( - } - /> - ))} - +
+ + {colors.map((color) => ( + } + /> + ))} + +
); } diff --git a/app/src/features/setup/steps/WelcomeStep.tsx b/app/src/features/setup/steps/WelcomeStep.tsx index 06458fba5..5b575bd24 100644 --- a/app/src/features/setup/steps/WelcomeStep.tsx +++ b/app/src/features/setup/steps/WelcomeStep.tsx @@ -1,14 +1,20 @@ // Step 1 (ARCHITECTURE.md section 17.1): one sentence about OpenNote, and the way to get started. import type { SetupStepProps } from '../../../registries'; +import { useResolvedTheme } from '../../../theme/theme'; import { t } from '../../../strings/t'; +import { DeskScene } from '../../../ui'; import styles from '../SetupView.module.css'; import { StepHeader } from '../StepHeader'; export default function WelcomeStep(props: SetupStepProps) { + const theme = useResolvedTheme(); return ( -
+
+
+ +

{t('setup.welcome.next')}

); From b065864496dc4b205d3b2c59be4ca0b30b323b54 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 3 Oct 2026 01:39:52 -0700 Subject: [PATCH 05/69] Warm the theme toggle and show the sky in the theme cards The title bar toggle was a gray sun or moon. The sun is now candle colored and the moon night colored, both well above the 3:1 an icon needs on the app surface and on its hover and pressed fills. The three theme cards share a row down to the compact size instead of wrapping two and one at 820 px, and each preview carries a small mark in a bottom corner: a low sun for Light, a moon and stars for Dark, and one of each for Match Windows. The marks sit below the preview lines, are hidden under a Windows contrast theme, and stay out of the screen reader and Tab order like the rest of the preview. Co-Authored-By: Claude Fable 5.1 --- app/src/features/theme/ThemeCards.module.css | 30 +++++++++++++++++-- app/src/features/theme/ThemeCards.test.tsx | 17 +++++++++++ app/src/features/theme/ThemeCards.tsx | 15 +++++++--- app/src/features/theme/ThemeToggle.module.css | 9 ++++++ app/src/features/theme/ThemeToggle.tsx | 6 +++- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/app/src/features/theme/ThemeCards.module.css b/app/src/features/theme/ThemeCards.module.css index f7aa1393d..1fede7ac0 100644 --- a/app/src/features/theme/ThemeCards.module.css +++ b/app/src/features/theme/ThemeCards.module.css @@ -9,8 +9,11 @@ gap: var(--space-7); } + /* The three cards share a row until the window is compact, shrinking from their full width together. */ .cards [role='radio'] { - inline-size: var(--size-theme-card); + flex: 1 1 0; + min-inline-size: 0; + max-inline-size: var(--size-theme-card); min-block-size: var(--size-theme-card); align-content: start; } @@ -21,7 +24,8 @@ } :global(:root[data-size-class='compact']) .cards [role='radio'] { - inline-size: auto; + flex: none; + max-inline-size: none; min-block-size: 0; } @@ -33,6 +37,7 @@ } .mini { + position: relative; display: grid; grid-template-rows: var(--space-3) 1fr; block-size: calc(var(--size-theme-card) / 2); @@ -42,6 +47,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 +146,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 5fc0234d9..b933ef046 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 fb07e3c83..88f5c4717 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 3dd04aafb..4c676653d 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 c9088548d..5b1584fa9 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 ?
); } diff --git a/app/src/features/tree/Tree.module.css b/app/src/features/tree/Tree.module.css index df12517d3..b1a11c7c1 100644 --- a/app/src/features/tree/Tree.module.css +++ b/app/src/features/tree/Tree.module.css @@ -246,13 +246,37 @@ color: var(--color-text-secondary); } + .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) { + .footer .plant { + display: block; + } + } + + :global(:root[data-size-class='compact']) .footer .plant { + display: none; + } + .saveStatus { display: inline-flex; align-items: center; @@ -262,6 +286,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 ca7bee0b2..192362d53 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 120 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 48 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 48 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 () => { From cbb890b7b6cbddb7fe6785f0e18d7dd7659ae131 Mon Sep 17 00:00:00 2001 From: Eric Dean Date: Sat, 3 Oct 2026 01:39:55 -0700 Subject: [PATCH 07/69] Show the notebook color as a dot in the palette and breadcrumb The tree gives every notebook a color chip, but the palette, the quick switcher and the breadcrumb were all one color. A page or notebook result now has a small 8 px dot in its notebook's color before the title, and the breadcrumb's first crumb has the same dot. The dots reuse the pen colors the tree already uses, are hidden from screen readers (the name is unchanged, and the path still says which notebook), and keep their own color under a Windows contrast theme. To carry the color, a palette result can now name an ink. Co-Authored-By: Claude Fable 5.1 --- app/src/features/palette/CommandPalette.module.css | 10 ++++++++++ app/src/features/palette/CommandPalette.test.tsx | 11 +++++++++++ app/src/features/palette/ResultList.tsx | 7 ++++++- app/src/features/palette/nodes.ts | 9 +++++++-- app/src/registries/types.ts | 2 ++ app/src/shell/titlebar/Breadcrumb.tsx | 10 +++++++--- app/src/shell/titlebar/TitleBar.module.css | 12 ++++++++++++ app/src/shell/titlebar/TitleBar.test.tsx | 12 ++++++++++++ 8 files changed, 67 insertions(+), 6 deletions(-) diff --git a/app/src/features/palette/CommandPalette.module.css b/app/src/features/palette/CommandPalette.module.css index 456d1b200..bbcc3139a 100644 --- a/app/src/features/palette/CommandPalette.module.css +++ b/app/src/features/palette/CommandPalette.module.css @@ -104,6 +104,16 @@ 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-3); + border-radius: var(--radius-pill); + forced-color-adjust: none; + } + .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 80248b78b..fde5adfa8 100644 --- a/app/src/features/palette/CommandPalette.test.tsx +++ b/app/src/features/palette/CommandPalette.test.tsx @@ -103,6 +103,17 @@ 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'); + const option = await screen.findByRole('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(); + }); + 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 fac34dc5e..bb19d1302 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 && (