From ae83860f437057223dfb145ec612640d1bffee09 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 19 Oct 2026 20:04:16 +0800 Subject: [PATCH 1/4] fix(assistant): make history management actions discoverable --- lib/agent-plugin/ui/history-controls.ts | 6 ++- styles/assistant.css | 67 ++++++++++++++++++------- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/lib/agent-plugin/ui/history-controls.ts b/lib/agent-plugin/ui/history-controls.ts index 94f8bd908..998318b44 100644 --- a/lib/agent-plugin/ui/history-controls.ts +++ b/lib/agent-plugin/ui/history-controls.ts @@ -1,4 +1,5 @@ import { localStorageGetItem, localStorageSetItem } from 'ranuts/utils'; +import { Div } from 'ranui/builder'; import { t, type I18nMessages } from '@ranuts/shared/i18n'; import type { createConversationStore } from './sessions'; @@ -49,7 +50,10 @@ export function createHistoryControls(options: HistoryControlsOptions) { const exported = button('agent-history-export'); const remove = button('agent-history-delete-current'); const clear = button('agent-history-delete-all'); - actions.append(restore, legacy, exported, remove, clear); + actions.append( + Div().class('agent-history-transfer-actions').children(restore, legacy, exported).build(), + Div().class('agent-history-danger-actions').children(remove, clear).build(), + ); const confirmation = document.createElement('div'); confirmation.className = 'agent-history-confirmation'; confirmation.hidden = true; diff --git a/styles/assistant.css b/styles/assistant.css index 113ab7885..0c5fb3746 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -144,12 +144,22 @@ body.agent-panel-resizing #app iframe { color: var(--ran-color-text-secondary); overflow-wrap: anywhere; } -.agent-history-actions, +.agent-history-transfer-actions, +.agent-history-danger-actions, .agent-history-confirmation { display: flex; flex-wrap: wrap; gap: var(--ran-space-2); } +.agent-history-actions { + display: flex; + flex-direction: column; + gap: var(--ran-space-4); +} +.agent-history-danger-actions { + border-block-start: 1px solid var(--ran-color-border); + padding-block-start: var(--ran-space-4); +} .agent-history-confirmation { margin-top: var(--ran-space-3); } @@ -160,21 +170,54 @@ body.agent-panel-resizing #app iframe { flex-basis: 100%; } .agent-history-options button { + display: inline-flex; + align-items: center; + justify-content: center; box-sizing: border-box; min-height: var(--ran-size-7); + min-width: 0; max-width: 100%; - padding: var(--ran-space-1) var(--ran-space-2); - border: 0; + padding: var(--ran-space-1) var(--ran-space-3); + border: 1px solid var(--ran-color-border); border-radius: var(--ran-radius-sm); - background: transparent; + background: var(--ran-color-bg-elevated); color: var(--ran-color-text); font: inherit; - font-size: var(--ran-text-label-3); + font-size: var(--ran-text-button-size); + font-weight: var(--ran-text-button-weight); + line-height: var(--ran-line-height); overflow-wrap: anywhere; cursor: pointer; } +.agent-history-options button:enabled:hover { + border-color: var(--ran-color-border-hover); + background: var(--ran-color-bg-hover); +} +.agent-history-options button:enabled:active { + border-color: var(--ran-color-border-active); + background: var(--ran-color-bg-active); +} +.agent-history-options button:is(.agent-history-delete-current, .agent-history-delete-all):enabled { + color: var(--app-danger-fill); + border-color: var(--app-danger-fill); +} +.agent-history-options .agent-history-confirm:enabled { + color: var(--app-danger-foreground); + border-color: var(--app-danger-fill); + background: var(--app-danger-fill); +} +.agent-history-options .agent-history-confirm:enabled:hover { + border-color: var(--app-danger-hover); + background: var(--app-danger-hover); +} +.agent-history-options .agent-history-confirm:enabled:active { + border-color: var(--app-danger-active); + background: var(--app-danger-active); +} .agent-history-options button:disabled { color: var(--ran-color-text-disabled); + border-color: var(--ran-color-border); + background: var(--ran-color-bg-muted); cursor: default; } .agent-history-options button:focus-visible, @@ -1837,14 +1880,6 @@ r-dropdown[role='listbox'] { .agent-history-empty[hidden] { display: none; } -.agent-history-options .agent-history-delete-all, -.agent-history-options .agent-history-confirm { - color: var(--ran-color-danger); -} -.agent-history-options button:hover:not(:disabled) { - background: var(--ran-color-bg-hover); -} - /* Contextual document tasks share the transcript and composer's leading edge. */ .agent-task-entry { font-weight: 400; @@ -2202,7 +2237,6 @@ r-dropdown[role='listbox'] { } .agent-workspace-view button:hover:enabled, .agent-artifacts button:hover:enabled, -.agent-history-options button:hover:enabled, .agent-view-back:hover:enabled, .agent-sources-toggle:hover:enabled, .agent-workspace-toggle:hover:enabled { @@ -2259,11 +2293,10 @@ r-dropdown[role='listbox'] { } .agent-workspace-view button:active:enabled, .agent-artifacts button:active:enabled, -.agent-history-options button:active:enabled, .agent-view-back:active:enabled { background: var(--ran-color-bg-active); } -.agent-panel :is(.agent-workspace-view, .agent-artifacts, .agent-history-options, .agent-view-header) button:disabled { +.agent-panel :is(.agent-workspace-view, .agent-artifacts, .agent-view-header) button:disabled { color: var(--ran-color-text-disabled); cursor: default; background: transparent; @@ -2282,8 +2315,6 @@ r-dropdown[role='listbox'] { background: var(--ran-color-primary-active); border-color: var(--ran-color-primary-active); } -.agent-history-options - button:is(.agent-history-delete-current, .agent-history-delete-all, .agent-history-confirm):enabled, .agent-artifacts button:is([data-action='delete'], [data-action='confirmDelete']):enabled { color: var(--ran-color-danger); } From 6f5477362d1ec4f5e789651116077690b4e9c14b Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 19 Oct 2026 20:04:16 +0800 Subject: [PATCH 2/4] test(assistant): cover history command surfaces and keyboard paths --- test/e2e/agent-history-actions.spec.ts | 179 +++++++++++++++++++++++ test/unit/agent-history-controls.test.ts | 6 + 2 files changed, 185 insertions(+) create mode 100644 test/e2e/agent-history-actions.spec.ts diff --git a/test/e2e/agent-history-actions.spec.ts b/test/e2e/agent-history-actions.spec.ts new file mode 100644 index 000000000..dc76c91c2 --- /dev/null +++ b/test/e2e/agent-history-actions.spec.ts @@ -0,0 +1,179 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect, test } from './lib/l0'; + +async function openHistory(page: Page, locale = 'en') { + await page.goto(`/editor?new=docx&locale=${locale}`); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-history-toggle').click(); + await page.locator('.agent-history-management > summary').click(); + await expect(page.locator('.agent-history-restore')).toBeEnabled(); +} + +async function surface(button: Locator) { + return button.evaluate((el) => { + const css = getComputedStyle(el); + return { + background: css.backgroundColor, + color: css.color, + border: css.borderColor, + borderWidth: parseFloat(css.borderTopWidth), + borderStyle: css.borderTopStyle, + outline: css.outlineStyle, + outlineWidth: parseFloat(css.outlineWidth), + }; + }); +} + +function contrast(foreground: string, background: string) { + const luminance = (color: string) => { + const rgb = color + .match(/[\d.]+/g)! + .slice(0, 3) + .map(Number); + const [r, g, b] = rgb.map((value) => { + const channel = value / (color.startsWith('color(srgb') ? 1 : 255); + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return r * 0.2126 + g * 0.7152 + b * 0.0722; + }; + const a = luminance(foreground), + b = luminance(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + localStorage.setItem('agent_conversation_saving_v2', 'true'); + }); +}); + +for (const scheme of ['light', 'dark'] as const) { + test(`history commands have visible resting surfaces and safe deletion states in ${scheme}`, async ({ + page, + browserName, + }, info) => { + test.setTimeout(120_000); + await page.emulateMedia({ colorScheme: scheme }); + for (const locale of ['en', 'pt']) { + await page.setViewportSize({ width: 1280, height: 900 }); + await openHistory(page, locale); + const options = page.locator('.agent-history-options'); + for (const width of [1280, 360, 320]) { + await page.setViewportSize({ width, height: 900 }); + await page.mouse.move(0, 0); + await options.screenshot({ path: info.outputPath(`history-actions-${locale}-${width}-${scheme}.png`) }); + const transfer = options.locator('.agent-history-restore, .agent-history-import, .agent-history-export'); + const danger = options.locator('.agent-history-delete-current, .agent-history-delete-all'); + for (const button of await transfer.or(danger).all()) { + const css = await surface(button); + // A borderless transparent command recreates the reported plain-text affordance. + expect(css.borderWidth).toBeGreaterThanOrEqual(1); + expect(css.borderStyle).toBe('solid'); + expect(css.border).not.toBe(css.background); + expect(css.background).not.toBe('rgba(0, 0, 0, 0)'); + expect(contrast(css.color, css.background)).toBeGreaterThanOrEqual(4.5); + expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(32); + expect(await button.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); + } + const transferBottom = Math.max( + ...(await transfer.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().bottom))), + ); + const dangerTop = Math.min( + ...(await danger.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top))), + ); + expect(dangerTop - transferBottom).toBeGreaterThanOrEqual(16); + expect(await options.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); + } + await page.setViewportSize({ width: 1280, height: 900 }); + const restore = options.locator('.agent-history-restore'); + await page.mouse.move(0, 0); + const resting = await surface(restore); + await restore.hover(); + const hovering = await surface(restore); + expect(hovering.border).not.toBe(resting.border); + await page.mouse.down(); + expect((await surface(restore)).background).not.toBe(hovering.background); + await page.mouse.move(0, 0); + await page.mouse.up(); + await restore.evaluate((el: HTMLButtonElement) => { + el.disabled = true; + }); + const disabled = await surface(restore); + await restore.hover(); + expect(await surface(restore)).toEqual(disabled); + await restore.evaluate((el: HTMLButtonElement) => { + el.disabled = false; + }); + + for (const danger of await options.locator('.agent-history-danger-actions button').all()) { + await page.mouse.move(0, 0); + const restingDanger = await surface(danger); + await danger.hover(); + expect((await surface(danger)).color).toBe(restingDanger.color); + expect((await surface(danger)).border).toBe(restingDanger.border); + } + + const remove = options.locator('.agent-history-delete-current'); + await remove.focus(); + // Safari's native keyboard navigation uses Option+Tab to include buttons. + const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + await page.keyboard.press(tab); + await page.keyboard.press(`Shift+${tab}`); + await expect(remove).toBeFocused(); + expect((await surface(remove)).outline).toBe('solid'); + expect((await surface(remove)).outlineWidth).toBeGreaterThanOrEqual(2); + await page.keyboard.press('Enter'); + const confirmation = page.locator('.agent-history-confirmation'); + await expect(confirmation).toBeVisible(); + await expect(options.locator('.agent-history-cancel')).toBeFocused(); + const confirm = options.locator('.agent-history-confirm'); + await page.mouse.move(0, 0); + const destructive = await surface(confirm); + expect(destructive.background).not.toBe((await surface(remove)).background); + expect(contrast(destructive.color, destructive.background)).toBeGreaterThanOrEqual(4.5); + await confirm.hover(); + const destructiveHover = await surface(confirm); + expect(destructiveHover.background).not.toBe(destructive.background); + expect(contrast(destructiveHover.color, destructiveHover.background)).toBeGreaterThanOrEqual(4.5); + await page.mouse.down(); + const destructiveActive = await surface(confirm); + expect(destructiveActive.background).not.toBe(destructiveHover.background); + expect(contrast(destructiveActive.color, destructiveActive.background)).toBeGreaterThanOrEqual(4.5); + await page.mouse.move(0, 0); + await page.mouse.up(); + await options.screenshot({ path: info.outputPath(`history-confirm-${locale}-${scheme}.png`) }); + // Pointer activation need not focus native buttons (Safari). Exercise the + // confirmation's Escape contract with keyboard focus inside that region. + await options.locator('.agent-history-cancel').focus(); + await expect(options.locator('.agent-history-cancel')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(confirmation).toBeHidden(); + await expect(remove).toBeFocused(); + const download = page.waitForEvent('download'); + await options.locator('.agent-history-export').click(); + expect((await download).suggestedFilename()).toMatch(/^conversations-\d{4}-\d{2}-\d{2}\.json$/); + } + }); +} + +test.describe('touch history actions', () => { + test.use({ hasTouch: true, viewport: { width: 320, height: 800 } }); + test('all commands and confirmation exits retain 44px touch targets', async ({ page }) => { + test.setTimeout(90_000); + await openHistory(page, 'pt'); + await page.locator('.agent-history-delete-all').click(); + for (const button of await page.locator('.agent-history-options button:visible').all()) { + const box = (await button.boundingBox())!; + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(320); + } + await page.locator('.agent-history-cancel').click(); + await expect(page.locator('.agent-history-confirmation')).toBeHidden(); + await expect(page.locator('.agent-history-delete-all')).toBeFocused(); + }); +}); diff --git a/test/unit/agent-history-controls.test.ts b/test/unit/agent-history-controls.test.ts index 9d7c39f9e..d6ded764e 100644 --- a/test/unit/agent-history-controls.test.ts +++ b/test/unit/agent-history-controls.test.ts @@ -33,6 +33,9 @@ it('does not offer cancellation after confirmed deletion has started', async () await vi.waitFor(() => expect(finish).toBeDefined()); const cancel = controls.el.querySelector('.agent-history-cancel')!; expect(cancel.disabled).toBe(true); + for (const action of controls.el.querySelectorAll('.agent-history-actions button')) { + expect(action.disabled).toBe(true); + } cancel.click(); expect(controls.el.querySelector('.agent-history-confirmation')!.hidden).toBe(false); finish(); @@ -40,6 +43,9 @@ it('does not offer cancellation after confirmed deletion has started', async () expect(controls.el.querySelector('.agent-history-confirmation')!.hidden).toBe(true), ); expect(cancel.disabled).toBe(false); + for (const action of controls.el.querySelectorAll('.agent-history-actions button')) { + expect(action.disabled).toBe(false); + } }); it('requires explicit opt-in to save and restores after a previously enabled preference', async () => { From 807e962e66868f080d169bf37ac8fd9116f80580 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 19 Oct 2026 20:04:17 +0800 Subject: [PATCH 3/4] docs(assistant): record history action design verification --- .../2026-10-18-assistant-design-audit.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/explorations/2026-10-18-assistant-design-audit.md b/docs/explorations/2026-10-18-assistant-design-audit.md index 4eeb54b36..b96d2274b 100644 --- a/docs/explorations/2026-10-18-assistant-design-audit.md +++ b/docs/explorations/2026-10-18-assistant-design-audit.md @@ -154,3 +154,36 @@ The published libraries require Node 24+, so the application engine requirement builder move to Node 24 as well. This is a build-toolchain requirement; the application continues to run in the browser. Model requests, persistence formats and account-free frontend operation are unaffected. + +## History-action affordances (2026-10-19) + +Rechecked against ran main `66a00775755dae6c11bcb59eb227af76e986c7f3`, especially +DESIGN sections 1, 3, 7 and 11 and INTERACTION section 2. The remaining history commands +had transparent, borderless resting surfaces. Hover styling and a pointer cursor did not +make them discoverable before interaction, and red text alone did not give deletion a +clear control boundary. + +Restore/import/export now have visible secondary button surfaces. Deletion commands live +in a separate group with danger outlines; only explicit confirmation receives a solid +danger fill. All states use the existing ran foundation and application danger roles, +including AA text contrast in both themes. Disabled controls keep their geometry and +remain quiet on hover. Native buttons, the existing confirmation flow, storage operations +and focus-restoration ownership remain intact. The wrappers use the installed ranui builder. + +Rendered regressions first caught the missing resting border, then a general hover rule +overriding the danger border. The final cascade owns history states together instead of +leaving later shared rules to override them. Browser checks cover English and Portuguese +at 1280/360/320px, light/dark, hover/press/disabled, Enter/Escape, real JSON download, +focus return and 44px coarse-pointer targets. Safari's native Option-Tab route is tested +with actual focus assertions; pointer activation is not assumed to focus a native button. +See [Apple's keyboard navigation reference](https://support.apple.com/guide/safari/cpsh003/mac). + +Verification on document base `b4c29f64` plus this working-tree change: + +- Full unit suite: **252 files / 6,799 tests passed**. Two non-fatal + `PromiseRejectionHandledWarning` messages were printed by the suite. +- **17 distinct browser cases passed without retries**: 11 Chromium history/workspace + cases and six WebKit/Firefox history-action cases. After correcting the cross-browser + keyboard assumptions, the three Chromium history-action cases were rechecked too. +- Production Vite builds, lint/TypeScript, formatting, all 1,511 immutable evaluation + artifacts and diff checks passed. No library update or persistence change was needed. From 12002592d95423f288cf2aa7ebf0c9e62ee7049b Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 19 Oct 2026 20:08:49 +0800 Subject: [PATCH 4/4] fix(assistant): preserve danger contrast on hover and press --- .../2026-10-18-assistant-design-audit.md | 6 ++++++ styles/assistant.css | 6 ++++++ test/e2e/agent-history-actions.spec.ts | 13 +++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/explorations/2026-10-18-assistant-design-audit.md b/docs/explorations/2026-10-18-assistant-design-audit.md index b96d2274b..ef0462d43 100644 --- a/docs/explorations/2026-10-18-assistant-design-audit.md +++ b/docs/explorations/2026-10-18-assistant-design-audit.md @@ -187,3 +187,9 @@ Verification on document base `b4c29f64` plus this working-tree change: keyboard assumptions, the three Chromium history-action cases were rechecked too. - Production Vite builds, lint/TypeScript, formatting, all 1,511 immutable evaluation artifacts and diff checks passed. No library update or persistence change was needed. + +Pre-PR review additionally reproduced insufficient light-theme outline-delete text +contrast against the neutral hover/pressed fills (4.48:1 / 4.28:1). Those foregrounds +now use the existing danger hover/active roles, while the outline retains its danger +identity. The browser regression checks both commands' actual hover and pressed +contrast rather than requiring an unchanged text colour. diff --git a/styles/assistant.css b/styles/assistant.css index 0c5fb3746..71b66b22c 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -201,6 +201,12 @@ body.agent-panel-resizing #app iframe { color: var(--app-danger-fill); border-color: var(--app-danger-fill); } +.agent-history-options button:is(.agent-history-delete-current, .agent-history-delete-all):enabled:hover { + color: var(--app-danger-hover); +} +.agent-history-options button:is(.agent-history-delete-current, .agent-history-delete-all):enabled:active { + color: var(--app-danger-active); +} .agent-history-options .agent-history-confirm:enabled { color: var(--app-danger-foreground); border-color: var(--app-danger-fill); diff --git a/test/e2e/agent-history-actions.spec.ts b/test/e2e/agent-history-actions.spec.ts index dc76c91c2..6020075d0 100644 --- a/test/e2e/agent-history-actions.spec.ts +++ b/test/e2e/agent-history-actions.spec.ts @@ -112,8 +112,17 @@ for (const scheme of ['light', 'dark'] as const) { await page.mouse.move(0, 0); const restingDanger = await surface(danger); await danger.hover(); - expect((await surface(danger)).color).toBe(restingDanger.color); - expect((await surface(danger)).border).toBe(restingDanger.border); + const dangerHover = await surface(danger); + expect(dangerHover.color).not.toBe(hovering.color); + expect(dangerHover.border).toBe(restingDanger.border); + expect(contrast(dangerHover.color, dangerHover.background)).toBeGreaterThanOrEqual(4.5); + await page.mouse.down(); + const dangerActive = await surface(danger); + expect(dangerActive.color).not.toBe(hovering.color); + expect(dangerActive.border).toBe(restingDanger.border); + expect(contrast(dangerActive.color, dangerActive.background)).toBeGreaterThanOrEqual(4.5); + await page.mouse.move(0, 0); + await page.mouse.up(); } const remove = options.locator('.agent-history-delete-current');