Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/explorations/2026-10-18-assistant-design-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,42 @@ 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.

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.
6 changes: 5 additions & 1 deletion lib/agent-plugin/ui/history-controls.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand Down
73 changes: 55 additions & 18 deletions styles/assistant.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -160,21 +170,60 @@ 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 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);
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,
Expand Down Expand Up @@ -1837,14 +1886,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;
Expand Down Expand Up @@ -2202,7 +2243,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 {
Expand Down Expand Up @@ -2259,11 +2299,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;
Expand All @@ -2282,8 +2321,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);
}
Expand Down
188 changes: 188 additions & 0 deletions test/e2e/agent-history-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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();
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');
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();
});
});
Loading
Loading