diff --git a/program/plugins/mermaid.client.tsx b/program/plugins/mermaid.client.tsx index 2d5076e..cfef758 100644 --- a/program/plugins/mermaid.client.tsx +++ b/program/plugins/mermaid.client.tsx @@ -15,6 +15,8 @@ import { uiStyles } from './ui/appearance.js' import type { Mermaid, MermaidConfig, RenderResult } from 'mermaid' import type { Plugin } from 'cordis' import type { MarkdownCodeBlockProps } from './markdown-api.js' +import { DEFAULT_THEME, normalizeAccent } from './theme-api.js' +import { useThemeAppearance, type ClientThemeAppearance } from './theme-runtime.js' import styles from './mermaid.css' export interface MermaidDiagramConfig { @@ -38,7 +40,27 @@ function clamp(value: number | undefined, fallback: number, min: number, max: nu return Math.max(min, Math.min(max, value ?? fallback)) } -export function mermaidConfiguration(config: MermaidDiagramConfig): MermaidConfig { +function mixColor(accent: string, surface: string, amount: number): string { + return '#' + [1, 3, 5].map((offset) => ( + Math.round(Number.parseInt(accent.slice(offset, offset + 2), 16) * amount + + Number.parseInt(surface.slice(offset, offset + 2), 16) * (1 - amount)) + .toString(16).padStart(2, '0') + )).join('') +} + +export function mermaidConfiguration( + config: MermaidDiagramConfig, + appearance: ClientThemeAppearance = { mode: 'light', accent: DEFAULT_THEME.accent }, +): MermaidConfig { + const dark = appearance.mode === 'dark' + const accent = normalizeAccent(appearance.accent) + const surface = dark ? '#262931' : '#f7f7fa' + const text = dark ? '#c8ccd8' : '#42475d' + // Mermaid derives other colors from these values, so supply concrete hex + // colors rather than CSS variables. Neutral ink keeps extreme accents legible. + const primary = mixColor(accent, surface, dark ? 0.18 : 0.08) + const primaryBorder = mixColor(accent, text, 0.35) + const border = dark ? '#565c6d' : '#c7cad5' return { startOnLoad: false, securityLevel: 'strict', @@ -48,44 +70,45 @@ export function mermaidConfiguration(config: MermaidDiagramConfig): MermaidConfi theme: 'base', look: 'classic', themeVariables: { - background: '#f7f7fa', + darkMode: dark, + background: surface, fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, sans-serif', fontSize: '14px', - primaryColor: '#f0edff', - primaryBorderColor: '#a993f1', - primaryTextColor: '#42475d', - secondaryColor: '#edf2ff', - secondaryBorderColor: '#aebce5', - secondaryTextColor: '#42475d', - tertiaryColor: '#f2f3f7', - tertiaryBorderColor: '#c7cad5', - tertiaryTextColor: '#42475d', - lineColor: '#858ba0', - textColor: '#42475d', - mainBkg: '#f0edff', - nodeBorder: '#a993f1', - clusterBkg: '#f5f4fa', - clusterBorder: '#d8d5e2', - edgeLabelBackground: '#f7f7fa', - noteBkgColor: '#fff8d9', - noteBorderColor: '#d8c980', - noteTextColor: '#4d4b43', - actorBkg: '#f0edff', - actorBorder: '#a993f1', - actorTextColor: '#42475d', - signalColor: '#73798f', - signalTextColor: '#42475d', - labelBoxBkgColor: '#f7f7fa', - labelBoxBorderColor: '#c7cad5', - labelTextColor: '#42475d', + primaryColor: primary, + primaryBorderColor: primaryBorder, + primaryTextColor: text, + secondaryColor: dark ? '#2c3447' : '#edf2ff', + secondaryBorderColor: dark ? '#5c6a8b' : '#aebce5', + secondaryTextColor: text, + tertiaryColor: dark ? '#2b2e37' : '#f2f3f7', + tertiaryBorderColor: border, + tertiaryTextColor: text, + lineColor: dark ? '#989fb2' : '#858ba0', + textColor: text, + mainBkg: primary, + nodeBorder: primaryBorder, + clusterBkg: dark ? '#23262e' : '#f5f4fa', + clusterBorder: dark ? border : '#d8d5e2', + edgeLabelBackground: surface, + noteBkgColor: dark ? '#403a24' : '#fff8d9', + noteBorderColor: dark ? '#9e8948' : '#d8c980', + noteTextColor: dark ? '#e9ddb6' : '#4d4b43', + actorBkg: primary, + actorBorder: primaryBorder, + actorTextColor: text, + signalColor: dark ? '#989fb2' : '#73798f', + signalTextColor: text, + labelBoxBkgColor: surface, + labelBoxBorderColor: border, + labelTextColor: text, }, } } -async function mermaidRuntime(config: MermaidDiagramConfig): Promise { +async function mermaidRuntime(config: MermaidDiagramConfig, appearance: ClientThemeAppearance): Promise { runtimePromise ??= import('mermaid').then((module) => module.default) const runtime = await runtimePromise - const siteConfig = mermaidConfiguration(config) + const siteConfig = mermaidConfiguration(config, appearance) const signature = JSON.stringify(siteConfig) if (signature !== activeConfiguration) { runtime.initialize(siteConfig) @@ -228,6 +251,7 @@ export function MermaidDiagram({ config, overlays, }: MarkdownCodeBlockProps & { config: MermaidDiagramConfig; overlays?: ClientOverlays }): ReactNode { + const { mode, accent } = useThemeAppearance() const renderId = `alto-mermaid-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}` const stageRef = useRef(null) const [expandedHeight, setExpandedHeight] = useState() @@ -255,7 +279,7 @@ export function MermaidDiagram({ const timer = window.setTimeout(() => { void (async () => { try { - const runtime = await mermaidRuntime(config) + const runtime = await mermaidRuntime(config, { mode, accent }) const valid = await runtime.parse(source, { suppressErrors: true }) if (!valid) throw new Error('Invalid Mermaid diagram') // Mermaid uses this id while building the SVG. A fresh id avoids @@ -278,7 +302,7 @@ export function MermaidDiagram({ active = false window.clearTimeout(timer) } - }, [code, config, maxSourceCharacters, renderId]) + }, [code, config, maxSourceCharacters, renderId, mode, accent]) useEffect(() => { if (!render.svg) closeExpanded() }, [render.svg, closeExpanded]) diff --git a/program/plugins/mermaid.css b/program/plugins/mermaid.css index ad4e1a8..4008901 100644 --- a/program/plugins/mermaid.css +++ b/program/plugins/mermaid.css @@ -5,7 +5,7 @@ color: var(--text); border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); border-radius: 12px; - background: color-mix(in srgb, var(--panel-solid) 88%, white); + background: var(--ui-surface); } .cordis-mermaid figcaption { @@ -154,10 +154,7 @@ min-width: 0; overflow: auto; overscroll-behavior: contain; - /* Match the renderer’s light diagram palette in either application theme. */ - color-scheme: light; - --ui-focus: CanvasText; - background: Canvas; + background: var(--ui-surface); } .cordis-mermaid-expanded-viewport:focus-visible { diff --git a/program/plugins/theme.css b/program/plugins/theme.css index 83533f0..14480b0 100644 --- a/program/plugins/theme.css +++ b/program/plugins/theme.css @@ -93,7 +93,7 @@ html[data-alto-theme='dark'] .ghostty-terminal-surface { html[data-alto-theme='dark'] .shell-kernel main:not(.program-modal-content) { background: - radial-gradient(circle at 51% -18%, rgba(76, 66, 112, 0.16), transparent 40%), + radial-gradient(circle at 51% -18%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 40%), linear-gradient(135deg, #1f2229 0%, #1c1f26 54%, #191c22 100%); } diff --git a/program/plugins/ui/appearance-tokens.css b/program/plugins/ui/appearance-tokens.css index 37ce3ac..fbf076a 100644 --- a/program/plugins/ui/appearance-tokens.css +++ b/program/plugins/ui/appearance-tokens.css @@ -1,6 +1,7 @@ /* App-wide roles inherit the selected palette, including body-mounted portals. The window's decorative accent and native material remain independent. */ :root { + accent-color: var(--ui-action); --ui-font: var(--composer-font); --sans: var(--ui-font); --display: var(--sans); diff --git a/program/plugins/ui/controls.css b/program/plugins/ui/controls.css index 0e783ac..cade10e 100644 --- a/program/plugins/ui/controls.css +++ b/program/plugins/ui/controls.css @@ -48,16 +48,14 @@ :is(.alto-button, .button).ghost:active:not(:disabled) { background: var(--ui-pressed); } -:is(.alto-button, .button).primary, -.composer .send-button:not(.stop):not(:disabled) { +:is(.alto-button, .button).primary { color: var(--ui-on-action); border-color: transparent; background: var(--ui-action); box-shadow: none; } -:is(.alto-button, .button).primary:hover:not(:disabled), -.composer .send-button:not(.stop):not(:disabled):hover { +:is(.alto-button, .button).primary:hover:not(:disabled) { color: var(--ui-on-action); border-color: transparent; background: var(--ui-action-hover); @@ -65,8 +63,7 @@ filter: none; } -:is(.alto-button, .button).primary:active:not(:disabled), -.composer .send-button:not(.stop):not(:disabled):active { background: var(--ui-action-pressed); } +:is(.alto-button, .button).primary:active:not(:disabled) { background: var(--ui-action-pressed); } :is(.alto-button, .button).danger { color: var(--ui-danger); } :is(.alto-button, .button).danger:hover:not(:disabled) { background: color-mix(in srgb, var(--ui-danger) 9%, transparent); } @@ -111,6 +108,22 @@ --composer-action-hover-fill: var(--ui-hover); } +/* A quiet accent wash keeps Send distinct without overpowering the editor. */ +.composer .send-button:not(.stop):not(:disabled) { + color: var(--ui-action); + border-color: transparent; + background: color-mix(in srgb, var(--ui-action) 12%, transparent); + box-shadow: none; + transition: none; +} +.composer .send-button:not(.stop):not(:disabled):hover { + background: color-mix(in srgb, var(--ui-action) 18%, transparent); + filter: none; +} +.composer .send-button:not(.stop):not(:disabled):active { + background: color-mix(in srgb, var(--ui-action) 24%, transparent); +} + .composer :is(.composer-attach, button.composer-workspace-trigger, .composer-permission-trigger, .composer-model-trigger) { --icon-scale: 1; --icon-opacity: 1; diff --git a/program/plugins/ui/default.css b/program/plugins/ui/default.css index d228dff..4cadc18 100644 --- a/program/plugins/ui/default.css +++ b/program/plugins/ui/default.css @@ -285,7 +285,7 @@ html[data-desktop-platform="darwin"] [role="tab"] { } .history-entry:hover:not(:disabled) { color: var(--text-strong); background: rgba(79, 85, 105, 0.06); } -.history-entry.active { color: var(--text-strong); background: rgba(111, 102, 154, 0.1); } +.history-entry.active { color: var(--text-strong); background: var(--ui-selected); } .history-entry:disabled { opacity: 1; cursor: default; } .history-entry > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .history-entry-meta { display: inline-flex; align-items: center; gap: 5px; } @@ -2035,10 +2035,12 @@ details pre { .composer { --composer-outline: light-dark(#e2e4ec, #363a46); - --composer-focus-outline: light-dark(#d9caf8, #63527d); - --composer-action-ink: light-dark(var(--accent), #b59af9); - --composer-action-fill: light-dark(#ede5ff, #3a3053); - --composer-action-hover-fill: light-dark(#e6dafb, #473665); + --composer-focus-outline: light-dark( + color-mix(in srgb, var(--ui-action, var(--accent)) 24%, var(--composer-surface)), + color-mix(in srgb, var(--ui-action, var(--accent)) 40%, var(--composer-surface))); + --composer-action-ink: var(--ui-action, var(--accent)); + --composer-action-fill: color-mix(in srgb, var(--ui-action, var(--accent)) 12%, var(--composer-surface)); + --composer-action-hover-fill: color-mix(in srgb, var(--ui-action, var(--accent)) 18%, var(--composer-surface)); --composer-shadow-tint: light-dark(#33354916, #00000042); --composer-elevation: 0 3px 6px var(--composer-shadow-tint), 0 10px 24px var(--composer-shadow-tint); --composer-resting-shadow: 0 0 0 1px var(--composer-outline), var(--composer-elevation); @@ -2718,8 +2720,6 @@ button.composer-workspace-trigger:focus-visible { color: var(--text); background } .send-button:hover:not(:disabled) { background: var(--composer-action-hover-fill); } -.composer .send-button:not(.stop):not(:disabled) { color: var(--accent-text); background: var(--accent); } -.composer .send-button:not(.stop):not(:disabled):hover { background: color-mix(in srgb, var(--accent) 86%, var(--text-strong)); } .send-button:disabled { opacity: 0.32; cursor: not-allowed; } .send-button > :is(svg, .stop-glyph) { transform: translate(-3px, -3px); } .stop-glyph { diff --git a/program/plugins/workspace-tabs-appearance.css b/program/plugins/workspace-tabs-appearance.css index 0702cb0..274d603 100644 --- a/program/plugins/workspace-tabs-appearance.css +++ b/program/plugins/workspace-tabs-appearance.css @@ -69,6 +69,7 @@ html:not([data-desktop-platform='darwin']) .vertical-tab-rail:not(.is-collapsed) } .vertical-tab-rail:not(.is-collapsed) .workspace-tabs { --workspace-tab-group-line-inset: var(--ui-row-inline-padding); + --workspace-tab-group-member-indent: calc(var(--ui-row-inline-padding) + var(--ui-row-gap)); padding-inline: var(--ui-panel-inset); } @@ -112,8 +113,13 @@ html:not([data-desktop-platform='darwin']) .vertical-tab-rail:not(.is-collapsed) :is(.workspace-tabbar, .vertical-tab-rail:not(.is-collapsed)) .workspace-tab > :is(.workspace-tab-thread-status, .workspace-tab-group-count) { inset-inline-end: calc(var(--workspace-tab-action-size) + var(--workspace-tab-action-inset)); } -.vertical-tab-rail:not(.is-collapsed) .workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab-group-member:not(.is-group-collapsed) { - padding-inline-start: calc(var(--ui-row-inline-padding) + var(--space-1) / 2 + var(--ui-row-gap)); +/* Keep the continuous group line in a gutter outside the rounded tab surface. + Shared hover, selection, and focus states then all clear the line. */ +.vertical-tab-rail:not(.is-collapsed) .workspace-tabs-with-groups[aria-orientation='vertical'] .workspace-tab.workspace-tab-group-member { + --workspace-tab-group-line-inset: calc(-1 * var(--ui-row-gap)); + width: calc(100% - var(--workspace-tab-group-member-indent)); + margin-inline-start: var(--workspace-tab-group-member-indent); + padding-inline-start: var(--ui-row-inline-padding); } .workspace-tabbar .workspace-tab-add { diff --git a/scripts/design-system-audit-baseline.json b/scripts/design-system-audit-baseline.json index a25fa43..116b438 100644 --- a/scripts/design-system-audit-baseline.json +++ b/scripts/design-system-audit-baseline.json @@ -36,7 +36,7 @@ "program/plugins/thread-status.css": {"rawGeometry": 6}, "program/plugins/turn-progress.css": {"rawFontSize": 3, "rawFontWeight": 1, "rawRadius": 1, "rawSpacing": 10, "rawGeometry": 10, "rawShadow": 2}, "program/plugins/ui/composer.tsx": {"inlineStyle": 1}, - "program/plugins/ui/default.css": {"rawFontSize": 60, "rawFontWeight": 30, "rawRadius": 47, "rawSpacing": 270, "rawGeometry": 137, "rawColor": 68, "rawShadow": 16, "negativeTracking": 8}, + "program/plugins/ui/default.css": {"rawFontSize": 60, "rawFontWeight": 30, "rawRadius": 47, "rawSpacing": 270, "rawGeometry": 137, "rawColor": 65, "rawShadow": 16, "negativeTracking": 8}, "program/plugins/ui/markdown.tsx": {"inlineStyle": 2}, "program/plugins/work-targets.client.tsx": {"inlineStyle": 1}, "program/plugins/work-targets.css": {"rawSpacing": 26, "rawGeometry": 9}, diff --git a/tests/fixtures/interface-appearance.cjs b/tests/fixtures/interface-appearance.cjs index a23d0cc..fa0cde2 100644 --- a/tests/fixtures/interface-appearance.cjs +++ b/tests/fixtures/interface-appearance.cjs @@ -11,17 +11,23 @@ async function check() { stdin: { resolveDir: process.cwd(), sourcefile: 'interface-appearance-check.tsx', loader: 'tsx', contents: ` import React,{useState,useSyncExternalStore} from 'react'; import {createRoot} from 'react-dom/client'; - import {flushSync} from 'react-dom'; + import {createPortal,flushSync} from 'react-dom'; + import {themeStyle} from './src/client/shell'; import appearancePlugin from './program/plugins/interface-appearance.client'; import workspaceAppearancePlugin from './program/plugins/workspace-tabs-appearance.client'; import surfacesPlugin from './program/plugins/ui-surfaces.client'; - import themePlugin from './program/plugins/theme.client'; + import themePlugin,{ThemeController} from './program/plugins/theme.client'; + import {DEFAULT_THEME,THEME_STORAGE_KEY} from './program/plugins/theme-api'; + import {useThemeAppearance} from './program/plugins/theme-runtime'; import searchPlugin from './program/plugins/search.client'; import {WorkspaceTabActionMenu} from './program/plugins/workspace-tab-menu.client'; import {NewWorkspaceDialog} from './program/plugins/ui/project-settings'; import {Composer} from './program/plugins/ui/composer'; import {uiStyles} from './program/plugins/ui/appearance'; + // Production shells have an inline default accent; Settings must beat it. + for(const [name,value] of Object.entries(themeStyle({accent:DEFAULT_THEME.accent})))document.getElementById('root').style.setProperty(name,value); + const owner=()=>{let cleanups=[];return { effect(fn){const cleanup=fn();if(typeof cleanup==='function')cleanups.push(cleanup);return cleanup}, dispose(){const pending=cleanups;cleanups=[];for(const cleanup of pending.reverse())cleanup()} @@ -72,9 +78,12 @@ async function check() { disabled={false} readOnly={false} sending={false} activeTurn={false} onImagesChange={()=>{}} onAttachmentsChange={()=>{}} onAttachFile={async()=>{}} onModelChange={()=>{}} onEffortChange={()=>{}} onPermissionModeChange={()=>{}} onWorkspaceChange={()=>{}} onSubmit={()=>{}} onInterrupt={()=>{}}/>} function Creator(){const active=useSyncExternalStore(overlays.subscribe,overlays.snapshot);return active==='creator'?overlays.close('creator')} onCreate={async()=>{}}/>:null} + function AccentProbe(){const theme=useThemeAppearance();return createPortal(,document.body)} function App(){useSyncExternalStore(ui.subscribe,ui.snapshot);const Settings=surfaces.get('default-settings'),Search=roots.get('default-search');return <>
-
} + } const root=createRoot(document.getElementById('root')); flushSync(()=>root.render()); window.frame=()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); @@ -92,6 +101,23 @@ async function check() { window.searchState=()=>({query:document.querySelector('.search-palette input')?.value,selected:[...document.querySelectorAll('.search-result')].findIndex(element=>element.getAttribute('aria-selected')==='true'),overlay,selectedThreads}); window.remember=(selector,name)=>{const element=document.querySelector(selector);element.focus();element.setSelectionRange?.(3,8);window[name]={element,value:element.value,text:element.textContent,start:element.selectionStart,end:element.selectionEnd};return true}; window.preserved=(selector,name)=>{const saved=window[name],element=document.querySelector(selector);return saved.element===element&&element.isConnected&&element.value===saved.value&&element.textContent===saved.text&&element.selectionStart===saved.start&&element.selectionEnd===saved.end}; + window.rememberAccentCaret=()=>flushSync(()=>{const editor=document.querySelector('[data-cordis-composer-editor]');editor.focus();const text=document.createTreeWalker(editor,NodeFilter.SHOW_TEXT).nextNode(),range=document.createRange();range.setStart(text,3);range.setEnd(text,8);getSelection().removeAllRanges();getSelection().addRange(range);window.accentCaret={text,start:3,end:8}}); + window.accentValues=()=>{ + const canvas=document.createElement('canvas'),context=canvas.getContext('2d');canvas.width=canvas.height=1; + const rgba=color=>{context.clearRect(0,0,1,1);context.fillStyle=color;context.fillRect(0,0,1,1);const value=[...context.getImageData(0,0,1,1).data];value[3]/=255;return value}; + const composer=document.querySelector('.composer'),probe=document.createElement('span');probe.hidden=true;composer.append(probe); + const token=(element,name)=>{element.append(probe);probe.style.color='var('+name+')';return rgba(getComputedStyle(probe).color)}; + const result={inline:document.getElementById('root').style.getPropertyValue('--accent'),published:document.documentElement.style.getPropertyValue('--alto-theme-accent'), + shell:token(document.getElementById('root'),'--accent'),body:token(document.getElementById('accent-body-portal'),'--accent'), + material:token(document.querySelector('.workspace-layout-root'),'--workspace-material-tint'),chrome:token(document.querySelector('.workspace-layout-root'),'--workspace-chrome'), + focus:token(composer,'--composer-focus-outline'),runtime:document.getElementById('accent-body-portal').dataset.theme, + native:[...document.querySelectorAll('#accent-body-portal input,select[aria-label="Chrome palette"],input[aria-label="Custom accent color"]')].map(element=>getComputedStyle(element).accentColor==='auto'?'auto':rgba(getComputedStyle(element).accentColor)), + selection:rgba(getComputedStyle(document.querySelector('#accent-body-portal a'),'::selection').backgroundColor), + stored:JSON.parse(localStorage.getItem(THEME_STORAGE_KEY)),restored:new ThemeController().snapshot(), + caret:getSelection().anchorNode===accentCaret.text&&getSelection().focusNode===accentCaret.text&&getSelection().anchorOffset===accentCaret.start&&getSelection().focusOffset===accentCaret.end, + focusApplied:getComputedStyle(composer).boxShadow.includes(getComputedStyle(probe).color)}; + probe.remove();return result; + }; window.samples=selectors=>{ const canvas=document.createElement('canvas'),context=canvas.getContext('2d');canvas.width=canvas.height=1; const rgba=color=>{context.clearRect(0,0,1,1);context.fillStyle=color;context.fillRect(0,0,1,1);const value=[...context.getImageData(0,0,1,1).data];value[3]/=255;return value}; @@ -222,6 +248,54 @@ async function check() { await win.webContents.debugger.sendCommand('CSS.enable') await win.webContents.debugger.sendCommand('Emulation.setFocusEmulationEnabled', { enabled: true }) await run(`remember('[data-cordis-composer-editor]','originalEditor')`) + + // Exercise the actual Settings event/controller/storage path before the + // broader style-role tests override tokens or write theme attributes. + await run(`openSettings();nav('Appearance');input('.program-modal-search input','appearance');remember('.program-modal-search input','accentSettings');rememberAccentCaret()`) + await settleGeometry() + const accentSelectors = ['.composer', '[data-cordis-composer-editor]', '.send-button'] + const accentGeometry = measured => Object.fromEntries(accentSelectors.map(selector => [selector, Object.fromEntries(['width', 'radius', 'font', 'size'].map(property => [property, measured[selector][property]]))])) + const originalAccentGeometry = accentGeometry(await sample(accentSelectors)) + for (const mode of ['light', 'dark']) { + await run(`click('.theme-mode-picker button:nth-child(${mode === 'light' ? 2 : 3})')`) + for (const [label, accent, channel, chrome] of [['Green', '#16a34a', 1, 'alto'], ['Orange', '#ea580c', 0, 'ink']]) { + await run(`click('button[aria-label="${label} accent"]');(()=>{const select=document.querySelector('select[aria-label="Chrome palette"]');select.value=${JSON.stringify(chrome)};select.dispatchEvent(new Event('change',{bubbles:true}))})()`) + await settleGeometry() + const values = await run('accentValues()') + const expected = accent.slice(1).match(/../g).map(value => parseInt(value, 16)).concat(1) + const context = mode + ' actual ' + label + ' Settings click: ' + assert.equal(values.inline, '#7c3aed', 'The real shell retains its inline default during user overrides') + assert.equal(values.published, accent, context + 'publishes the requested accent') + assert.equal(values.runtime, mode + '|' + accent, context + 'updates subscribed renderer appearance') + assert.deepEqual(values.shell, expected, context + 'beats the inline shell default') + assert.deepEqual(values.body, expected, context + 'reaches the body-mounted portal') + assert.equal(await run(`document.querySelector('#accent-body-portal').parentElement===document.body`), true) + const measured = await sample([...accentSelectors, '#accent-body-portal a', '#accent-body-portal button']) + assert.deepEqual(measured['#accent-body-portal a'].color, expected, context + 'recolors Markdown links') + for (const [name, color] of Object.entries({ material: values.material, chrome: values.chrome, focus: values.focus, selection: values.selection, send: measured['.send-button'].color })) { + assert.ok(color[channel] > Math.max(...color.slice(0, 3).filter((_value, index) => index !== channel)), context + name + ' follows the selected hue: ' + JSON.stringify(color)) + } + assert.ok(values.focusApplied, context + 'the real focused Composer paints its themed outline') + assert.ok(measured['.send-button'].fill[3] > 0 && measured['.send-button'].fill[3] < .5, context + 'Send retains its soft translucent treatment') + assert.ok(measured['.send-button'].contrast >= 3, context + 'Send glyph remains legible: ' + JSON.stringify(measured['.send-button'])) + for (const color of values.native) assert.deepEqual(color, measured['#accent-body-portal button'].fill, context + 'native controls inherit the action accent') + assert.ok(values.native.length >= 3, 'Native checkbox and actual Settings palette controls are sampled') + assert.ok(values.selection[3] > 0 && values.selection[3] < 1, context + 'text selection keeps a translucent accent') + for (const saved of [values.stored, values.restored]) assert.deepEqual([saved.mode, saved.accent, saved.chrome], [mode, accent, chrome], context + 'persists and restores the preference') + assert.ok(values.caret && await run(`preserved('.program-modal-search input','accentSettings')&&preserved('[data-cordis-composer-editor]','originalEditor')`), context + 'preserves Settings query/caret and Composer draft/editor/caret') + assert.deepEqual(accentGeometry(measured), originalAccentGeometry, context + 'changes no editor or Send shape/type/width') + } + await artifact('settings-accent-' + mode) + await run(`key('.program-modal-search input','Escape');openSettings();nav('Appearance')`) + await settle() + assert.equal(await run(`document.querySelector('button[aria-label="Orange accent"]').getAttribute('aria-checked')`), 'true', mode + ' reopening Settings retains the selected accent') + assert.equal(await run(`document.querySelector('input[aria-label="Custom accent color"]').value`), '#ea580c') + await run(`input('.program-modal-search input','appearance');remember('.program-modal-search input','accentSettings');rememberAccentCaret()`) + await settleGeometry() + } + await run(`click('.theme-settings-card .settings-row:last-child');key('.program-modal-search input','Escape')`) + console.log('Real Settings accent propagation, persistence, and Composer retention checks passed') + await setSentinels(true) for (const [property, token] of [['color', '--ui-text'], ['fill', '--ui-control-fill'], ['radius', '--ui-control-radius'], ['font', '--ui-font'], ['size', '--ui-size'], ['weight', '--ui-weight']]) await assertRole('#menu-trigger', property, token) for (const [states, token] of [[['hover'], '--ui-hover'], [['hover', 'active'], '--ui-pressed']]) { diff --git a/tests/fixtures/mermaid-expand.cjs b/tests/fixtures/mermaid-expand.cjs index 48cfcaa..b6432ae 100644 --- a/tests/fixtures/mermaid-expand.cjs +++ b/tests/fixtures/mermaid-expand.cjs @@ -15,19 +15,28 @@ async function check(){ const root=createRoot(document.getElementById('root')); window.render=code=>flushSync(()=>root.render(
)); window.frame=()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); + window.diagramColors=()=>{const svg=document.querySelector('.cordis-mermaid-svg>svg,.cordis-mermaid-expanded-svg>svg'),style=getComputedStyle(svg.querySelector('.node rect'));return{id:svg.id,fill:style.fill,stroke:style.stroke,viewBox:svg.getAttribute('viewBox')}}; window.open=()=>{const b=document.querySelector('[aria-label="Expand diagram"]');b.focus();b.click()}; window.close=()=>document.querySelector('[aria-label="Close expanded diagram"]').click(); window.teardown=()=>flushSync(()=>root.unmount());render(${JSON.stringify(source)}); `},bundle:true,write:false,platform:'browser',format:'iife',jsx:'automatic',loader:{'.css':'text'},define:{'process.env.NODE_ENV':'"production"'}}) const css=(await Promise.all(['src/client/styles.css', 'program/plugins/ui/appearance-tokens.css', 'program/plugins/ui/controls.css', 'program/plugins/ui/surfaces.css','program/plugins/theme.css','program/plugins/ui/default.css'].map(f=>fs.readFile(f,'utf8')))).join('\n').replace(/@import[^;]+;/g,'') const win=new BrowserWindow({show:false,width:1100,height:760,webPreferences:{offscreen:true,sandbox:true,contextIsolation:true,backgroundThrottling:false}}) - const run=code=>win.webContents.executeJavaScript(code) + const run=code=>win.webContents.executeJavaScript(code).catch(error=>{throw new Error('Renderer check failed: '+code,{cause:error})}) const frames=()=>run('frame()') const until=async expression=>{for(let i=0;i<140;i++){if(await run(expression))return;await frames()}throw new Error('Timed out: '+expression)} + const capture=async name=>{if(process.env.ALTO_MERMAID_ARTIFACT_DIR){await frames();await fs.mkdir(process.env.ALTO_MERMAID_ARTIFACT_DIR,{recursive:true});await fs.writeFile(path.join(process.env.ALTO_MERMAID_ARTIFACT_DIR,name+'.png'),(await win.webContents.capturePage()).toPNG())}} try{ - await win.loadURL('data:text/html,'+encodeURIComponent('
')) + await win.loadURL('data:text/html,'+encodeURIComponent('
')) await run(bundle.outputFiles[0].text) await until('!!document.querySelector(".cordis-mermaid-svg svg") && !document.querySelector("[aria-label=\\"Expand diagram\\"]").disabled') + const original=await run('diagramColors()') + await run("document.documentElement.style.setProperty('--alto-theme-accent','#16a34a')") + await until(`!!document.querySelector('.cordis-mermaid.is-ready') && diagramColors().id!==${JSON.stringify(original.id)} && diagramColors().fill!==${JSON.stringify(original.fill)}`) + const green=await run('diagramColors()') + assert.notEqual(green.stroke,original.stroke,'An existing diagram updates its node border with the Settings accent') + assert.equal(green.viewBox,original.viewBox,'Changing the accent preserves diagram geometry') + await capture('mermaid-accent-green-inline') const height=await run('document.querySelector("figure").getBoundingClientRect().height') await run('open()');await frames() assert.ok(await run('!!document.querySelector("[role=dialog]")')) @@ -35,6 +44,13 @@ async function check(){ assert.equal(await run('document.activeElement.getAttribute("aria-label")'),'Close expanded diagram') assert.ok(await run('overlays.nativeViewsOccluded()'),'The viewer participates in native surface coordination') assert.equal(await run('document.querySelector("figure").getBoundingClientRect().height'),height,'Expanding preserves conversation geometry') + await run("window.originalDialog=document.querySelector('[role=dialog]');window.originalOverlay=overlays.snapshot();document.documentElement.style.setProperty('--alto-theme-accent','#2563eb')") + await until(`!!document.querySelector('.cordis-mermaid.is-ready') && diagramColors().id!==${JSON.stringify(green.id)} && diagramColors().fill!==${JSON.stringify(green.fill)}`) + const blue=await run('diagramColors()') + assert.notEqual(blue.stroke,green.stroke,'An expanded diagram updates its node border with the Settings accent') + assert.equal(blue.viewBox,green.viewBox,'Expanded accent updates preserve diagram geometry') + assert.equal(await run('originalDialog===document.querySelector("[role=dialog]") && originalOverlay===overlays.snapshot()'),true,'Accent updates preserve the open viewer') + assert.equal(await run('document.activeElement.getAttribute("aria-label")'),'Close expanded diagram','Accent updates preserve keyboard focus') for(const width of [1100,360]){ win.setContentSize(width,760);await frames() const metrics=await run(`(()=>{const d=document.querySelector('[role=dialog]'),r=d.getBoundingClientRect(),c=d.querySelector('[aria-label="Close expanded diagram"]').getBoundingClientRect(),v=d.querySelector('.cordis-mermaid-expanded-viewport'),s=d.querySelector('svg').getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,width:innerWidth,height:innerHeight,area:r.width*r.height/(innerWidth*innerHeight),closeRight:c.right,scroll:document.documentElement.scrollWidth,svgWidth:s.width,availableWidth:v.clientWidth,svgHeight:s.height,availableHeight:v.clientHeight}})()`) @@ -47,7 +63,7 @@ async function check(){ assert.notEqual(await run('document.querySelector("output").textContent'),fit) assert.ok(await run('(()=>{const e=document.querySelector(".cordis-mermaid-expanded-viewport");return e.scrollWidth>e.clientWidth})()'),'Zoomed diagram can be panned') await run('document.querySelector("[aria-label=\\"Fit diagram to window\\"]").click()');await frames() - if(process.env.ALTO_MERMAID_ARTIFACT_DIR){await fs.mkdir(process.env.ALTO_MERMAID_ARTIFACT_DIR,{recursive:true});await fs.writeFile(path.join(process.env.ALTO_MERMAID_ARTIFACT_DIR,'mermaid-expanded-'+width+'.png'),(await win.webContents.capturePage()).toPNG())} + await capture('mermaid-expanded-'+width) } await run('document.querySelector(".cordis-mermaid-expanded-viewport").focus();document.activeElement.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",bubbles:true,cancelable:true}))') assert.equal(await run('document.activeElement===document.querySelector("[role=dialog] button:not(:disabled)")'),true,'Keyboard navigation stays inside the viewer') @@ -61,12 +77,18 @@ async function check(){ await run('open()');await frames();await run('overlays.open("another-dialog")');await frames() assert.equal(await run('!!document.querySelector("[role=dialog]")'),false) assert.equal(await run('overlays.snapshot()'),'another-dialog') - await run('overlays.closeAll();render("flowchart TD\\n A --> B")') - await until('!!document.querySelector(".cordis-mermaid.is-ready .cordis-mermaid-svg svg")') + await run('overlays.closeAll();render("flowchart TD\\n A --> B\\n style B fill:#ffe4b5,stroke:#a65e00,color:#422a00")') + await until('!!document.querySelector(".cordis-mermaid.is-ready .cordis-mermaid-svg svg") && document.querySelectorAll(".cordis-mermaid-svg .node").length===2') await run('open()');await frames() assert.ok(await run('parseInt(document.querySelector("output").textContent,10)>100'),'Expansion enlarges vector diagrams to use the available space') - await run("document.documentElement.dataset.altoTheme='dark'");await frames() - assert.equal(await run('getComputedStyle(document.querySelector(".cordis-mermaid-expanded-viewport")).colorScheme'),'light','The diagram keeps its readable Mermaid palette in dark mode') + const light=await run('diagramColors()') + const authored=await run("(()=>{const s=getComputedStyle(document.querySelector('[id*=-flowchart-B-] rect'));return{s:s.stroke,f:s.fill}})()") + await run("document.documentElement.dataset.altoTheme='dark'") + await until(`!!document.querySelector('.cordis-mermaid.is-ready') && diagramColors().id!==${JSON.stringify(light.id)} && diagramColors().fill!==${JSON.stringify(light.fill)}`) + assert.equal(await run('diagramColors().viewBox'),light.viewBox,'Changing theme mode preserves diagram geometry') + assert.deepEqual(await run("(()=>{const s=getComputedStyle(document.querySelector('[id*=-flowchart-B-] rect'));return{s:s.stroke,f:s.fill}})()"),authored,'Theme updates preserve explicit authored node colors') + assert.equal(await run('getComputedStyle(document.querySelector(".cordis-mermaid-expanded-viewport")).colorScheme'),'dark','The expanded viewer inherits the active theme mode') + await capture('mermaid-expanded-dark-360') await run('teardown()');await frames() assert.equal(await run('!!document.querySelector("[role=dialog]")'),false) assert.equal(await run('overlays.snapshot()'),undefined) diff --git a/tests/fixtures/vertical-tabs.cjs b/tests/fixtures/vertical-tabs.cjs index efea4e0..d29c6a6 100644 --- a/tests/fixtures/vertical-tabs.cjs +++ b/tests/fixtures/vertical-tabs.cjs @@ -194,6 +194,25 @@ async function check() { assert.deepEqual(changed.close, original.close, label + ': a status update does not move the close action') await run('geometryStatus.remove()') } + const assertGroupRailGeometry = async (label, state) => { + const measured=await run(`(()=>{ + const heading=document.querySelector('[data-workspace-group] > span:first-child').getBoundingClientRect(),other=document.querySelector('[data-workspace-tab=c]'),close=other.querySelector('.workspace-tab-close').getBoundingClientRect(); + return {heading:heading.left,right:other.getBoundingClientRect().right,close:close.right,rows:[...document.querySelectorAll('.workspace-tab-group-member')].map(row=>{ + const r=row.getBoundingClientRect(),style=getComputedStyle(row),line=getComputedStyle(row,'::before'),left=r.left+parseFloat(style.borderLeftWidth)+parseFloat(line.left),action=row.querySelector('.workspace-tab-close')?.getBoundingClientRect(); + return {id:row.dataset.workspaceTab,left:r.left,right:r.right,close:action?.right,selected:row.classList.contains('is-active'),hover:row.matches(':hover'),focus:row.matches(':focus-visible'),fill:style.backgroundColor, + line:{left,right:left+parseFloat(line.width),top:r.top+parseFloat(style.borderTopWidth)+parseFloat(line.top),bottom:r.bottom-parseFloat(style.borderBottomWidth)-parseFloat(line.bottom)}}; + })}; + })()`) + for(const [index,row] of measured.rows.entries()){ + assert.ok(row.left-row.line.right>=4,label+': rounded member surfaces stay clear of the rail '+JSON.stringify(row)) + assert.equal(row.line.left,measured.heading,label+': rail stays aligned with the group heading') + assert.equal(row.right,measured.right,label+': member highlights retain the trailing column') + if(row.close!==undefined)assert.equal(row.close,measured.close,label+': member close actions retain the trailing column') + if(index)assert.ok(measured.rows[index-1].line.bottom>=row.line.top,label+': the rail remains continuous between members') + } + assert.equal(measured.rows.find(row=>row.id==='a').selected,true,label+': the selected member is painted') + if(state)assert.equal(measured.rows.find(row=>row.id==='b')[state],true,label+': the member has its '+state+' state') + } const tabListStart = () => run(`(() => { const strip=document.querySelector('.workspace-tabs'),rail=document.querySelector('.vertical-tab-rail'),style=getComputedStyle(strip),bounds=strip.getBoundingClientRect(); const first=strip.querySelector('.workspace-tab:not(.is-group-collapsed)').getBoundingClientRect(); @@ -613,9 +632,34 @@ async function check() { const groupColumn = await run(`(()=>{const label=document.querySelector('[data-workspace-group] > span:first-child').getBoundingClientRect(),tab=document.querySelector('[data-workspace-tab=a]'),r=tab.getBoundingClientRect(),line=getComputedStyle(tab,'::before'), left=r.left+parseFloat(getComputedStyle(tab).borderLeftWidth)+parseFloat(line.left);return {labelText:label.left,line:left,lineEnd:left+parseFloat(line.width),content:tab.firstElementChild.getBoundingClientRect().left}})()`) assert.equal(groupColumn.line, groupColumn.labelText, 'Group line aligns with the label text: ' + JSON.stringify(groupColumn)) - assert.equal(groupColumn.content - groupColumn.lineEnd, 8, 'Member content starts a step past the line: ' + JSON.stringify(groupColumn)) + assert.equal(groupColumn.content - groupColumn.lineEnd, 18, 'Member content clears the line and its inset highlight: ' + JSON.stringify(groupColumn)) await assertTabGeometry('Expanded group geometry') await capture('vertical-group-light') + window.webContents.debugger.attach('1.3') + const previousGroupTheme=await run("document.documentElement.getAttribute('data-alto-theme')") + await run("click('[data-workspace-tab=a]')") + for(const theme of ['light','dark'])for(const width of [232,168]){ + await run(`document.documentElement.dataset.altoTheme='${theme}';resize(${width}-document.querySelector('.vertical-tab-rail').getBoundingClientRect().width)`) + await finishMotion() + const target=await run("(()=>{const r=document.querySelector('[data-workspace-tab=b]').getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2}})()") + await window.webContents.debugger.sendCommand('Input.dispatchMouseEvent',{type:'mouseMoved',...target}) + await settle() + await assertGroupRailGeometry(theme+' '+width+'px selected and hovered','hover') + await capture(`vertical-group-member-${theme}-${width}-hover-selected`) + await window.webContents.debugger.sendCommand('Input.dispatchKeyEvent',{type:'keyDown',key:'Tab',code:'Tab',windowsVirtualKeyCode:9}) + await window.webContents.debugger.sendCommand('Input.dispatchKeyEvent',{type:'keyUp',key:'Tab',code:'Tab',windowsVirtualKeyCode:9}) + await run("document.querySelector('[data-workspace-tab=b]').focus({preventScroll:true})") + await assertGroupRailGeometry(theme+' '+width+'px keyboard focus','focus') + await capture(`vertical-group-member-${theme}-${width}-focus`) + await run('document.activeElement.blur()') + } + await run("document.querySelector('[data-workspace-tab=a]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))") + await finishMotion() + await assertGroupRailGeometry('Renaming an expanded member') + await run("key('Escape');click('[data-workspace-tab=c]');resize(232-document.querySelector('.vertical-tab-rail').getBoundingClientRect().width)") + await run(previousGroupTheme===null?"delete document.documentElement.dataset.altoTheme":`document.documentElement.dataset.altoTheme=${JSON.stringify(previousGroupTheme)}`) + await window.webContents.debugger.sendCommand('Input.dispatchMouseEvent',{type:'mouseMoved',x:1100,y:700}) + await finishMotion() await run("click('[data-workspace-group]')") await finishMotion() state = await run('inspect()') @@ -720,7 +764,6 @@ async function check() { await run("click('.vertical-tab-rail-toggle')") await finishMotion() - window.webContents.debugger.attach('1.3') const transparencyPreference = value => window.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-transparency', value }], }) diff --git a/tests/mermaid.test.tsx b/tests/mermaid.test.tsx index 9eb8377..5b29962 100644 --- a/tests/mermaid.test.tsx +++ b/tests/mermaid.test.tsx @@ -32,4 +32,37 @@ describe('Mermaid diagrams', () => { expect(html).toContain('Drawing diagram') expect(html).toContain('aria-busy="true"') }) + + it.each(['light', 'dark'] as const)('uses the selected accent for default %s nodes without recoloring semantic notes', (mode) => { + const palettes = ['#2563eb', '#16a34a', '#ffffff', '#000000'].map((accent) => ( + mermaidConfiguration({}, { mode, accent }).themeVariables! + )) + expect(new Set(palettes.map((palette) => palette.primaryColor)).size).toBe(palettes.length) + expect(new Set(palettes.map((palette) => palette.primaryBorderColor)).size).toBe(palettes.length) + for (const palette of palettes) { + expect(palette.mainBkg).toBe(palette.primaryColor) + expect(palette.actorBkg).toBe(palette.primaryColor) + expect(palette.nodeBorder).toBe(palette.primaryBorderColor) + expect(palette.actorBorder).toBe(palette.primaryBorderColor) + expect(palette.noteBkgColor).toBe(palettes[0].noteBkgColor) + expect(palette.noteBkgColor).not.toBe(palette.primaryColor) + } + }) + + it('keeps diagram labels and node boundaries readable for pale and extreme accents in either mode', () => { + const luminance = (hex: string): number => [1, 3, 5].reduce((sum, offset, index) => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255 + return sum + (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) * [0.2126, 0.7152, 0.0722][index]! + }, 0) + const contrast = (first: string, second: string): number => { + const a = luminance(first), b = luminance(second) + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + } + for (const mode of ['light', 'dark'] as const) for (const accent of ['#7c3aed', '#16a34a', '#ea580c', '#fff8d6', '#ffffff', '#000000']) { + const palette = mermaidConfiguration({}, { mode, accent }).themeVariables! + expect(contrast(palette.primaryTextColor, palette.primaryColor)).toBeGreaterThanOrEqual(4.5) + expect(contrast(palette.primaryBorderColor, palette.primaryColor)).toBeGreaterThanOrEqual(3) + expect(contrast(palette.noteTextColor, palette.noteBkgColor)).toBeGreaterThanOrEqual(4.5) + } + }) })