diff --git a/docs/design/zen-style-reference.md b/docs/design/zen-style-reference.md new file mode 100644 index 0000000..06bf53e --- /dev/null +++ b/docs/design/zen-style-reference.md @@ -0,0 +1,181 @@ +# Zen visual rules for Alto + +Research reference, 2026-10-28. Zen source is pinned to [7133db8][zen-revision]; Alto's starting point is checkpoint 3740b84 on jm/vertical-tabs. This document proposes subsequent design passes. It does not change the running interface. + +Zen extends Firefox, so its appearance comes from inherited controls, Zen overrides, platform rules, and preferences. The values below are representative source values, not a universal specification supplied by Zen. “Alto proposal” identifies our interpretation and proposed adaptation. + +## Design direction + +My interpretation of Zen's implementation is that a visibly colored window frame can coexist with restrained controls. Most controls share an ink color and acquire a light surface on interaction. Selection, primary actions, and floating panels receive progressively stronger treatment. Typography and alignment carry much of the hierarchy. + +For Alto, retain the approved accent glass, rounded composer, rounded tabs, and shared horizontal/vertical tab styling. Extend the control rules component by component. + +## Color and material + +**Zen implementation.** The theme derives related surface, border, and foreground colors from its primary accent. Dialog and address-field surfaces receive little color. [Theme colors][zen-colors] Toolbar hover and press use approximately 7% and 10% contrasting ink. These percentages belong to toolbar controls; other button contexts differ. [Toolbar states][zen-toolbar-states] + +**Alto proposal.** Separate decorative color from actionable color. A bright workspace hue can remain noticeable in the glass while an action uses a darker or lighter version of that hue for readable text. + +| Role | Alto rule | +| --- | --- | +| Frame | Keep the current accent wash over native material. Derive the opaque fallback from the same hue and surface family. | +| Content | Keep a stable reading surface related to the frame's palette. | +| Raised | Use for the composer and floating panels. A selected tab can use a translucent version appropriate to the frame. | +| Recessed | Use for user bubbles and embedded regions that need separation without another shadow. | +| Interaction | Define hover, press, selected, and open fills for each surface context. Reuse those roles across components. | +| Ink | Use primary, supporting, and disabled levels with consistent meanings. Enabled controls on glass need sufficient contrast against varying backdrops. | +| Action | Derive a readable action color from the accent. Do not reuse the raw decorative tint indiscriminately. | +| Status | Preserve distinct error, warning, success, and activity meanings independently of the workspace hue. | + +Paint the window tint once. Repeated opaque fills or blur layers inside tabs and toolbars would obscure the material we just established. Menu and dialog readability should come from their own surface, rather than increasingly darkening the entire window. + +## Fonts and hierarchy + +**Zen implementation.** macOS chrome uses SF Pro with system fallbacks; Windows uses Segoe UI. [Platform fonts][zen-fonts] The packaged Junicode serif is used for large branding titles. [Brand typography][zen-branding] Ordinary buttons use weight 500, with 600 on prominent accent actions. [Button typography][zen-buttons] + +**Alto proposal.** Use the existing native UI font throughout navigation, toolbars, forms, and dialogs. Preserve the conversation's reading sizes and code font. Reserve a display serif, if ever adopted, for a deliberate branding or welcome screen. + +The following is an Alto scale to evaluate, not a font scale extracted from Zen: + +| Role | Starting treatment | +| --- | --- | +| Metadata and keyboard hints | 11–12px, regular, supporting ink | +| Tabs, menu items, compact controls | 12–13px, regular; medium for selected items or action labels | +| Conversation body | Retain the current reading size and line height | +| Composer | Retain its current typography and shape | +| Section headings | 14–16px, medium or semibold, with spacing that establishes the section | + +Use 400, 500, and 600 as the main weight steps. Avoid making every navigation label semibold. Establish hierarchy through size, spacing, and selection before adding more weight or reducing opacity. + +## Buttons + +**Zen implementation.** Zen distinguishes faint secondary fills, transparent ghost actions, tonal dialog primaries, and prominent accent actions. Large accent buttons constrain accent lightness in OKLCH, a color space that exposes lightness separately from hue. Generic press feedback scales to 0.98; some toolbar icons use 0.95. These are different component treatments. [Button roles][zen-buttons], [toolbar overrides][zen-toolbar] + +**Alto proposal.** + +| Role | Appearance and use | +| --- | --- | +| Primary | Filled, readable action color; one visually dominant action within a local task, such as Send or an approval decision. | +| Secondary | Faint neutral fill, medium text, restrained hover and press. Useful for adjacent alternatives. | +| Ghost | Transparent at rest; quiet interaction fill. Use for routine toolbar, copy, close, and overflow actions. | +| Toggle or open trigger | Persistent selected fill while enabled or while its popover remains open. Its state must remain visible after the pointer leaves. | +| Destructive | Semantic danger ink or fill according to consequence. Keep the same geometry and type as neighboring controls. | + +Keep Alto's pill geometry for short buttons. A useful starting density is 32px compact targets and approximately 34–36px regular buttons, with shared horizontal padding and icon gaps. These dimensions are proposals for Alto, not Zen-wide defaults. + +Every button should have an explicit state contract: + +| State | Rule | +| --- | --- | +| Rest | Clear label or icon; hierarchy visible without hover | +| Hover | Immediate fill and readable ink, with stable geometry | +| Press | Stronger fill; optional restrained scale feedback | +| Keyboard focus | Visible outline independent of hover and selection | +| Selected or open | Persistent state fill | +| Disabled | Visibly unavailable; no hover or press feedback | +| Busy | Stable width and label alignment; reserved space for activity feedback | + +## Tabs, groups, and navigation rows + +**Zen implementation.** Sidebar tab backgrounds use a 14px radius in their context, with 2px block margins. [Vertical tabs][zen-tabs] Secondary row actions appear on hover. [Tab actions][zen-tab-actions] Selection uses a mostly neutral translucent plate and a small shadow. [Selected surface][zen-selection] + +**Alto proposal.** Keep the approved rounded tabs and shared appearance code for both orientations. Use regular weight for ordinary tabs and medium for the selected tab. Selection should be persistent and stronger than hover; group labels should establish membership without becoming another set of competing filled buttons. + +Reveal secondary actions on keyboard focus as well as hover. Reserve their space so titles and counts do not move when actions appear. Keep active, running, unread, and grouped states distinguishable: they communicate different things. + +## Icons + +**Zen implementation.** Toolbar glyphs are generally 16px, with a 17px macOS override. A common toolbar rule derives icon fill from 70% current ink. [Icon sizing][zen-icons], [toolbar overrides][zen-toolbar] + +**Alto proposal.** Retain one coherent glyph family. Normalize optical size, stroke weight, baseline, and icon-to-label spacing. Use inherited control ink so icons and labels respond together. Keep a comfortable target around a small glyph; increasing the glyph is not the only way to make the control easier to hit. + +Avoid applying opacity to an entire control when only the icon needs optical adjustment: that also weakens text, focus rings, and state fills. + +## Corners, spacing, and alignment + +**Zen implementation.** Corners vary by role: common toolbar overrides use 8px, menu rows 5px, sidebar tab backgrounds 14px, and dialogs a larger radius. Native outer corners have platform overrides. Inner corners are derived from outer geometry and separation. [Toolbar][zen-toolbar], [menus][zen-menus], [native radius][zen-native-radius], [nested radius][zen-nested-radius] The default content separation is 8px. [Spacing preference][zen-spacing] + +**Alto proposal.** Preserve the rounded shape the user selected, including the composer and tabs. Adopt the relationship between containers and inset controls: inner rounding should fit the available bounds and inset. Small controls naturally become pills; larger surfaces retain visible rounded corners. + +Use a small shared spacing scale, initially 2, 4, 8, 12, 16, and 24px, for internal gaps and gutters. This is an Alto proposal; Zen does not use a strict version of that scale everywhere. Reuse existing content gutters and title alignment. Align icon centers, text baselines, and trailing actions within each row family. + +## Fields, search, and toggles + +**Zen implementation.** The address field uses a quiet enclosing surface, stable internal padding, and contextual actions. [Omnibox][zen-fields] Toggle fills reuse button tokens, with an accent-derived on state. [Toggle patch][zen-toggles] + +**Alto proposal.** Let the field container own its background, rounding, and focus treatment. Inner text inputs should not add another rounded border. Apply the same rule to search, rename fields, model pickers, and settings controls. + +Keep the composer's existing shape and editing behavior. Use the accent action role for checked or enabled controls; use the shared neutral control treatment when off. Labels and help text should follow the same typography rules as buttons and menus. + +## Menus, popovers, tooltips, and dialogs + +**Zen implementation.** Menu geometry includes 8px vertical and 14px horizontal row padding, 2px by 4px row margins, and a reserved icon column. macOS panel corners are 10px, or 12px under its Tahoe condition. [Menu geometry][zen-menus] Dialogs add a fine edge and a soft shadow, with larger corners. [Dialog surface][zen-dialogs] + +**Alto proposal.** Build one floating-panel family, with menu rows that share button hover, focus, disabled, and selected treatments. Align labels, icons, checkmarks, submenus, and keyboard hints into stable columns. Keep that alignment for items without icons. + +Use supporting ink for shortcuts and descriptions. Separate groups with spacing and a subtle divider. Tooltips should use the same surface and caption typography at a smaller density. Dialogs can have more elevation and padding than anchored menus while retaining the same palette and action hierarchy. + +## Elevation and edges + +**Alto proposal from Zen's selected-tab and dialog treatments.** Use elevation where it explains a relationship: a selected tab, an anchored popup, a modal, or the main pane above the glass. Ordinary buttons and every message do not need their own shadows. + +Keep the existing light pane shadow. Use one edge treatment per boundary, avoiding stacked outlines, borders, and shadows that recreate the stray pixel line. In dark mode a restrained highlight edge can clarify a floating surface without a heavy black shadow. + +## Motion and responsiveness + +**Zen implementation.** Common controls use roughly 100–200ms transitions; contextual address-field actions fade over 150ms. Dialog entry uses 300ms with a 10px translation. Reduced-motion handling exists in several components but is not uniform across every rule. [Toolbar motion][zen-toolbar], [field disclosure][zen-fields], [dialog timing][zen-dialogs], [dialog keyframes][zen-motion] + +**Alto proposal.** Preserve immediate tab hover painting, which addressed the sluggish pointer feedback. Add movement only where it clarifies an action: a restrained press, panel appearance, or group expansion. Avoid animating every fill while the pointer crosses a long list. + +Use short opacity or transform transitions for optional polish, keep layout stable, and apply reduced-motion behavior consistently. Disclosure must work for keyboard users too. + +## Applying the rules to Alto-specific content + +Zen's browser chrome does not prescribe an Alto conversation design. The following are adaptations: + +- Conversation cards, approval requests, and tool results should reuse the content, raised, recessed, border, and status roles. +- Code blocks, diffs, terminals, and tables should retain appropriate text density and semantic coloring. Their controls can share the common button language. +- Badges should use quiet surfaces unless they convey actionable status. A workspace accent should not overwrite error or approval meaning. +- Empty states and onboarding can use more expressive typography. Ordinary conversation and navigation should remain consistent. +- Loading indicators should occupy stable space and use the same supporting ink or status roles as nearby content. + +Preserve visible keyboard focus even where individual Zen overrides suppress it. Test legibility over both light and dark backdrops, along with the opaque material fallback. + +## Suggested adoption order + +1. **Buttons, typography, and icons.** Normalize roles and states across pane toolbars, tab tools, composer actions, approval actions, and settings. Keep both tab orientations on shared rules. +2. **Fields and floating panels.** Unify search, pickers, menus, tooltips, and dialogs around the same surfaces, gutters, and state contract. +3. **Spacing and component geometry.** Align row families, action slots, nested corners, and panel insets while preserving the approved rounded composer and tabs. +4. **Optional motion and content details.** Add restrained feedback, then harmonize badges, tool cards, tables, and empty states. + +Each pass should be reviewed across light and dark themes, several accent hues, horizontal and vertical tabs, and split panes. Interaction changes should preserve keyboard operation, immediate hover response, and active composer state. + +Steps 1–3 now use shared appearance and geometry roles. Compact rows are 32px, +two-line choices are 52px, icon/action slots are 24px, and panel/dialog insets are +12px/24px. Both tab orientations reserve trailing actions without shifting their +titles. Settings, search, and pickers fit the available pane and scroll in short +windows. The approved rounded tabs, composer, and accent glass remain intact. +Step 4 has a small initial pass: shared neutral card and attachment-badge +surfaces, rounded table wrappers, and readable empty-state copy. Regular buttons +have a 100ms press response when reduced motion is off. Further motion and +content changes remain optional. + +[zen-revision]: https://github.com/zen-browser/desktop/tree/7133db8aee16c3cdf042307e9e11071359d92851 +[zen-colors]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-theme.css#L22-L121 +[zen-toolbar-states]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-theme.css#L167-L188 +[zen-fonts]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-theme.css#L291-L306 +[zen-branding]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-branding.css#L7-L29 +[zen-buttons]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-buttons.css#L13-L153 +[zen-toolbar]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-single-components.css#L185-L215 +[zen-tabs]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/tabs/zen-tabs/vertical-tabs.css#L259-L394 +[zen-tab-actions]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/tabs/zen-tabs/vertical-tabs.css#L712-L733 +[zen-selection]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/spaces/zen-workspaces.css#L394-L399 +[zen-icons]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-theme.css#L159-L175 +[zen-native-radius]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/zenThemeModifier.js#L112-L144 +[zen-nested-radius]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-theme.css#L282-L288 +[zen-spacing]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/prefs/zen/theme.yaml#L5-L9 +[zen-fields]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-omnibox.css#L19-L172 +[zen-toggles]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/toolkit/content/widgets/moz-toggle/moz-toggle-tokens-css.patch#L6-L14 +[zen-menus]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-popup.css#L10-L78 +[zen-dialogs]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-panels/dialog.css#L6-L32 +[zen-motion]: https://github.com/zen-browser/desktop/blob/7133db8aee16c3cdf042307e9e11071359d92851/src/zen/common/styles/zen-animations.css#L92-L135 diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index 62f803f..4d88126 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -136,6 +136,55 @@ export default client Both registrations are effects owned by the browser fiber. Cordis removes the renderer, listeners returned from the plugin, and its `
')) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const win = new BrowserWindow({ show: false, width: 1100, height: 800, webPreferences: { offscreen: true, sandbox: true, contextIsolation: true, backgroundThrottling: false } }) + const run = code => win.webContents.executeJavaScript(code) + const settle = () => run('frame()') + const sample = selectors => run(`samples(${JSON.stringify(selectors)})`) + let documentNode + const force = async (selector, states) => { + if (!documentNode) documentNode = (await win.webContents.debugger.sendCommand('DOM.getDocument')).root.nodeId + const { nodeId } = await win.webContents.debugger.sendCommand('DOM.querySelector', { nodeId: documentNode, selector }) + assert.ok(nodeId, 'Missing control: ' + selector) + await win.webContents.debugger.sendCommand('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: states }) + } + const sentinel = { + '--ui-surface': '#eef2f6', '--ui-surface-inset': '#dde7ec', '--ui-border': '#203f59', + '--ui-text': '#203040', '--ui-text-strong': '#102030', '--ui-text-secondary': '#405060', + '--ui-control-fill': '#e1e8f1', + '--ui-hover': '#d1dbe8', '--ui-pressed': '#c4d2e4', '--ui-selected': '#bdcde2', + '--ui-field-fill': '#e6ebf2', '--ui-field-border': '#4f6580', '--ui-focus': '#8a276c', + '--ui-action': '#364e78', '--ui-on-action': '#ffffff', '--ui-action-hover': '#2b4167', '--ui-action-pressed': '#1e304d', + '--ui-panel-radius': '17px', '--ui-dialog-radius': '23px', '--ui-row-radius': '7px', '--ui-field-radius': '11px', + '--ui-control-radius': '19px', '--ui-font': 'monospace', '--ui-size': '14px', '--ui-weight': '600', + '--ui-panel-shadow': 'rgb(10, 20, 30) 0px 4px 9px 0px', '--ui-dialog-shadow': 'rgb(30, 20, 10) 0px 7px 11px 0px', + } + const setSentinels = enabled => run(`for(const [token,value] of Object.entries(${JSON.stringify(sentinel)})){${enabled ? 'document.documentElement.style.setProperty(token,value)' : 'document.documentElement.style.removeProperty(token)'}}`) + const geometrySentinel = { + '--ui-panel-inset': '16px', '--ui-dialog-inset': '28px', '--ui-row-min-height': '38px', + '--ui-row-block-padding': '9px', '--ui-row-inline-padding': '20px', '--ui-row-gap': '11px', + '--ui-choice-row-min-height': '60px', '--ui-field-min-height': '40px', '--ui-field-inline-padding': '18px', + '--ui-control-gap': '10px', '--ui-control-inline-padding': '22px', '--ui-icon-slot': '26px', + } + const setGeometrySentinels = enabled => run(`for(const [token,value] of Object.entries(${JSON.stringify(geometrySentinel)})){${enabled ? 'document.documentElement.style.setProperty(token,value)' : 'document.documentElement.style.removeProperty(token)'}}`) + const assertGeometry = async (selector, expected) => { + const actual = await run(`geometry(${JSON.stringify(selector)})`) + for (const [property, value] of Object.entries(expected)) assert.deepEqual(actual[property], value, `${selector} ${property} follows shared geometry`) + } + const settleGeometry = async () => { + await settle() + await run('Promise.all(document.getAnimations().filter(animation=>Number.isFinite(animation.effect?.getComputedTiming().iterations)).map(animation=>animation.finished.catch(()=>{})))') + await settle() + } + const expectedColor = token => sentinel[token].slice(1).match(/../g).map(value => parseInt(value, 16)).concat(1) + const assertRole = async (selector, property, token) => { + const measured = (await sample([selector]))[selector][property] + assert.deepEqual(measured, token.startsWith('--ui-') && sentinel[token].startsWith('#') ? expectedColor(token) : sentinel[token], `${selector} ${property} consumes ${token}`) + } + const assertPortal = selector => run(`!document.querySelector(${JSON.stringify(selector)}).closest('.workspace-layout-root')`) + const assertFits = async selector => { + const bounds = await run(`bounds(${JSON.stringify(selector)})`) + assert.ok(bounds.left >= 0 && bounds.right <= bounds.width && bounds.scroll <= bounds.width, selector + ': ' + JSON.stringify(bounds)) + } + const assertDialogFits = async selector => { + const bounds = await run(`bounds(${JSON.stringify(selector)})`) + assert.ok(bounds.left >= bounds.workspaceLeft - 1 && bounds.right <= bounds.workspaceRight + 1 && bounds.top >= -1 && bounds.bottom <= bounds.height + 1 && bounds.scroll <= bounds.width + 1, + selector + ' stays inside the available workspace and viewport: ' + JSON.stringify(bounds)) + } + const assertFieldsFit = async (panelSelector, fieldSelector) => { + const fields = await run(`(()=>{const panel=document.querySelector(${JSON.stringify(panelSelector)}).getBoundingClientRect();return[...document.querySelectorAll(${JSON.stringify(fieldSelector)})].map(field=>({field:field.getBoundingClientRect().toJSON(),panel:panel.toJSON()}))})()`) + assert.ok(fields.length, fieldSelector + ' renders actual fields') + for (const { field, panel } of fields) assert.ok(field.width > 0 && field.left >= panel.left && field.right <= panel.right + 1, fieldSelector + ' fits its enclosing dialog: ' + JSON.stringify({ field, panel })) + } + const assertSettingsClearance = async stacked => { + const container = await run(`(()=>{const element=document.querySelector('.program-modal-content'),style=getComputedStyle(element);return{name:style.containerName,type:style.containerType,width:element.clientWidth-parseFloat(style.paddingLeft)-parseFloat(style.paddingRight)}})()`) + for (const row of await run('settingsRowBounds()')) { + assert.ok(row.copyScroll <= row.copyWidth + 1, 'Settings supporting copy fits: ' + JSON.stringify(row)) + assert.ok(row.copy.right <= row.control.left + 1 || row.copy.bottom <= row.control.top + 1, 'Settings copy and control do not overlap: ' + JSON.stringify(row)) + assert.ok(row.control.left >= row.row.left + row.inset[0] - 1 && row.control.right <= row.row.right - row.inset[1] + 1, 'Settings control stays inside the row insets: ' + JSON.stringify(row)) + for (const field of row.fields) assert.ok(field.box.left >= row.control.left - 1 && field.box.right <= row.control.right + 1, 'Settings palette/field stays inside its control: ' + JSON.stringify({ label: row.label, control: row.control, field })) + if (stacked && !row.interactive) assert.equal(row.direction, 'column', row.label + ' stacks at the actual content width: ' + JSON.stringify(container)) + } + } + const assertReachable = async selector => { + await run(`reach(${JSON.stringify(selector)})`) + await settle() + const reached = await run(`reach(${JSON.stringify(selector)},false)`) + assert.ok(reached.top >= -1 && reached.bottom <= reached.height + 1 && reached.clips.every(clip => reached.top >= clip.top - 1 && reached.bottom <= clip.bottom + 1), + selector + ' is reachable by scrolling: ' + JSON.stringify(reached)) + return reached + } + const artifact = async name => { + if (!process.env.ALTO_INTERFACE_APPEARANCE_ARTIFACT_DIR) return + await fs.mkdir(process.env.ALTO_INTERFACE_APPEARANCE_ARTIFACT_DIR, { recursive: true }) + await fs.writeFile(path.join(process.env.ALTO_INTERFACE_APPEARANCE_ARTIFACT_DIR, name + '.png'), (await win.webContents.capturePage()).toPNG()) + } + const contrast = async (selectors, minimum, context) => { + await run('Promise.all(document.getAnimations().filter(animation=>Number.isFinite(animation.effect?.getComputedTiming().iterations)).map(animation=>animation.finished.catch(()=>{})))') + for (const [selector, measured] of Object.entries(await sample(selectors))) { + assert.equal(measured.backgroundImage, 'none', context + ': flat roles make the contrast sample complete') + assert.ok(measured.contrast >= minimum, JSON.stringify({ context, selector, minimum, ...measured })) + } + } + try { + await win.loadURL(`http://127.0.0.1:${server.address().port}`) + await run(bundle.outputFiles[0].text) + await settle() + win.webContents.debugger.attach('1.3') + await win.webContents.debugger.sendCommand('DOM.enable') + await win.webContents.debugger.sendCommand('CSS.enable') + await win.webContents.debugger.sendCommand('Emulation.setFocusEmulationEnabled', { enabled: true }) + await run(`remember('[data-cordis-composer-editor]','originalEditor')`) + 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']]) { + await force('#menu-trigger', states) + await assertRole('#menu-trigger', 'fill', token) + } + await force('#menu-trigger', ['focus', 'focus-visible']) + await assertRole('#menu-trigger', 'outline', '--ui-focus') + await force('#menu-trigger', []) + + await run('cmdK()') + await settle() + assert.ok(await assertPortal('.search-palette'), 'Cmd+K is a real portal outside workspace appearance scope') + for (const [property, token] of [['fill', '--ui-surface'], ['border', '--ui-border'], ['radius', '--ui-dialog-radius'], ['shadow', '--ui-dialog-shadow'], ['font', '--ui-font']]) await assertRole('.search-palette', property, token) + for (const [property, token] of [['fill', '--ui-field-fill'], ['border', '--ui-field-border'], ['radius', '--ui-field-radius']]) await assertRole('.search-palette-input', property, token) + await assertRole('.search-palette input', 'color', '--ui-text') + await assertRole('.search-result strong', 'color', '--ui-text-strong') + await assertRole('.search-result strong', 'size', '--ui-size') + await assertRole('.search-result strong', 'weight', '--ui-weight') + await assertRole('.search-result-copy>span', 'color', '--ui-text-secondary') + await assertRole('.search-result.active', 'fill', '--ui-selected') + for (const [states, token] of [[['hover'], '--ui-hover'], [['hover', 'active'], '--ui-pressed']]) { + await force('.search-result:nth-child(2)', states) + await assertRole('.search-result:nth-child(2)', 'fill', token) + } + await force('.search-result:nth-child(2)', []) + assert.equal(await run(`document.activeElement===document.querySelector('.search-palette input')`), true, 'Cmd+K focuses its input for immediate typing') + assert.equal((await sample(['.search-palette-input']))['.search-palette-input'].outlineStyle, 'none', 'The focused Search input keeps its neutral field border') + await run(`input('.search-palette input','Conversation');key('.search-palette input','ArrowDown');remember('.search-palette input','originalSearch')`) + const searchBefore = await run('searchState()') + await run('disableWorkspaceAppearance()') + await assertRole('.search-palette', 'fill', '--ui-surface') + for (let cycle = 0; cycle < 2; cycle++) { + await run('disableAppearance()') + assert.equal((await run('styleIds()')).filter(id => id.startsWith('interface-')).length, 0, 'Disposal removes only the owner styles') + assert.ok(await run(`preserved('.search-palette input','originalSearch') && preserved('[data-cordis-composer-editor]','originalEditor')`), 'Disposal preserves query, caret, and Composer draft nodes') + await run('installAppearance()') + await assertRole('.search-palette', 'fill', '--ui-surface') + assert.ok(await run(`preserved('.search-palette input','originalSearch') && preserved('[data-cordis-composer-editor]','originalEditor')`), 'Reapplication preserves query, caret, and Composer draft nodes') + assert.deepEqual(await run('searchState()'), searchBefore, 'Reapplication does not reset keyboard selection') + } + for (let i = 0; i < 14; i++) await run(`key('.search-palette input','ArrowDown')`) + assert.ok(await run(`(()=>{const list=document.querySelector('.search-results'),row=list.querySelector('[aria-selected="true"]'),a=list.getBoundingClientRect(),b=row.getBoundingClientRect();return list.scrollTop>0&&b.top>=a.top&&b.bottom<=a.bottom})()`), 'Keyboard navigation scrolls the selected result into view') + await run(`key('.search-palette input','Escape')`) + assert.equal(await run('searchState().overlay'), undefined) + + await run('openSettings()') + await settle() + assert.ok(await assertPortal('.program-modal'), 'Settings is a real portal outside workspace appearance scope') + for (const [property, token] of [['fill', '--ui-surface'], ['border', '--ui-border'], ['radius', '--ui-dialog-radius'], ['shadow', '--ui-dialog-shadow'], ['font', '--ui-font']]) await assertRole('.program-modal', property, token) + await assertRole('.program-modal-sidebar', 'fill', '--ui-surface-inset') + await assertRole('.program-modal-search', 'fill', '--ui-field-fill') + await assertRole('.program-modal-search input', 'color', '--ui-text') + await assertRole('.program-modal-nav-item.active', 'fill', '--ui-selected') + await assertRole('.settings-row-copy strong', 'color', '--ui-text-strong') + await assertRole('.settings-row-copy small', 'color', '--ui-text-secondary') + await force('.settings-row.is-interactive:first-child', ['hover']) + await assertRole('.settings-row.is-interactive:first-child', 'fill', '--ui-hover') + await force('.settings-row.is-interactive:first-child', ['hover', 'active']) + await assertRole('.settings-row.is-interactive:first-child', 'fill', '--ui-pressed') + await force('.settings-row.is-interactive:first-child', []) + await run(`click('.settings-row[role="radio"]')`) + assert.equal(await run(`document.querySelector('.settings-row[role="radio"]').getAttribute('aria-checked')`), 'true', 'Permission rows remain interactive') + await assertRole('.settings-row[role="radio"]', 'fill', '--ui-selected') + await force('.settings-row[role="radio"]', ['hover']) + await assertRole('.settings-row[role="radio"]', 'fill', '--ui-selected') + await force('.settings-row[role="radio"]', ['hover', 'active']) + await assertRole('.settings-row[role="radio"]', 'fill', '--ui-pressed') + await force('.settings-row[role="radio"]', []) + await run(`input('.program-modal-search input','workspaces');remember('.program-modal-search input','originalSettings');disableAppearance();installAppearance()`) + assert.ok(await run(`preserved('.program-modal-search input','originalSettings')`), 'Open Settings retains query, caret, and node across appearance replacement') + await run(`input('.program-modal-search input','');nav('Workspaces');click('button[aria-label="Add workspace"]')`) + await assertRole('.project-settings-form input', 'fill', '--ui-field-fill') + await assertRole('.project-settings-form input', 'border', '--ui-focus') + await run('document.activeElement.blur()') + await assertRole('.project-settings-form input', 'border', '--ui-field-border') + await assertRole('.project-settings-save', 'color', '--ui-on-action') + for (const [states, token] of [[[], '--ui-action'], [['hover'], '--ui-action-hover'], [['hover', 'active'], '--ui-action-pressed']]) { + await force('.project-settings-save', states) + await assertRole('.project-settings-save', 'fill', token) + } + await force('.project-settings-save', []) + await run(`input('.project-settings-form input','/keep/my/new-workspace');remember('.project-settings-form input','originalProject');disableAppearance();installAppearance()`) + assert.ok(await run(`preserved('.project-settings-form input','originalProject')`), 'An unsaved workspace draft survives appearance replacement') + + await setSentinels(false) + for (const mode of ['light', 'dark']) { + for (const accent of ['#7c3aed', '#16a34a', '#fff8d6']) { + await run(`setTheme(${JSON.stringify(mode)},${JSON.stringify(accent)})`) + for (const state of [[], ['hover'], ['hover', 'active']]) { + await force('.project-settings-save', state) + await contrast(['.project-settings-save', '.project-settings-form input', '.program-modal-nav-item.active'], 4.5, mode + ' Settings ' + accent + ' ' + state.join('/')) + } + await force('.project-settings-save', []) + for (const selector of ['.project-settings-save', '.project-settings-form input', '.program-modal-nav-item.active']) { + await force(selector, ['focus', 'focus-visible']) + await run(`document.querySelector(${JSON.stringify(selector)}).focus({preventScroll:true})`) + const measured = (await sample([selector]))[selector] + if(selector==='.project-settings-form input'){ + assert.ok(measured.borderWidth===1 && measured.borderContrast>=3 && measured.outlineStyle==='none' && measured.shadow==='none', JSON.stringify({ mode, accent, selector, ...measured })) + }else{ + assert.ok(measured.outlineStyle !== 'none' && measured.outlineWidth >= 2 && measured.outlineContrast >= 3, JSON.stringify({ mode, accent, selector, ...measured })) + } + await run('document.activeElement.blur()') + await force(selector, []) + } + } + await run(`nav('Appearance')`) + await contrast(['.settings-row-copy strong', '.settings-row-copy small', 'select[aria-label="Chrome palette"]', '.theme-mode-picker button.active'], 4.5, mode + ' real Appearance settings') + for (const width of [1100, 390]) { + win.setContentSize(width, 800) + await settle() + await assertFits('.program-modal') + await assertSettingsClearance(width <= 480) + if (width <= 480) { + for (const copy of await run('settingsCopyBounds()')) assert.ok(copy.scroll <= copy.width + 1, mode + ': supporting Settings copy stays clear of its control ' + JSON.stringify(copy)) + } + if (process.env.ALTO_INTERFACE_APPEARANCE_ARTIFACT_DIR) { + await fs.mkdir(process.env.ALTO_INTERFACE_APPEARANCE_ARTIFACT_DIR, { recursive: true }) + await fs.writeFile(path.join(process.env.ALTO_INTERFACE_APPEARANCE_ARTIFACT_DIR, `settings-${mode}-${width}.png`), (await win.webContents.capturePage()).toPNG()) + } + } + win.setContentSize(1100, 800) + await run(`nav('Workspaces');click('button[aria-label="Add workspace"]')`) + } + await run(`key('.program-modal-search input','Escape')`) + assert.equal(await run('searchState().overlay'), undefined, 'Settings Escape remains functional') + + await setSentinels(true) + await run(`click('#menu-trigger')`) + await settle() + assert.ok(await assertPortal('.workspace-tab-action-menu'), 'The common menu is also outside workspace appearance scope') + for (const [property, token] of [['fill', '--ui-surface'], ['border', '--ui-border'], ['radius', '--ui-panel-radius'], ['shadow', '--ui-panel-shadow']]) await assertRole('.workspace-tab-action-menu', property, token) + await assertRole('.workspace-tab-action-menu button', 'color', '--ui-text') + await assertRole('.workspace-tab-action-menu button', 'radius', '--ui-row-radius') + for (const [states, token] of [[['hover'], '--ui-hover'], [['hover', 'active'], '--ui-pressed']]) { + await force('.workspace-tab-action-menu button', states) + await assertRole('.workspace-tab-action-menu button', 'fill', token) + } + await force('.workspace-tab-action-menu button', []) + await run(`key('.workspace-tab-action-menu button','ArrowDown')`) + assert.equal(await run('document.activeElement.textContent'), 'Close tab', 'Common menu keyboard navigation remains functional') + await run(`key('.workspace-tab-action-menu button','Escape')`) + assert.equal(await run('document.activeElement.id'), 'menu-trigger', 'Common menu Escape returns focus to its trigger') + await setSentinels(false) + + for (const mode of ['light', 'dark']) { + await run(`setTheme(${JSON.stringify(mode)},'#fff8d6');cmdK()`) + await settle() + for (const states of [[], ['hover'], ['hover', 'active']]) { + await force('.search-result.active', states) + await contrast(['.search-result strong', '.search-result-copy>span', '.search-palette input'], 4.5, mode + ' search with pale accent ' + states.join('/')) + await contrast(['.search-result-check'], 3, mode + ' current conversation indicator with pale accent ' + states.join('/')) + } + await force('.search-result.active', []) + await force('.search-result.active', ['focus', 'focus-visible']) + await run(`document.querySelector('.search-result.active').focus({preventScroll:true})`) + const row = (await sample(['.search-result.active']))['.search-result.active'] + assert.ok(row.outlineStyle !== 'none' && row.outlineWidth >= 2 && row.outlineContrast >= 3, JSON.stringify({ mode, row })) + await force('.search-result.active', []) + await run(`document.querySelector('.search-palette input').focus({preventScroll:true})`) + assert.equal((await sample(['.search-palette-input']))['.search-palette-input'].outlineStyle, 'none', 'Search does not draw an accent outline around the typing field') + for (const width of [1100, 390]) { + win.setContentSize(width, 800) + await settle() + await assertFits('.search-palette') + const clearance = await run('searchClearance()') + assert.ok(Object.values(clearance).every(value => value >= 4), mode + ': Search field stays clear of the dialog clip ' + JSON.stringify(clearance)) + if (process.env.ALTO_INTERFACE_APPEARANCE_ARTIFACT_DIR) { + await fs.writeFile(path.join(process.env.ALTO_INTERFACE_APPEARANCE_ARTIFACT_DIR, `search-${mode}-${width}.png`), (await win.webContents.capturePage()).toPNG()) + } + } + await run(`key('.search-palette input','Escape')`) + } + + // Distinct sentinels expose feature overrides that would otherwise look + // correct only while the shared defaults happen to have the same values. + win.setContentSize(1100, 800) + await setGeometrySentinels(true) + await assertGeometry('#menu-trigger', { gap: '10px', padding: ['9px', '22px', '9px', '22px'] }) + await run(`click('#menu-trigger')`) + await settleGeometry() + await assertGeometry('.workspace-tab-action-menu', { padding: ['16px', '16px', '16px', '16px'] }) + await assertGeometry('.workspace-tab-action-menu button', { minHeight: '38px', gap: '11px', padding: ['9px', '20px', '9px', '20px'] }) + await run('disableAppearance();installAppearance()') + await assertGeometry('.workspace-tab-action-menu button', { minHeight: '38px', padding: ['9px', '20px', '9px', '20px'] }) + await run(`key('.workspace-tab-action-menu button','Escape');openSettings()`) + await settleGeometry() + await assertGeometry('.program-modal-backdrop', { padding: ['28px', '28px', '28px', '28px'] }) + await assertGeometry('.program-modal-content', { padding: ['28px', '28px', '28px', '28px'] }) + await assertGeometry('.settings-row', { minHeight: '60px', padding: ['11px', '20px', '11px', '20px'] }) + await run(`nav('Workspaces');click('button[aria-label="Add workspace"]')`) + await settleGeometry() + await assertGeometry('.project-settings-form', { padding: ['16px', '16px', '16px', '16px'], gap: '16px' }) + await assertGeometry('.project-settings-form input', { minHeight: '40px', height: 40, padding: ['9px', '18px', '9px', '18px'] }) + await run(`key('.program-modal-search input','Escape');cmdK()`) + await settleGeometry() + await assertGeometry('.search-results', { padding: ['16px', '16px', '16px', '16px'] }) + await assertGeometry('.search-result', { minHeight: '60px', gap: '11px', padding: ['9px', '20px', '9px', '20px'] }) + await setGeometrySentinels(false) + await assertGeometry('.search-result', { minHeight: '52px' }) + assert.equal((await sample(['.search-palette-input']))['.search-palette-input'].outlineStyle, 'none', 'Geometry reapplication does not add a purple Cmd+K field outline') + await run(`key('.search-palette input','Escape')`) + + // Simulate a sidebar/split pane while retaining the wide browser viewport. + await run(`setWorkspaceInset(440);openSettings();nav('Appearance')`) + await settleGeometry() + assert.equal((await run(`bounds('.program-modal')`)).workspaceWidth, 660) + await assertDialogFits('.program-modal') + await assertFieldsFit('.program-modal', '.program-modal-search,.settings-row select,.theme-mode-picker') + await assertSettingsClearance(true) + await artifact('settings-constrained-660') + await run(`nav('Workspaces');click('button[aria-label="Add workspace"]')`) + await settleGeometry() + await assertFieldsFit('.program-modal', '.project-settings-form input,.project-settings-form textarea') + await assertReachable('.project-settings-save') + await run(`key('.program-modal-search input','Escape');cmdK()`) + await settleGeometry() + await assertDialogFits('.search-palette') + await assertFieldsFit('.search-palette', '.search-palette-input') + await assertReachable('.search-result:last-child') + await artifact('search-constrained-660') + await run(`key('.search-palette input','Escape');setWorkspaceInset(0)`) + + for (const [width, height] of [[900, 480], [390, 480]]) { + win.setContentSize(width, height) + await run(`openSettings();nav('Appearance')`) + await settleGeometry() + await assertDialogFits('.program-modal') + await assertFieldsFit('.program-modal', '.program-modal-search,.settings-row select,.theme-mode-picker') + await assertSettingsClearance(width < 480) + await assertReachable('.theme-settings-card .settings-row:last-child') + await artifact(`settings-short-${width}x${height}`) + await run(`nav('Workspaces');click('button[aria-label="Add workspace"]')`) + await settleGeometry() + await assertFieldsFit('.program-modal', '.project-settings-form input,.project-settings-form textarea') + const save = await assertReachable('.project-settings-save') + assert.ok(save.clips.some(clip => clip.max > 0 && clip.scroll > 0), 'The short Settings form can scroll to its Save action') + await run(`key('.program-modal-search input','Escape');cmdK()`) + await settleGeometry() + await assertDialogFits('.search-palette') + await assertReachable('.search-result:last-child') + assert.equal((await sample(['.search-palette-input']))['.search-palette-input'].outlineStyle, 'none') + await artifact(`search-short-${width}x${height}`) + await run(`key('.search-palette input','Escape')`) + } + for (const [width, height] of [[900, 480], [390, 320]]) { + win.setContentSize(width, height) + await run('openCreator()') + await settleGeometry() + await assertDialogFits('.workspace-creator') + await assertFieldsFit('.workspace-creator', '.workspace-creator-form input,.workspace-creator-form textarea') + const create = await assertReachable('.workspace-creator-form button[type="submit"]') + if (height < 400) assert.ok(create.clips.some(clip => clip.max > 0 && clip.scroll > 0), 'The short creator scrolls its form while retaining its header') + await artifact(`creator-short-${width}x${height}`) + await run(`click('.workspace-creator>header>button')`) + } + win.setContentSize(1100, 800) + await settle() + await run(`click('.composer-permission-trigger')`) + await settleGeometry() + await assertGeometry('.composer-permission-picker', { padding: ['12px', '12px', '12px', '12px'] }) + const permissionRows = await run(`(()=>{window.originalPermissionPicker=document.querySelector('.composer-permission-picker');return[...originalPermissionPicker.querySelectorAll('button:has(.permission-option-copy small)')].map(row=>({height:getComputedStyle(row).minHeight,columns:getComputedStyle(row).gridTemplateColumns.split(' ')}))})()`) + assert.ok(permissionRows.length >= 2, 'The real Composer permission menu contains description choices') + for (const row of permissionRows) { + assert.equal(row.height, '52px', 'Permission descriptions retain two-line choice density') + assert.equal(row.columns[0], '24px', 'Permission icon column shares the icon slot') + assert.equal(row.columns.at(-1), '24px', 'Permission check column shares the icon slot') + } + await run('disableAppearance();installAppearance()') + assert.ok(await run(`originalPermissionPicker===document.querySelector('.composer-permission-picker')&&preserved('[data-cordis-composer-editor]','originalEditor')`), 'Geometry replacement preserves the open permission picker and Composer draft nodes') + await assertGeometry('.composer-permission-picker>button', { minHeight: '52px', padding: ['6px', '12px', '6px', '12px'] }) + await run(`key('.composer-permission-trigger','Escape')`) + assert.ok(await run(`preserved('[data-cordis-composer-editor]','originalEditor')`), 'Every appearance/state update preserves the original Composer draft node') + await run('teardown()') + assert.deepEqual(await run('styleIds()'), [], 'All fixture-owned styles clean up') + console.log('Interface appearance browser checks passed') + } finally { + if (win.webContents.debugger.isAttached()) win.webContents.debugger.detach() + win.destroy() + await new Promise(resolve => server.close(resolve)) + } +} + +check().then(() => app.quit()).catch(error => { console.error(error); app.exit(1) }) diff --git a/tests/fixtures/markdown-link-preview.cjs b/tests/fixtures/markdown-link-preview.cjs index 5c73618..a36a8a6 100644 --- a/tests/fixtures/markdown-link-preview.cjs +++ b/tests/fixtures/markdown-link-preview.cjs @@ -26,7 +26,7 @@ async function check() { define: { 'process.env.NODE_ENV': '"production"' }, }) const css = (await Promise.all([ - 'src/client/styles.css', 'program/plugins/ui/default.css', 'program/plugins/markdown.css', + 'src/client/styles.css', 'program/plugins/ui/appearance-tokens.css', 'program/plugins/ui/controls.css', 'program/plugins/ui/surfaces.css', 'program/plugins/ui/default.css', 'program/plugins/markdown.css', 'program/plugins/markdown-viewer.css', 'program/plugins/theme.css', ].map(file => fs.readFile(file, 'utf8')))).join('\n').replace(/@import[^;]+;/g, '') const server = http.createServer((_request, response) => { diff --git a/tests/fixtures/markdown-selection.cjs b/tests/fixtures/markdown-selection.cjs index 17021ec..dc3f6fb 100644 --- a/tests/fixtures/markdown-selection.cjs +++ b/tests/fixtures/markdown-selection.cjs @@ -71,7 +71,7 @@ async function check() { ` }, 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', 'src/client/pane-toolbar.css', 'program/plugins/ui/default.css', 'program/plugins/theme.css'].map(file => fs.readFile(file, 'utf8')))).join('\n').replace(/@import[^;]+;/g, '') + 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', 'src/client/pane-toolbar.css', 'program/plugins/ui/default.css', 'program/plugins/theme.css'].map(file => fs.readFile(file, 'utf8')))).join('\n').replace(/@import[^;]+;/g, '') const server = http.createServer(async (request, response) => { if (request.url?.startsWith('/fonts/')) { try { response.end(await fs.readFile(path.join('public/fonts', path.basename(decodeURIComponent(request.url))))); return } diff --git a/tests/fixtures/mermaid-expand.cjs b/tests/fixtures/mermaid-expand.cjs index 450d8c9..48cfcaa 100644 --- a/tests/fixtures/mermaid-expand.cjs +++ b/tests/fixtures/mermaid-expand.cjs @@ -19,7 +19,7 @@ async function check(){ 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/theme.css','program/plugins/ui/default.css'].map(f=>fs.readFile(f,'utf8')))).join('\n').replace(/@import[^;]+;/g,'') + 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 frames=()=>run('frame()') diff --git a/tests/fixtures/orchestrator.cjs b/tests/fixtures/orchestrator.cjs index 5642270..1a12cd8 100644 --- a/tests/fixtures/orchestrator.cjs +++ b/tests/fixtures/orchestrator.cjs @@ -56,7 +56,7 @@ async function check() { loader: { '.css': 'text' }, define: { 'process.env.NODE_ENV': '"production"' }, }) const css = (await Promise.all([ - 'src/client/pane-toolbar.css', 'src/client/styles.css', 'program/plugins/theme.css', 'program/plugins/ui/default.css', 'program/plugins/orchestrator.css', + 'src/client/pane-toolbar.css', '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', 'program/plugins/orchestrator.css', ].map(file => fs.readFile(file, 'utf8')))).join('\n').replace(/@import[^;]+;/g, '') const server = http.createServer((_request, response) => response.end(`
') diff --git a/tests/fixtures/request-card-styling.cjs b/tests/fixtures/request-card-styling.cjs index cf62b43..bbba257 100644 --- a/tests/fixtures/request-card-styling.cjs +++ b/tests/fixtures/request-card-styling.cjs @@ -51,7 +51,7 @@ async function check() { ` }, 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/default.css', 'program/plugins/theme.css', 'program/plugins/ui/user-input.css'].map(file => fs.readFile(file, 'utf8')))).join('\n').replace(/@import[^;]+;/g, '') + 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/ui/default.css', 'program/plugins/theme.css', 'program/plugins/ui/user-input.css'].map(file => fs.readFile(file, 'utf8')))).join('\n').replace(/@import[^;]+;/g, '') const server = http.createServer((_request, response) => response.end('
')) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const win = new BrowserWindow({ show: false, width: 1000, height: 1000, webPreferences: { offscreen: true, sandbox: true, contextIsolation: true, backgroundThrottling: false } }) diff --git a/tests/fixtures/search.cjs b/tests/fixtures/search.cjs index 013f323..3c2bc85 100644 --- a/tests/fixtures/search.cjs +++ b/tests/fixtures/search.cjs @@ -45,7 +45,7 @@ async function check() { window.teardown=()=>{flushSync(()=>root.unmount());for(const cleanup of effects.reverse())cleanup?.()}; ` }, 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','src/client/pane-toolbar.css','program/plugins/ui/default.css'].map(file=>fs.readFile(file,'utf8')))).join('\n').replace(/@import[^;]+;/g,'') + 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','src/client/pane-toolbar.css','program/plugins/ui/default.css'].map(file=>fs.readFile(file,'utf8')))).join('\n').replace(/@import[^;]+;/g,'') const server=http.createServer((_request,response)=>response.end('
')) await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)) const win=new BrowserWindow({show:false,width:1100,height:760,webPreferences:{offscreen:true,sandbox:true,contextIsolation:true,backgroundThrottling:false}}) diff --git a/tests/fixtures/source-review.cjs b/tests/fixtures/source-review.cjs index fa56f2f..4a34622 100644 --- a/tests/fixtures/source-review.cjs +++ b/tests/fixtures/source-review.cjs @@ -127,6 +127,7 @@ async function check() { }) const css = (await Promise.all([ 'src/client/styles.css', 'src/client/pane-toolbar.css', 'program/plugins/ui/default.css', 'program/plugins/theme.css', + 'program/plugins/ui/appearance-tokens.css', 'program/plugins/ui/controls.css', 'program/plugins/ui/surfaces.css', ].map(file => fs.readFile(file, 'utf8')))).join('\n').replace(/@import[^;]+;/g, '') const server = http.createServer(async (request, response) => { if (request.url?.startsWith('/fonts/')) { diff --git a/tests/fixtures/todo.cjs b/tests/fixtures/todo.cjs index 29bcd29..36c206e 100644 --- a/tests/fixtures/todo.cjs +++ b/tests/fixtures/todo.cjs @@ -82,7 +82,7 @@ async function check() { 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/default.css','program/plugins/theme.css'].map(file=>fs.readFile(file,'utf8')))).join('\n').replace(/@import[^;]+;/g,'') + 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/ui/default.css','program/plugins/theme.css'].map(file=>fs.readFile(file,'utf8')))).join('\n').replace(/@import[^;]+;/g,'') const server=http.createServer((request,response)=>response.end('
')) await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)) const win=new BrowserWindow({show:false,width:1200,height:760,webPreferences:{offscreen:true,sandbox:true,contextIsolation:true,backgroundThrottling:false}}) diff --git a/tests/fixtures/user-input.cjs b/tests/fixtures/user-input.cjs index 1d10f57..c5ebf14 100644 --- a/tests/fixtures/user-input.cjs +++ b/tests/fixtures/user-input.cjs @@ -149,7 +149,7 @@ async function check() { loader: { '.css': 'text' }, define: { 'process.env.NODE_ENV': '"production"' }, }) const css = (await Promise.all([ - 'src/client/styles.css', 'program/plugins/ui/default.css', 'program/plugins/theme.css', 'program/plugins/ui/user-input.css', + 'src/client/styles.css', 'program/plugins/ui/appearance-tokens.css', 'program/plugins/ui/controls.css', 'program/plugins/ui/surfaces.css', 'program/plugins/ui/default.css', 'program/plugins/theme.css', 'program/plugins/ui/user-input.css', ].map(file => fs.readFile(file, 'utf8')))).join('\n').replace(/@import[^;]+;/g, '') const server = http.createServer((request, response) => { if (request.url === '/fonts/Inter[opsz,wght].ttf') { diff --git a/tests/fixtures/vertical-tabs.cjs b/tests/fixtures/vertical-tabs.cjs new file mode 100644 index 0000000..efea4e0 --- /dev/null +++ b/tests/fixtures/vertical-tabs.cjs @@ -0,0 +1,980 @@ +const { app, BrowserWindow } = require('./background-electron.cjs') +const { build } = require('esbuild') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const http = require('node:http') +const path = require('node:path') + +async function check() { + await app.whenReady() + const bundle = await build({ + stdin: { resolveDir: process.cwd(), sourcefile: 'vertical-tabs-check.tsx', loader: 'tsx', contents: ` + import React,{useEffect} from 'react'; + import {createRoot} from 'react-dom/client'; + import {flushSync} from 'react-dom'; + import {CheckSquare,Network,SquareTerminal,X} from 'lucide-react'; + import {Composer} from './program/plugins/ui/composer'; + import plugin from './program/plugins/workspace-layout.client'; + import groupsPlugin from './program/plugins/workspace-tab-groups.client'; + import verticalTabsPlugin from './program/plugins/vertical-tabs.client'; + import workspaceMaterialPlugin from './program/plugins/workspace-material.client'; + import workspaceTabsAppearancePlugin from './program/plugins/workspace-tabs-appearance.client'; + import interfaceAppearancePlugin from './program/plugins/interface-appearance.client'; + import verticalHotkeysPlugin from './program/plugins/hotkeys-vertical-tabs.client'; + import {HotkeysService} from './program/plugins/hotkeys.client'; + import {parseWorkspaceLayout} from './program/plugins/workspace-layout-state'; + let saved={version:2,activeViewId:'a',views:['Alto','Incident review with a long name','Design','Review','Shell'].map((name,i)=>{ + const id=String.fromCharCode(97+i);return {id,name,workspace:'/repo',focusedPaneId:'pane-'+id,root:{type:'pane',id:'pane-'+id,workspace:'/repo',kind:id==='e'?'terminal':'test'}}; + })}; + localStorage.setItem('codex-cordis.workspace-layout',JSON.stringify(saved)); + localStorage.removeItem('codex-cordis.vertical-tabs'); + const state={connected:true,session:{workspace:'/repo',permissionMode:'full'},projects:[],threads:[],skills:[],turn:{tag:'idle'},activities:[],history:{tag:'ready',entries:[]},harness:{codex:{status:'ready',models:[]},ui:{surfaces:[],contributions:[{id:'test-tool',slot:'header-right'}],regions:[]}}}; + const host={subscribe:()=>()=>{},snapshot:()=>({connectionEpoch:1}),call:async(method,payload)=>{ + if(method.endsWith('.read'))return structuredClone(saved); + if(method.endsWith('.write')){saved=parseWorkspaceLayout(payload);return null;} + }}; + let Surface,registry,root; + const session={snapshot:()=>state,subscribe:()=>()=>{}}; + const Placeholder=()=>null; + const Tool=()=><> + ; + const ui={subscribe:()=>()=>{},snapshot:()=>0,component:()=>Placeholder,renderer:()=>undefined,contributionRenderer:()=>Tool, + overlays:{open:()=>{},close:()=>{}}, + registerSurface:(_owner,_id,component)=>{Surface=component}, + registerStyle:(owner,id,css)=>{owner.effect(()=>{const el=document.createElement('style');el.dataset.pluginStyle=id;el.textContent=css;document.head.append(el);return ()=>el.remove()})}}; + const ctx={provide:(name,value)=>{if(name==='clientVerticalTabs')registry.verticalTabs=value;else registry=value},clientUi:ui,clientHost:host,clientSession:session, + clientSessionRouter:{setActive:()=>{},clearActive:()=>{}},clientSessionFactory:{create:()=>{throw new Error('Unexpected chat creation')}}}; + plugin({...ctx,effect:fn=>fn()}); + const fiber=install=>{let effects=[];return { + enable:()=>flushSync(()=>install({...ctx,clientWorkspaceLayout:registry,effect:fn=>{const cleanup=fn();effects.push(cleanup);return cleanup}})), + disable:()=>flushSync(()=>{for(const cleanup of effects.reverse())cleanup();effects=[]}), + }}; + const hotkeys=new HotkeysService(ui,{leader:'Alt'}); + const controls=fiber(interfaceAppearancePlugin),appearance=fiber(workspaceTabsAppearancePlugin),material=fiber(workspaceMaterialPlugin),groups=fiber(groupsPlugin),vertical=fiber(ctx=>{verticalTabsPlugin(ctx); + verticalHotkeysPlugin({...ctx,clientVerticalTabs:registry.verticalTabs,clientHotkeys:{registerAction:(owner,action)=>hotkeys.registerAction(owner,action)}})}); + window.enableGroups=groups.enable;window.disableGroups=groups.disable; + window.enableVertical=vertical.enable;window.disableVertical=vertical.disable; + window.enableMaterial=material.enable;window.disableMaterial=material.disable; + window.enableAppearance=appearance.enable;window.disableAppearance=appearance.disable; + controls.enable();appearance.enable(); + material.enable(); + groups.enable(); + window.mounts=0; + const Pane=({pane})=>{useEffect(()=>{window.mounts++},[]);const body=

Conversation in {pane.id}