From ab83304ff937e9fb05ea57903db351eed09e1ded Mon Sep 17 00:00:00 2001 From: chaxus Date: Fri, 16 Oct 2026 20:21:17 +0800 Subject: [PATCH 1/5] docs: plan assistant workspace token consistency --- ...6-10-16-assistant-workspace-consistency.md | 23 +++++++++++++++ ...-assistant-workspace-consistency-design.md | 29 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-16-assistant-workspace-consistency.md create mode 100644 docs/superpowers/specs/2026-10-16-assistant-workspace-consistency-design.md diff --git a/docs/superpowers/plans/2026-10-16-assistant-workspace-consistency.md b/docs/superpowers/plans/2026-10-16-assistant-workspace-consistency.md new file mode 100644 index 000000000..e5ce4c3f5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-assistant-workspace-consistency.md @@ -0,0 +1,23 @@ +# Assistant workspace consistency implementation plan + +> **For agentic workers:** Use subagent-driven-development with one implementer and separate spec/quality reviews. Preserve multiple commits as authorized by the owner. + +**Goal:** Align AI subpage controls, spacing and navigation using the existing design-system tokens. + +**Architecture:** Host stylesheet owns visual policy. A small view-header composition reuses existing heading/button nodes; each source/artifact/settings/panel owner retains callbacks and state. No shared runtime, persistence or provider contract changes. + +**Tech stack:** TypeScript DOM views, ranui components/public CSS hooks, Vitest, Playwright. + +## Chunk 1: Uniform workspace + +- [x] Inspect deployed pages and code; record measured drift, undefined tokens, consumer boundaries and acceptance in the companion spec. +- [ ] Add failing browser geometry/state tests for Sources/Models/History/Artifacts, native versus ranui 32 px frames, consistent titles/insets/gaps, enabled hover and disabled no-hover, coarse targets and long labels. Add focused tests for header composition and artifact dirty/busy back paths before implementing behavior changes. +- [ ] Consolidate relevant rules in `styles/assistant.css` at their existing owner, replace undefined spacing/hover tokens, use tokenized type/spacing/radius/states, maintain content-sized editors, primary versus ordinary/destructive actions, and touch minima. +- [ ] Add `lib/agent-plugin/ui/view-header.ts`; integrate Sources, Artifacts, Models and History owners without moving or duplicating state. Preserve artifact button registry, labels, dirty confirmation, asynchronous generation guards and focus restoration. Add destination label only where existing vocabulary is insufficient, updating all seven locales and types. +- [ ] Run focused units and browser regression red/green, inspect screenshots and self-review; commit implementation and tests. Spec review, then quality review; resolve concrete findings and reverify. + +## Chunk 2: Acceptance and delivery + +- [ ] Run full unit coverage, lint/types, format and production build; run relevant UI/source/artifact/native browser workflows separately from timing-sensitive unit workloads. +- [ ] Verify light/dark/narrow/coarse/keyboard/long-label states and actual deployed preview. Save a compact screenshot deliverable. +- [ ] Create the follow-up PR because #276 is merged, attach it, wait for all latest-head checks, fix failures and perform a final incremental review. Record authoritative CI/review results in the PR description; do not merge. diff --git a/docs/superpowers/specs/2026-10-16-assistant-workspace-consistency-design.md b/docs/superpowers/specs/2026-10-16-assistant-workspace-consistency-design.md new file mode 100644 index 000000000..00ea713b6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-assistant-workspace-consistency-design.md @@ -0,0 +1,29 @@ +# Assistant workspace consistency + +## Scope and evidence + +The owner requested uniform buttons, spacing and interactions across the AI workspace, explicitly using design-system tokens. PR #276 is already merged; this increment starts from `origin/main` (`2427b27`) and requires a follow-up PR. Prior approval for compact, flat UI and continued implementation remains in force. + +Observed on the deployed preview: Sources commands are 38 px and native fields 40 px; model controls are 32 px; history management commands are 30 px. Sources has a separate back row, Artifacts places its list Back after Import, and model/history navigation relies on the global header. Native workspace headings vary between 16, 16.8 and 20/24 px. Code inspection found undefined `--ran-space-5` and `--ran-color-fill-secondary` usages, removing intended section spacing and hover feedback. + +## Scope lock + +- May change host `styles/assistant.css`, focused UI view composition (`sources-view.ts`, `artifacts-view.ts`, `settings-view.ts`, `panel.ts`, and their label adapter), a small local `view-header.ts` owner, existing/new host unit and browser tests, seven locale tables/types only if a new destination label is required, and design/acceptance docs. +- Keep the shared chat/runtime/provider/queue/storage/OAuth/native document contracts, vendor SDK, global ranui tokens, marketing pages and standalone page CTA scale unchanged. The site's documented 42 px page actions are a deliberate separate context. +- No new consent, implicit send/connect/download/save, destructive behavior or data format. Preserve pending-operation disablement, dirty-artifact confirmation, locale updates, navigation snapshots, IME, callback ownership and offline behavior. + +## Design + +Use existing tokens: `--ran-size-7` for 32 px desktop command frames; `--ran-text-label-3` for compact command labels, `--ran-text-label-2` for fields/list identity, `--ran-text-heading-3` for subpage titles, `--ran-text-label-1` for subsection titles. Use spaces 1/2/3/4/6 for 4/8/12/16/24 px relationships, `--ran-radius-sm` for controls, semantic background/foreground/border/link/danger tokens for every state. Do not invent missing ran tokens or replace them with arbitrary fallbacks. A host semantic control-size alias may combine these values; the existing 44 px coarse-pointer accessibility minimum is the only physical hit-target exception. + +Ordinary commands are quiet ghost buttons with real enabled hover/pressed feedback. Explicit artifact save/accept commitments may use a compact primary treatment; destructive commitments remain clearly named and semantically styled. Disabled commands never look active or gain enabled hover. Resource and artifact rows keep readable identities and content-sized wrapping. Native and ranui fields share frame geometry; text editors/tables retain their content-driven sizes. + +Use one flat header composition for Sources, Models, Conversations and Artifacts. Reuse the owner's actual heading and back button rather than moving navigation state into the helper. Back uses an ordinary accessible button with a small decorative chevron, consistent frame and destination-specific accessible label/tooltip. Each view continues to own its callback, localization and busy/dirty guards. Artifact list returns to conversation; editor/import returns to artifacts and preserves discard confirmation. Keep one back path per state and focus restoration after navigation; no footer Back mixed with save/import/export commands. + +Keep titles, fields, help, lists and statuses on a shared 16 px inset. Use 8 px field/internal gaps, 16 px groups and 24 px sections. Remove invalid-token rules at their owner rather than layering a global override. Disclosures remain progressively revealed, scrollbars stay at the panel edge, and no new nested cards or ambient animation is added. + +## Acceptance + +Measure real browser geometry at 320/432 px panels and narrow windows, light/dark and coarse pointer. Check title/body leading edges, consistent control frames, real hover/pressed/disabled feedback, no overflow with long translations, and keyboard focus/return. Exercise Sources search/preview/attachment/connector failure, both model sources and advanced/offline visibility, history search/data confirmations, artifact list/import/editor/version/dirty Back/refine/export, result actions and queue. Controlled model/auth/voice responses remain explicitly distinct from real OAuth or model-quality validation. + +The minimal alternative is CSS-only resizing; it cannot fix the inconsistent back path and busy/dirty ownership. A new UI framework or global component restyle is unnecessary. Adopt a small host-only header composition and existing view owners, then targeted regressions, full unit/coverage/lint/format/build and relevant browser suites, latest-head remote CI and final review. From a0bb0d3cb976a81f602cd6dae425f3b8224acfee Mon Sep 17 00:00:00 2001 From: chaxus Date: Fri, 16 Oct 2026 20:34:41 +0800 Subject: [PATCH 2/5] fix(assistant): unify workspace controls and navigation with design tokens --- lib/agent-plugin/ui/artifacts-view.ts | 42 ++- lib/agent-plugin/ui/panel.ts | 13 +- lib/agent-plugin/ui/sources-view.ts | 6 +- lib/agent-plugin/ui/view-header.ts | 16 ++ lib/agent-plugin/ui/workspace-labels.ts | 2 + packages/shared/src/i18n/messages/de.ts | 1 + packages/shared/src/i18n/messages/en.ts | 1 + packages/shared/src/i18n/messages/es.ts | 1 + packages/shared/src/i18n/messages/ja.ts | 1 + packages/shared/src/i18n/messages/ko.ts | 1 + packages/shared/src/i18n/messages/pt.ts | 1 + packages/shared/src/i18n/messages/zh-CN.ts | 1 + packages/shared/src/i18n/types.ts | 1 + styles/assistant.css | 253 ++++++++++++++----- test/e2e/agent-sources-artifacts.spec.ts | 2 +- test/e2e/agent-workspace-consistency.spec.ts | 159 ++++++++++++ test/unit/agent-artifacts-view.test.ts | 52 ++++ test/unit/agent-design-tokens.test.ts | 13 + test/unit/agent-view-header.test.ts | 21 ++ 19 files changed, 508 insertions(+), 79 deletions(-) create mode 100644 lib/agent-plugin/ui/view-header.ts create mode 100644 test/e2e/agent-workspace-consistency.spec.ts create mode 100644 test/unit/agent-design-tokens.test.ts create mode 100644 test/unit/agent-view-header.test.ts diff --git a/lib/agent-plugin/ui/artifacts-view.ts b/lib/agent-plugin/ui/artifacts-view.ts index c4fd88735..fa45f5004 100644 --- a/lib/agent-plugin/ui/artifacts-view.ts +++ b/lib/agent-plugin/ui/artifacts-view.ts @@ -1,3 +1,4 @@ +import { createViewHeader, labelViewBack } from './view-header'; import { createArtifactPresentation, type ArtifactPresentationLabels } from './artifact-presentation'; import { validatePayload, payloadText } from '../artifacts/payload'; import { @@ -14,6 +15,8 @@ export interface ArtifactsLabels extends ArtifactPresentationLabels { title: string; empty: string; back: string; + backToConversation?: string; + backToList?: string; titleField: string; textField: string; version: string; @@ -48,9 +51,12 @@ export function createArtifactsView(options: { }) { const el = View('section').class('agent-artifacts').build(); const heading = View('h2').build(); + heading.tabIndex = -1; + const header = createViewHeader(heading); + let backDestination: 'conversation' | 'list' = 'conversation'; const body = Div().build(); const status = View('p').class('agent-artifact-status').attr('role', 'status').build(); - el.append(heading, body, status); + el.append(header, body, status); let labels = options.labels; let current: Artifact | undefined; let activePresentation: ReturnType | undefined; @@ -69,7 +75,10 @@ export function createArtifactsView(options: { let cancelConfirmation: (() => void) | undefined; const buttons = new Set(); function button(key: keyof ArtifactsLabels, run: () => void): HTMLButtonElement { - const b = ButtonBuilder().attr('type', 'button').text(labels[key]).build(); + const b = ButtonBuilder() + .attr('type', 'button') + .text(labels[key] ?? '') + .build(); b.dataset.action = key; b.addEventListener('click', () => { if (key !== 'discardChanges' && key !== 'cancel' && cancelConfirmation) { @@ -82,6 +91,14 @@ export function createArtifactsView(options: { buttons.add(b); return b; } + function setBack(back: HTMLButtonElement, destination: 'conversation' | 'list'): void { + backDestination = destination; + labelViewBack( + back, + (destination === 'conversation' ? labels.backToConversation : labels.backToList) ?? labels.back, + ); + header.prepend(back); + } function draftContent(text: string) { const version = current?.versions.find( (v) => String(v.version) === body.querySelector('select')?.value, @@ -109,7 +126,7 @@ export function createArtifactsView(options: { invalid: 'importHint', }[e.code] as keyof ArtifactsLabels) : 'failed'; - status.textContent = labels[key]; + status.textContent = labels[key] ?? labels.failed; if (e instanceof ArtifactError && (e.code === 'version-limit' || e.code === 'conflict')) { status.append( ' ', @@ -165,6 +182,7 @@ export function createArtifactsView(options: { cancelConfirmation?.(); cancelConfirmation = undefined; buttons.clear(); + header.replaceChildren(heading); body.replaceChildren(); status.textContent = ''; heading.textContent = labels.title; @@ -201,6 +219,7 @@ export function createArtifactsView(options: { void refresh(); }), ); + setBack(back, 'list'); const title = View('input') .attr('type', 'text') .attr('maxlength', '200') @@ -317,7 +336,7 @@ export function createArtifactsView(options: { .children(title, select, ...(source.hidden ? [text] : []), presentationHost, source, origin, actions) .build(); if (presentation.kind !== 'text') form.insertBefore(actions, presentationHost); - body.append(back, form); + body.append(form); const applyButton = actions.querySelector('[data-action=apply]')!; applyButton.hidden = !!a.versions.at(-1)!.payload && a.versions.at(-1)!.payload!.kind !== 'text'; captureDraft = () => @@ -456,12 +475,15 @@ export function createArtifactsView(options: { status.textContent = labels.saved; }); }), + ); + setBack( button('back', () => discard(() => { dirty = false; void refresh(); }), ), + 'list', ); data.addEventListener('input', () => { dirty = true; @@ -474,8 +496,8 @@ export function createArtifactsView(options: { ), ); - if (options.onClose) body.append(button('back', options.onClose)); - if (el.isConnected) body.querySelector('button')?.focus(); + if (options.onClose) setBack(button('back', options.onClose), 'conversation'); + if (el.isConnected) heading.focus(); } catch (e) { if (!disposed) error(e); } @@ -570,7 +592,12 @@ export function createArtifactsView(options: { if (sourceSummary) sourceSummary.textContent = labels.textField + ' · JSON'; for (const b of buttons) { const key = b.dataset.action as keyof ArtifactsLabels; - if (!b.dataset.artifact) b.textContent = labels[key]; + if (key === 'back') { + labelViewBack( + b, + (backDestination === 'conversation' ? labels.backToConversation : labels.backToList) ?? labels.back, + ); + } else if (!b.dataset.artifact) b.textContent = labels[key] ?? ''; } for (const option of body.querySelectorAll('option')) option.textContent = `${labels.version} ${option.value}`; const origin = body.querySelector('.agent-artifact-provenance'); @@ -594,6 +621,7 @@ export function createArtifactsView(options: { for (const settle of pendingSaves) settle('cancelled'); ++generation; buttons.clear(); + header.replaceChildren(heading); el.remove(); }, get current() { diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 5e935b114..b63f91401 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -1,3 +1,4 @@ +import { createViewHeader, labelViewBack } from './view-header'; import { createTaskReceipt } from './task-receipt'; import { createResultActions } from './result-actions'; import { createWorkspaceNavigation } from './workspace-navigation'; @@ -3263,6 +3264,10 @@ export function createAgentPanel( }); const { settingsHeading, modelActions, localCard, serviceCard, advanced, settingsHero, sourcePicker, offlineHint } = settingsView; + const settingsBack = document.createElement('button'); + settingsBack.type = 'button'; + settingsBack.addEventListener('click', () => showView('chat')); + settingsHero.replaceChildren(createViewHeader(settingsHeading, settingsBack)); syncOfflineSettings = () => { settingsView.setOnline(navigator.onLine !== false); ggufUrl.hidden = navigator.onLine === false; @@ -3338,7 +3343,11 @@ export function createAgentPanel( }, onDelete: (id, trigger) => historyControls?.requestDelete(id, trigger), }); - historyView.append(conversationList.el, historyControls.el); + const historyBack = document.createElement('button'); + historyBack.type = 'button'; + historyBack.addEventListener('click', () => showView('chat')); + const historyHeading = conversationList.el.querySelector('h2')!; + historyView.append(createViewHeader(historyHeading, historyBack), conversationList.el, historyControls.el); header.insertBefore(artifactsBtn, settingsBtn); header.insertBefore(historyBtn, settingsBtn); header.insertBefore(clearBtn, settingsBtn); @@ -3396,6 +3405,8 @@ export function createAgentPanel( }); const syncProductLabels = () => { settingsView.syncLabels(); + labelViewBack(settingsBack, t('agentBackToChat')); + labelViewBack(historyBack, t('agentBackToChat')); sourceView.setLabels(sourcesLabels()); artifactsView.setLabels(artifactLabels()); artifactsView.setRefinementLabels(artifactWorkflowLabels()); diff --git a/lib/agent-plugin/ui/sources-view.ts b/lib/agent-plugin/ui/sources-view.ts index 916733d14..c2d2c02ab 100644 --- a/lib/agent-plugin/ui/sources-view.ts +++ b/lib/agent-plugin/ui/sources-view.ts @@ -1,3 +1,4 @@ +import { createViewHeader, labelViewBack } from './view-header'; import { sourcePreview } from './source-preview'; import { sourceMatches } from '../connectors/reference-search'; import { renderSearchText } from './search-highlight'; @@ -223,8 +224,7 @@ export function createSourcesView(options: { .children(driveHeading, driveHint, drive, driveSearch, driveList, driveDisconnect, driveRows, more) .build(); el.append( - back, - heading, + createViewHeader(heading, back), excerpt, offlineHint, status, @@ -438,7 +438,7 @@ export function createSourcesView(options: { offlineHint.textContent = labels.offline; syncConnectivity(); excerpt.textContent = labels.excerpt; - back.textContent = labels.close; + labelViewBack(back, labels.close); localHeading.textContent = labels.local; attachFile.textContent = labels.attach; syncConnectionActions(); diff --git a/lib/agent-plugin/ui/view-header.ts b/lib/agent-plugin/ui/view-header.ts new file mode 100644 index 000000000..07c0a38f2 --- /dev/null +++ b/lib/agent-plugin/ui/view-header.ts @@ -0,0 +1,16 @@ +/** Flat view chrome: callers retain navigation, labels and operation ownership. */ +export function createViewHeader(heading: HTMLElement, back?: HTMLButtonElement): HTMLElement { + const header = document.createElement('div'); + header.className = 'agent-view-header'; + if (back) header.append(back); + header.append(heading); + return header; +} + +export function labelViewBack(back: HTMLButtonElement, destination: string): void { + back.classList.add('agent-view-back'); + back.setAttribute('aria-label', destination); + back.title = destination; + back.innerHTML = + ''; +} diff --git a/lib/agent-plugin/ui/workspace-labels.ts b/lib/agent-plugin/ui/workspace-labels.ts index ad016ec6a..3549f27f8 100644 --- a/lib/agent-plugin/ui/workspace-labels.ts +++ b/lib/agent-plugin/ui/workspace-labels.ts @@ -42,6 +42,8 @@ export function artifactLabels(): ArtifactsLabels { title: t('agentArtifactTitle'), empty: t('agentArtifactEmpty'), back: t('agentArtifactBack'), + backToConversation: t('agentBackToChat'), + backToList: t('agentArtifactBackToList'), titleField: t('agentArtifactTitleField'), textField: t('agentArtifactTextField'), version: t('agentArtifactVersion'), diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts index 78bce6c82..1a9c6b212 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -538,6 +538,7 @@ export const de: Partial = { agentArtifactTitle: 'Artefakte', agentArtifactEmpty: 'Nützliche Ergebnisse hier speichern, bearbeiten und lokal versionieren.', agentArtifactBack: 'Zurück', + agentArtifactBackToList: 'Zurück zu den Ergebnissen', agentArtifactTitleField: 'Titel', agentArtifactTextField: 'Inhalt', agentArtifactVersion: 'Version', diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index eafc2caf0..f9152dcdc 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -515,6 +515,7 @@ export const en: I18nMessages = { agentArtifactTitle: 'Artifacts', agentArtifactEmpty: 'Save useful results here to edit and keep versions on this device.', agentArtifactBack: 'Back', + agentArtifactBackToList: 'Back to artifacts', agentArtifactTitleField: 'Title', agentArtifactTextField: 'Content', agentArtifactVersion: 'Version', diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts index bb45b02eb..69523e268 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -525,6 +525,7 @@ export const es: Partial = { agentArtifactTitle: 'Artefactos', agentArtifactEmpty: 'Guarda resultados aquí para editarlos y conservar versiones en este dispositivo.', agentArtifactBack: 'Volver', + agentArtifactBackToList: 'Volver a los resultados', agentArtifactTitleField: 'Título', agentArtifactTextField: 'Contenido', agentArtifactVersion: 'Versión', diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index 830be4783..f490a84db 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -518,6 +518,7 @@ export const ja: Partial = { agentArtifactTitle: '成果物', agentArtifactEmpty: '結果を保存して、この端末で編集・バージョン管理できます。', agentArtifactBack: '戻る', + agentArtifactBackToList: '成果物一覧に戻る', agentArtifactTitleField: 'タイトル', agentArtifactTextField: '内容', agentArtifactVersion: 'バージョン', diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts index 8b03595ad..023c66cf0 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -505,6 +505,7 @@ export const ko: Partial = { agentArtifactTitle: '아티팩트', agentArtifactEmpty: '결과를 저장하고 이 기기에서 편집하며 버전을 보관하세요.', agentArtifactBack: '뒤로', + agentArtifactBackToList: '결과물 목록으로 돌아가기', agentArtifactTitleField: '제목', agentArtifactTextField: '내용', agentArtifactVersion: '버전', diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index 462d16134..8d732670e 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -520,6 +520,7 @@ export const pt: Partial = { agentArtifactTitle: 'Artefatos', agentArtifactEmpty: 'Salve resultados aqui para editar e manter versões neste dispositivo.', agentArtifactBack: 'Voltar', + agentArtifactBackToList: 'Voltar aos resultados', agentArtifactTitleField: 'Título', agentArtifactTextField: 'Conteúdo', agentArtifactVersion: 'Versão', diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index 57ff9014d..2de2c85e6 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -488,6 +488,7 @@ export const zhCN: I18nMessages = { agentArtifactTitle: '成果', agentArtifactEmpty: '将有用的结果保存在这里,在此设备编辑并保留版本。', agentArtifactBack: '返回', + agentArtifactBackToList: '返回成果列表', agentArtifactTitleField: '标题', agentArtifactTextField: '内容', agentArtifactVersion: '版本', diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts index 207a6312d..0ce6afdbe 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -503,6 +503,7 @@ export interface I18nMessages { agentArtifactTitle: string; agentArtifactEmpty: string; agentArtifactBack: string; + agentArtifactBackToList: string; agentArtifactTitleField: string; agentArtifactTextField: string; agentArtifactVersion: string; diff --git a/styles/assistant.css b/styles/assistant.css index 8a3f11cbc..abbc67895 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -114,7 +114,9 @@ body.agent-panel-resizing #app iframe { display: flex; align-items: center; justify-content: space-between; - padding: var(--ran-space-2) 0; + box-sizing: border-box; + min-height: var(--ran-size-7); + padding: var(--ran-space-1) 0; color: var(--ran-color-text-secondary); font-weight: var(--ran-text-label-weight); cursor: pointer; @@ -159,14 +161,16 @@ body.agent-panel-resizing #app iframe { flex-basis: 100%; } .agent-history-options button { - min-height: var(--ran-size-6); + box-sizing: border-box; + min-height: var(--ran-size-7); max-width: 100%; padding: var(--ran-space-1) var(--ran-space-2); - border: 1px solid var(--ran-color-border-secondary); + border: 0; border-radius: var(--ran-radius-sm); - background: var(--ran-color-bg-elevated); + background: transparent; color: var(--ran-color-text); font: inherit; + font-size: var(--ran-text-label-3); overflow-wrap: anywhere; cursor: pointer; } @@ -797,7 +801,7 @@ r-dropdown[role='listbox'] { overflow: auto; flex: 1 1 auto; max-height: none; - padding: 16px; + padding: var(--ran-space-4); gap: var(--ran-space-4); border-bottom: 0; scrollbar-width: thin; @@ -869,8 +873,10 @@ r-dropdown[role='listbox'] { justify-content: space-between; align-items: center; list-style: none; - padding: 12px 0; - font-weight: 500; + box-sizing: border-box; + min-height: var(--ran-size-7); + padding: var(--ran-space-1) 0; + font-weight: var(--ran-text-label-weight); } .agent-preferences > :not(summary) { margin-top: 16px; @@ -879,7 +885,7 @@ r-dropdown[role='listbox'] { flex: 1; min-height: 0; overflow: auto; - padding: var(--ran-space-6) var(--ran-space-4); + padding: var(--ran-space-4); } .agent-welcome { padding: 40px 28px; @@ -1073,8 +1079,8 @@ r-dropdown[role='listbox'] { display: flex; flex-direction: column; min-width: 0; - gap: 8px; - font-size: 12px; + gap: var(--ran-space-2); + font-size: var(--ran-text-label-3); line-height: 1.6; color: var(--ran-color-text-secondary); } @@ -1090,32 +1096,52 @@ r-dropdown[role='listbox'] { .agent-panel-settings r-select { width: 100%; min-width: 0; - --ran-input-border-radius: 8px; - --ran-input-min-height: 32px; - --ran-input-content-min-height: 30px; - --ran-input-content-font-size: 13px; - --ran-input-content-padding: 0 12px; - --ran-select-border-radius: 8px; - --ran-select-height: 32px; - --ran-select-font-size: 13px; + --ran-input-border-radius: var(--ran-radius-sm); + --ran-input-min-height: var(--ran-size-7); + --ran-input-content-min-height: calc(var(--ran-size-7) - 2px); + --ran-input-content-font-size: var(--ran-text-label-2); + --ran-input-content-padding: 0 var(--ran-space-3); + --ran-select-border-radius: var(--ran-radius-sm); + --ran-select-height: var(--ran-size-7); + --ran-select-font-size: var(--ran-text-label-2); } .agent-panel-settings r-button { --ran-btn-padding: 0; } .agent-panel-settings r-button::part(content) { box-sizing: border-box; - min-height: 32px; + min-height: var(--ran-size-7); display: flex; align-items: center; - font-size: 12px; - padding: 5px 10px; + font-size: var(--ran-text-label-3); + padding: var(--ran-space-1) var(--ran-space-3); + border-color: transparent; + background: transparent; + color: var(--ran-color-text); } -.agent-panel-load::part(content), -.agent-panel-endpoint-connect::part(content) { +.agent-panel-settings r-button:not([disabled]):hover::part(content) { + background: var(--ran-color-bg-hover); +} +.agent-panel-settings r-button:not([disabled]):active::part(content) { + background: var(--ran-color-bg-active); +} +.agent-panel-settings .agent-panel-load::part(content), +.agent-panel-settings .agent-panel-endpoint-connect::part(content) { border-color: var(--ran-color-primary); background: var(--ran-color-primary); color: var(--ran-color-primary-text); } +.agent-panel-settings :is(.agent-panel-load, .agent-panel-endpoint-connect):not([disabled]):hover::part(content) { + background: var(--ran-color-primary-hover); +} +.agent-panel-settings :is(.agent-panel-load, .agent-panel-endpoint-connect):not([disabled]):active::part(content) { + background: var(--ran-color-primary-active); +} +.agent-panel-settings r-button[disabled]::part(content) { + border-color: transparent; + color: var(--ran-color-text-disabled); + background: transparent; +} .agent-panel-endpoint-connect { align-self: flex-start; } @@ -1154,15 +1180,6 @@ r-dropdown[role='listbox'] { .agent-panel-settings > .agent-panel-note { margin: -8px 0 0; } -@container agent-panel (max-width: 370px) { - .agent-panel-settings { - padding: 16px; - } - - .agent-settings-hero h2 { - font-size: 24px; - } -} /* Compact model settings: one source and one active configuration. */ .agent-source-picker { @@ -1184,7 +1201,7 @@ r-dropdown[role='listbox'] { } .agent-model-cache > summary { cursor: pointer; - min-height: 32px; + min-height: var(--ran-size-7); display: flex; align-items: center; font-size: var(--ran-text-label-2); @@ -1386,7 +1403,7 @@ r-dropdown[role='listbox'] { --ran-select-background-color: transparent; --ran-select-border: 1px solid transparent; --ran-select-hover-border: 1px solid transparent; - --ran-select-height: 32px; + --ran-select-height: var(--ran-size-7); --ran-select-min-height: 32px; --ran-select-font-size: var(--ran-text-label-3); --ran-select-color: var(--ran-color-text-secondary); @@ -1662,7 +1679,9 @@ r-dropdown[role='listbox'] { display: flex; align-items: center; gap: var(--ran-space-2); - padding: var(--ran-space-2) var(--ran-space-3); + box-sizing: border-box; + min-height: var(--ran-size-7); + padding: var(--ran-space-1) var(--ran-space-2); border: 1px solid var(--ran-color-border); border-radius: var(--ran-radius-sm); color: var(--ran-color-text-secondary); @@ -1680,7 +1699,7 @@ r-dropdown[role='listbox'] { background: transparent; color: var(--ran-color-text); font: inherit; - font-size: var(--ran-text-label-1); + font-size: var(--ran-text-label-2); line-height: var(--ran-line-height); } .agent-history-search:focus { @@ -1802,7 +1821,7 @@ r-dropdown[role='listbox'] { margin-top: var(--ran-space-3); } .agent-welcome-hint { - margin: 0 0 var(--ran-space-5); + margin: 0 0 var(--ran-space-6); font-size: 13px; line-height: 1.65; } @@ -2058,7 +2077,7 @@ r-dropdown[role='listbox'] { .agent-search-match { color: inherit; background: color-mix(in srgb, var(--ran-color-primary) 18%, transparent); - border-radius: var(--ran-radius-xs, 2px); + border-radius: var(--ran-radius-sm); font-weight: 600; padding: 0; } @@ -2100,22 +2119,22 @@ r-dropdown[role='listbox'] { } .agent-workspace-view h2, .agent-artifacts h2 { - margin: var(--ran-space-3) 0 var(--ran-space-5); - font-size: 1.2em; + margin: 0; + font-size: var(--ran-text-heading-3); } .agent-workspace-view h3 { - margin: var(--ran-space-5) 0 var(--ran-space-2); - font-size: 1em; - font-weight: 600; + margin: var(--ran-space-6) 0 var(--ran-space-2); + font-size: var(--ran-text-label-1); + font-weight: var(--ran-text-heading-weight); } .agent-workspace-view label, .agent-artifact-editor label { display: flex; flex-direction: column; gap: var(--ran-space-2); - margin-block: var(--ran-space-3); + margin-block: var(--ran-space-4); } -.agent-workspace-view input, +.agent-workspace-view input:not([type='checkbox']):not([type='radio']):not([type='file']), .agent-artifact-editor input, .agent-artifact-editor textarea, .agent-artifact-editor select { @@ -2127,7 +2146,9 @@ r-dropdown[role='listbox'] { background: transparent; border: 1px solid var(--ran-color-border); border-radius: var(--ran-radius-sm); - padding: var(--ran-space-2); + padding: var(--ran-space-1) var(--ran-space-2); + min-height: var(--ran-size-7); + font-size: var(--ran-text-label-2); } .agent-artifact-editor textarea { min-height: 16em; @@ -2138,12 +2159,18 @@ r-dropdown[role='listbox'] { .agent-artifacts button, .agent-sources-toggle, .agent-workspace-toggle { + box-sizing: border-box; + max-width: 100%; color: inherit; background: transparent; border: 0; + border-radius: var(--ran-radius-sm); font: inherit; - padding: var(--ran-space-2); - min-height: 36px; + font-size: var(--ran-text-label-3); + line-height: var(--ran-line-height); + overflow-wrap: anywhere; + padding: var(--ran-space-1) var(--ran-space-2); + min-height: var(--ran-size-7); cursor: pointer; text-align: start; } @@ -2152,12 +2179,17 @@ r-dropdown[role='listbox'] { padding: var(--ran-space-1) var(--ran-space-2); font-size: var(--ran-text-label-3); } -.agent-workspace-view button:hover, -.agent-artifacts button:hover, -.agent-sources-toggle:hover, -.agent-workspace-toggle:hover { - background: var(--ran-color-fill-secondary); +.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 { + background: var(--ran-color-bg-hover); } +.agent-workspace-view input:focus-visible, +.agent-artifact-editor select:focus-visible, +.agent-artifact-workflow input:focus-visible, .agent-workspace-view button:focus-visible, .agent-artifacts button:focus-visible, .agent-sources-toggle:focus-visible, @@ -2165,6 +2197,103 @@ r-dropdown[role='listbox'] { outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } +/* View owners keep navigation callbacks; the chrome shares one token rhythm. */ +.agent-panel .agent-view-header { + display: flex; + align-items: center; + gap: var(--ran-space-2); + margin-block-end: var(--ran-space-4); + min-width: 0; +} +.agent-panel .agent-view-header h2 { + margin: 0; + min-width: 0; + font-size: var(--ran-text-heading-3); + font-weight: var(--ran-text-heading-weight); + letter-spacing: var(--ran-text-heading-tracking); + line-height: 1.3; + overflow-wrap: anywhere; +} +.agent-settings-hero .agent-view-header { + margin-block-end: 0; +} +.agent-panel .agent-view-back { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + flex: 0 0 auto; + min-width: var(--ran-size-7); + min-height: var(--ran-size-7); + padding: var(--ran-space-2); + border: 0; + border-radius: var(--ran-radius-sm); + color: var(--ran-color-text-secondary); + background: transparent; + cursor: pointer; +} +.agent-panel .agent-view-back:focus-visible { + outline: 2px solid var(--ran-color-primary); + outline-offset: 2px; +} +.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 { + color: var(--ran-color-text-disabled); + cursor: default; + background: transparent; +} +.agent-artifacts button:is([data-action='saveVersion'], [data-action='saveNew'], [data-action='accept']):enabled { + background: var(--ran-color-primary); + color: var(--ran-color-primary-text); +} +.agent-artifacts button:is([data-action='saveVersion'], [data-action='saveNew'], [data-action='accept']):hover:enabled { + background: var(--ran-color-primary-hover); +} +.agent-artifacts + button:is([data-action='saveVersion'], [data-action='saveNew'], [data-action='accept']):active:enabled { + background: 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); +} +.agent-artifacts > div > p { + font-size: var(--ran-text-label-3); + color: var(--ran-color-text-secondary); + line-height: 1.6; +} +.agent-artifacts-list button { + font-size: var(--ran-text-label-2); +} +@media (pointer: coarse) { + .agent-panel :is(.agent-workspace-view, .agent-artifacts, .agent-history-options, .agent-view-header) button { + min-height: 44px; + min-width: 44px; + } + .agent-workspace-view input:not([type='checkbox']):not([type='radio']):not([type='file']), + .agent-artifact-editor > select, + .agent-artifact-editor > input, + .agent-artifact-workflow form input, + .agent-history-search-row { + min-height: 44px; + } + .agent-panel-settings :is(r-input, r-select) { + --ran-input-min-height: 44px; + --ran-input-content-min-height: 42px; + --ran-select-height: 44px; + } + .agent-panel-settings r-button::part(content), + .agent-panel .agent-disclosure > summary, + .agent-history-management > summary { + min-height: 44px; + } +} .agent-source-row, .agent-artifact-actions { display: flex; @@ -2234,10 +2363,7 @@ r-dropdown[role='listbox'] { color: var(--ran-color-primary); font-weight: 600; } -.agent-artifact-actions button:disabled { - opacity: 0.45; - cursor: default; -} + /* The language picker yields space before it can cover Stop/Send. */ .agent-speech-language { flex: 1 1 96px; @@ -2274,7 +2400,7 @@ r-dropdown[role='listbox'] { } .agent-source-settings summary { cursor: pointer; - padding-block: var(--ran-space-2); + padding-block: var(--ran-space-1); } .agent-source-settings summary h3 { display: inline; @@ -2418,14 +2544,7 @@ r-dropdown[role='listbox'] { background: color-mix(in srgb, var(--ran-color-primary) 12%, transparent); text-decoration: none; } -.agent-artifact-workflow button[data-action='accept'] { - color: var(--ran-color-primary); - font-weight: 600; -} -.agent-artifact-workflow button:disabled { - opacity: 0.45; - cursor: default; -} + .agent-artifact-scope { flex-basis: 100%; margin: 0; @@ -2437,7 +2556,7 @@ r-dropdown[role='listbox'] { .agent-artifact-data-preview { overflow-x: auto; max-width: 100%; - font-size: var(--ran-font-size-small, 12px); + font-size: var(--ran-text-label-3); } .agent-artifact-data-preview > p { color: var(--ran-color-text-secondary); diff --git a/test/e2e/agent-sources-artifacts.spec.ts b/test/e2e/agent-sources-artifacts.spec.ts index 12f88b526..29f25e371 100644 --- a/test/e2e/agent-sources-artifacts.spec.ts +++ b/test/e2e/agent-sources-artifacts.spec.ts @@ -586,7 +586,7 @@ test('structured results validate explicitly, preview compactly and export witho .toBeLessThan(2); await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`structured-${theme}.png`) }); } - await page.locator('.agent-artifacts').getByRole('button', { name: 'Back', exact: true }).click(); + await page.locator('.agent-artifacts').getByRole('button', { name: 'Back to artifacts', exact: true }).click(); await page.getByRole('button', { name: 'Import structured result', exact: true }).click(); await page.locator('.agent-artifact-editor input').fill('Release slides'); await page.locator('.agent-artifact-editor textarea').fill( diff --git a/test/e2e/agent-workspace-consistency.spec.ts b/test/e2e/agent-workspace-consistency.spec.ts new file mode 100644 index 000000000..f38a79249 --- /dev/null +++ b/test/e2e/agent-workspace-consistency.spec.ts @@ -0,0 +1,159 @@ +import { expect, test } from './lib/l0'; + +test('workspace pages share compact token geometry and a keyboard return path', async ({ page }, testInfo) => { + test.setTimeout(90000); + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.cui-input').fill('Keep this draft'); + await page.locator('.cui-input').evaluate((el: HTMLTextAreaElement) => el.setSelectionRange(2, 6)); + await page.locator('.agent-sources-toggle').click(); + const source = page.locator('.agent-sources-view'); + const search = source.getByRole('searchbox', { name: 'Search sources', exact: true }); + await expect(search).toBeVisible(); + expect((await search.boundingBox())!.height).toBe(32); + const section = source.getByRole('heading', { name: 'Local files', exact: true }); + expect( + (await section.boundingBox())!.y - ((await search.boundingBox())!.y + (await search.boundingBox())!.height), + ).toBeGreaterThanOrEqual(24); + expect((await source.getByRole('button', { name: 'Back to conversation', exact: true }).boundingBox())!.height).toBe( + 32, + ); + for (const width of [1280, 360, 320]) { + await page.setViewportSize({ width, height: 800 }); + for (const scheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme: scheme }); + for (const [open, view] of [ + ['.agent-sources-toggle', '.agent-sources-view'], + ['.agent-panel-settings-toggle', '.agent-panel-settings'], + ['.agent-history-toggle', '.agent-history-view'], + ['.agent-workspace-toggle', '.agent-artifacts'], + ] as const) { + if (!(await page.locator(view).isVisible())) await page.locator(open).click(); + const current = page.locator(view); + const header = current.locator('.agent-view-header'); + await expect(header).toBeVisible(); + expect(await header.locator('h2').evaluate((el) => getComputedStyle(el).fontSize)).toBe('20px'); + await expect(current.locator('.agent-view-back')).toHaveCount(1); + if (view === '.agent-history-view') { + expect((await current.locator('.agent-history-search-row').boundingBox())!.height).toBe(32); + } + if (view === '.agent-panel-settings') { + expect((await current.locator('.agent-source-picker').boundingBox())!.height).toBe(32); + for (const field of await current.locator('r-input:visible, r-select:visible').all()) { + expect((await field.boundingBox())!.height).toBe(32); + } + } + for (const summary of await current.locator('details > summary:visible').all()) { + expect((await summary.boundingBox())!.height).toBe(32); + } + const back = header.getByRole('button', { name: 'Back to conversation', exact: true }); + expect((await back.boundingBox())!.height).toBe(32); + expect(await current.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); + await page + .locator('.agent-runtime-panel') + .screenshot({ path: testInfo.outputPath(`${view.slice(1)}-${width}-${scheme}.png`) }); + await back.focus(); + await back.press('Enter'); + await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); + expect( + await page.locator('.cui-root, .agent-runtime-panel').evaluateAll((els) => { + const active = document.activeElement; + return els.some((el) => !!active && el.contains(active) && active.closest('.cui-messages, .cui-input')); + }), + ).toBe(true); + await expect(page.locator('.cui-input')).toHaveValue('Keep this draft'); + expect( + await page.locator('.cui-input').evaluate((el: HTMLTextAreaElement) => [el.selectionStart, el.selectionEnd]), + ).toEqual([2, 6]); + } + } + } + await page.goto('/editor?new=docx&locale=pt'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-sources-toggle').click(); + const translated = page.locator('.agent-sources-view'); + await expect(translated.locator('.agent-view-back')).toHaveAttribute('aria-label', /.+/); + expect(await translated.locator('.agent-view-back').getAttribute('aria-label')).not.toBe('Back to conversation'); + expect(await translated.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); + for (const button of await translated.locator('button:visible').all()) { + expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(32); + expect(await button.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); + } +}); + +test('artifact commands distinguish disabled, ordinary and explicit save states', async ({ page }) => { + test.setTimeout(90000); + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-workspace-toggle').click(); + await page.locator('.agent-artifacts [data-action=import]').click(); + const artifacts = page.locator('.agent-artifacts'); + await expect(artifacts.getByRole('button', { name: 'Back to artifacts', exact: true })).toBeVisible(); + await artifacts.getByRole('textbox', { name: 'Title', exact: true }).fill('Table'); + await artifacts + .locator('textarea') + .fill(JSON.stringify({ schemaVersion: 1, payload: { kind: 'table', columns: ['Name'], rows: [['A']] } })); + await artifacts.locator('[data-action=saveNew]').click(); + await expect(artifacts.locator('.agent-artifact-status')).toHaveText('Saved on this device'); + const save = artifacts.locator('[data-action=saveVersion]'); + await expect(save).toBeDisabled(); + const disabled = await save.evaluate((el) => getComputedStyle(el).backgroundColor); + await save.hover(); + expect(await save.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe(disabled); + await artifacts.getByRole('textbox', { name: 'Title', exact: true }).fill('Renamed table'); + await expect(save).toBeEnabled(); + expect(await save.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(disabled); + const ordinary = artifacts.locator('[data-action=delete]'); + const idle = await ordinary.evaluate((el) => getComputedStyle(el).backgroundColor); + await ordinary.hover(); + expect(await ordinary.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(idle); + expect((await ordinary.boundingBox())!.height).toBe(32); + const back = artifacts.getByRole('button', { name: 'Back to artifacts', exact: true }); + await back.click(); + await expect(artifacts.locator('[data-action=discardChanges]')).toBeVisible(); + await artifacts.locator('[data-action=cancel]').click(); + await expect(artifacts.getByRole('textbox', { name: 'Title', exact: true })).toHaveValue('Renamed table'); +}); + +test.describe('coarse pointer workspace', () => { + test.use({ hasTouch: true, viewport: { width: 360, height: 800 } }); + test('keeps physical command targets while retaining compact visual hierarchy', async ({ page }) => { + test.setTimeout(90000); + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-sources-toggle').click(); + const source = page.locator('.agent-sources-view'); + const back = source.locator('.agent-view-back'); + expect((await back.boundingBox())!.height).toBeGreaterThanOrEqual(44); + expect((await back.boundingBox())!.width).toBeGreaterThanOrEqual(44); + expect( + (await source.getByRole('searchbox', { name: 'Search sources', exact: true }).boundingBox())!.height, + ).toBeGreaterThanOrEqual(44); + for (const button of await source.locator('button:visible').all()) { + expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(44); + } + expect(await source.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); + await back.click(); + await page.locator('.agent-panel-settings-toggle').click(); + expect((await page.locator('.agent-source-picker').boundingBox())!.height).toBeGreaterThanOrEqual(44); + for (const summary of await page.locator('.agent-panel-settings details > summary:visible').all()) { + expect((await summary.boundingBox())!.height).toBeGreaterThanOrEqual(44); + } + }); +}); diff --git a/test/unit/agent-artifacts-view.test.ts b/test/unit/agent-artifacts-view.test.ts index 3a0176e94..65bea68dd 100644 --- a/test/unit/agent-artifacts-view.test.ts +++ b/test/unit/agent-artifacts-view.test.ts @@ -721,3 +721,55 @@ it('retains imported text payload envelopes when editing a version', async () => expect((await store.get(a.id))?.versions[1].payload).toEqual({ kind: 'text', text: 'two' }); view.dispose(); }); + +it('keeps destination-aware header Back in the busy registry and guards imported drafts', async () => { + const store = createArtifactStore(); + const a = await store.create({ title: 'Draft', text: 'original', provenance: {} }); + const close = vi.fn(); + const view = createArtifactsView({ + store, + labels: { + ...labels, + import: 'Import', + backToConversation: 'Back to conversation', + backToList: 'Back to artifacts', + }, + onApply: vi.fn(), + onClose: close, + }); + document.body.append(view.el); + await view.refresh(); + const back = () => view.el.querySelector('.agent-view-header [data-action=back]')!; + expect(back().getAttribute('aria-label')).toBe('Back to conversation'); + expect(view.el.querySelectorAll('[data-action=back]')).toHaveLength(1); + let resolve!: (value: typeof a) => void; + vi.spyOn(store, 'get').mockImplementationOnce(() => new Promise((r) => (resolve = r))); + view.el.querySelector('[data-artifact]')!.click(); + expect(back().disabled).toBe(true); + back().click(); + expect(close).not.toHaveBeenCalled(); + resolve(a); + await tick(); + expect(back().disabled).toBe(false); + expect(back().getAttribute('aria-label')).toBe('Back to artifacts'); + view.setLabels({ ...labels, backToList: '返回成果列表' }); + expect(back().getAttribute('aria-label')).toBe('返回成果列表'); + expect(back().querySelectorAll('svg')).toHaveLength(1); + back().click(); + await tick(); + expect(document.activeElement).toBe(view.el.querySelector('h2')); + view.el.querySelector('[data-action=import]')!.click(); + const data = view.el.querySelector('textarea')!; + data.value = 'unfinished import'; + data.dispatchEvent(new Event('input')); + back().click(); + expect(view.el.querySelector('textarea')).toBe(data); + view.el.querySelector('[data-action=cancel]')!.click(); + expect(data.value).toBe('unfinished import'); + back().click(); + view.el.querySelector('[data-action=discardChanges]')!.click(); + await tick(); + expect(view.el.querySelector('textarea')).toBeNull(); + expect(view.el.querySelectorAll('[data-action=back]')).toHaveLength(1); + view.dispose(); +}); diff --git a/test/unit/agent-design-tokens.test.ts b/test/unit/agent-design-tokens.test.ts new file mode 100644 index 000000000..c7a08a77d --- /dev/null +++ b/test/unit/agent-design-tokens.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; +import { expect, it } from 'vitest'; + +it('resolves every assistant design-token role against the installed foundation or host hooks', () => { + const host = readFileSync('styles/assistant.css', 'utf8'); + const foundation = readFileSync('public/ran-tokens.css', 'utf8'); + const definitions = new Set([...`${foundation}\n${host}`.matchAll(/(--ran-[\w-]+)\s*:/g)].map((m) => m[1])); + const missing = [...new Set([...host.matchAll(/var\((--ran-[\w-]+)/g)].map((m) => m[1]))].filter( + (name) => !definitions.has(name), + ); + // Fallbacks cannot silently conceal a misspelled or removed design role. + expect(missing).toEqual([]); +}); diff --git a/test/unit/agent-view-header.test.ts b/test/unit/agent-view-header.test.ts new file mode 100644 index 000000000..9196a8804 --- /dev/null +++ b/test/unit/agent-view-header.test.ts @@ -0,0 +1,21 @@ +import { expect, it, vi } from 'vitest'; +import { createViewHeader, labelViewBack } from '../../lib/agent-plugin/ui/view-header'; + +it('composes the owned heading and action without replacing their identity or callbacks', () => { + const heading = document.createElement('h2'); + const back = document.createElement('button'); + const close = vi.fn(); + back.addEventListener('click', close); + const header = createViewHeader(heading, back); + labelViewBack(back, 'Back to conversation'); + expect(header.querySelector('h2')).toBe(heading); + expect(header.querySelector('button')).toBe(back); + expect(back.getAttribute('aria-label')).toBe('Back to conversation'); + expect(back.title).toBe('Back to conversation'); + expect(back.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + labelViewBack(back, '返回成果'); + expect(back.querySelectorAll('svg')).toHaveLength(1); + expect(back.getAttribute('aria-label')).toBe('返回成果'); + back.click(); + expect(close).toHaveBeenCalledTimes(1); +}); From abb672f5bf2049d5ad676cac4359336731dbae1a Mon Sep 17 00:00:00 2001 From: chaxus Date: Fri, 16 Oct 2026 20:41:14 +0800 Subject: [PATCH 3/5] fix(assistant): preserve destructive model command colors --- styles/assistant.css | 2 +- test/e2e/agent-workspace-consistency.spec.ts | 102 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/styles/assistant.css b/styles/assistant.css index abbc67895..3a87d0a0a 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -1117,7 +1117,7 @@ r-dropdown[role='listbox'] { padding: var(--ran-space-1) var(--ran-space-3); border-color: transparent; background: transparent; - color: var(--ran-color-text); + color: var(--ran-btn-color, var(--ran-color-text)); } .agent-panel-settings r-button:not([disabled]):hover::part(content) { background: var(--ran-color-bg-hover); diff --git a/test/e2e/agent-workspace-consistency.spec.ts b/test/e2e/agent-workspace-consistency.spec.ts index f38a79249..1458e85c4 100644 --- a/test/e2e/agent-workspace-consistency.spec.ts +++ b/test/e2e/agent-workspace-consistency.spec.ts @@ -157,3 +157,105 @@ test.describe('coarse pointer workspace', () => { } }); }); + +// Observe closed ranui shadow surfaces without changing shadow mode or rendering. +// The cache contains a one-byte fixture; no model download or inference occurs. +test('cached-model delete keeps its danger role through confirmation and pending deletion', async ({ page }) => { + test.setTimeout(90000); + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + const roots = new WeakMap(); + const attach = Element.prototype.attachShadow; + Element.prototype.attachShadow = function (options) { + const root = attach.call(this, options); + roots.set(this, root); + return root; + }; + Object.assign(window, { + assistantCommandStyle(button: Element) { + const surface = roots.get(button)?.querySelector('[part="content"]'); + if (!surface) throw new Error('Missing ranui command surface'); + const style = getComputedStyle(surface); + return { color: style.color, background: style.backgroundColor }; + }, + }); + const remove = FileSystemDirectoryHandle.prototype.removeEntry; + let release: () => void = () => {}; + const hold = new Promise((resolve) => (release = resolve)); + FileSystemDirectoryHandle.prototype.removeEntry = async function (name, options) { + if (name.includes('command-fixture.gguf')) await hold; + return remove.call(this, name, options); + }; + Object.assign(window, { releaseCommandDeletion: release }); + }); + await page.goto('/'); + await page.evaluate(async () => { + const cache = await (await navigator.storage.getDirectory()).getDirectoryHandle('cache', { create: true }); + for (const [name, value] of [ + ['command-fixture.gguf', 'x'], + [ + '__metadata__command-fixture.gguf', + JSON.stringify({ originalURL: 'https://example.invalid/command-fixture.gguf', originalSize: 1, etag: '' }), + ], + ]) { + const writable = await (await cache.getFileHandle(name, { create: true })).createWritable(); + await writable.write(value); + await writable.close(); + } + }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.locator('.agent-panel-settings-toggle').click(); + await expect(page.locator('.agent-model-cache > summary')).toContainText('(1)'); + await page.locator('.agent-model-cache > summary').click(); + const row = page.locator('.agent-model-cache-row', { hasText: 'command-fixture.gguf' }); + const remove = row.locator('.agent-cache-delete'); + const surface = (control = remove) => + control.evaluate((button) => + ( + window as unknown as { + assistantCommandStyle(button: Element): { color: string; background: string }; + } + ).assistantCommandStyle(button), + ); + const tokenColor = async (token: string) => + page.evaluate((name) => { + const probe = document.createElement('span'); + probe.style.color = `var(${name})`; + document.body.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }, token); + const danger = await tokenColor('--ran-color-danger'); + const disabled = await tokenColor('--ran-color-text-disabled'); + await expect.poll(async () => (await surface()).color).toBe(danger); + const idle = (await surface()).background; + await remove.hover(); + expect((await surface()).color).toBe(danger); + expect((await surface()).background).not.toBe(idle); + await remove.click(); + await expect(remove).toHaveText('Confirm deletion'); + expect((await surface()).color).toBe(danger); + const cancel = row.locator('.agent-cache-cancel'); + expect((await surface(cancel)).background).toBe('rgba(0, 0, 0, 0)'); + await cancel.hover(); + expect((await surface(cancel)).background).toBe(await tokenColor('--ran-color-bg-hover')); + await page.mouse.down(); + expect((await surface(cancel)).background).toBe(await tokenColor('--ran-color-bg-active')); + await page.mouse.up(); + await expect(remove).toHaveText('Delete download'); + await remove.click(); + await expect(remove).toHaveText('Confirm deletion'); + expect((await surface()).color).toBe(danger); + await remove.click(); + await expect(remove).toHaveAttribute('disabled', ''); + await expect.poll(async () => (await surface()).color).toBe(disabled); + const pending = (await surface()).background; + await remove.hover(); + expect((await surface()).background).toBe(pending); + await page.evaluate(() => (window as unknown as { releaseCommandDeletion(): void }).releaseCommandDeletion()); + await expect(row).toHaveCount(0); +}); From 08c9e92b8b80fc3380e536ec142b7ec90bc67044 Mon Sep 17 00:00:00 2001 From: chaxus Date: Fri, 16 Oct 2026 20:46:50 +0800 Subject: [PATCH 4/5] test(assistant): align history assertions with shared view header --- test/e2e/agent-history-rendering.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/e2e/agent-history-rendering.spec.ts b/test/e2e/agent-history-rendering.spec.ts index dcaced23d..ec60ccefd 100644 --- a/test/e2e/agent-history-rendering.spec.ts +++ b/test/e2e/agent-history-rendering.spec.ts @@ -132,11 +132,19 @@ test('restores a long saved conversation and switches sessions with completed an await expect(page.locator('.cui-input')).toHaveValue('Next request'); await page.locator('.agent-history-toggle').click(); const history = page.locator('.agent-history-view'); - const titleBox = await history.locator('.agent-history-heading').boundingBox(); + const viewHeader = history.locator('.agent-view-header'); + await expect(viewHeader.locator('.agent-history-heading')).toHaveCount(1); + const back = viewHeader.getByRole('button', { name: 'Back to conversation', exact: true }); + await expect(back).toHaveCount(1); + const viewHeaderBox = await viewHeader.boundingBox(); + const titleBox = await viewHeader.locator('.agent-history-heading').boundingBox(); + const backBox = await back.boundingBox(); const headerBox = await page.locator('.agent-runtime-panel .agent-panel-title').boundingBox(); - expect(Math.abs(titleBox!.x - headerBox!.x)).toBeLessThanOrEqual(1); + expect(Math.abs(viewHeaderBox!.x - headerBox!.x)).toBeLessThanOrEqual(1); const rowBox = await history.locator('.agent-conversation-title').first().boundingBox(); - expect(Math.abs(titleBox!.x - rowBox!.x)).toBeLessThanOrEqual(1); + expect(Math.abs(viewHeaderBox!.x - rowBox!.x)).toBeLessThanOrEqual(1); + // The owned title follows the compact Back target and the shared 8px gap. + expect(Math.abs(titleBox!.x - (backBox!.x + backBox!.width + 8))).toBeLessThanOrEqual(1); await page.locator('.agent-runtime-panel').screenshot({ path: test.info().outputPath('history-light.png') }); await page.emulateMedia({ colorScheme: 'dark' }); await page.locator('.agent-runtime-panel').screenshot({ path: test.info().outputPath('history-dark.png') }); From ff0faee348101cb7335b756118762894e746f9bc Mon Sep 17 00:00:00 2001 From: chaxus Date: Fri, 16 Oct 2026 20:47:13 +0800 Subject: [PATCH 5/5] docs(assistant): record workspace consistency validation --- ...26-10-16-assistant-workspace-consistency.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-16-assistant-workspace-consistency.md b/docs/superpowers/plans/2026-10-16-assistant-workspace-consistency.md index e5ce4c3f5..5bcd150f5 100644 --- a/docs/superpowers/plans/2026-10-16-assistant-workspace-consistency.md +++ b/docs/superpowers/plans/2026-10-16-assistant-workspace-consistency.md @@ -11,13 +11,21 @@ ## Chunk 1: Uniform workspace - [x] Inspect deployed pages and code; record measured drift, undefined tokens, consumer boundaries and acceptance in the companion spec. -- [ ] Add failing browser geometry/state tests for Sources/Models/History/Artifacts, native versus ranui 32 px frames, consistent titles/insets/gaps, enabled hover and disabled no-hover, coarse targets and long labels. Add focused tests for header composition and artifact dirty/busy back paths before implementing behavior changes. -- [ ] Consolidate relevant rules in `styles/assistant.css` at their existing owner, replace undefined spacing/hover tokens, use tokenized type/spacing/radius/states, maintain content-sized editors, primary versus ordinary/destructive actions, and touch minima. -- [ ] Add `lib/agent-plugin/ui/view-header.ts`; integrate Sources, Artifacts, Models and History owners without moving or duplicating state. Preserve artifact button registry, labels, dirty confirmation, asynchronous generation guards and focus restoration. Add destination label only where existing vocabulary is insufficient, updating all seven locales and types. -- [ ] Run focused units and browser regression red/green, inspect screenshots and self-review; commit implementation and tests. Spec review, then quality review; resolve concrete findings and reverify. +- [x] Add failing browser geometry/state tests for Sources/Models/History/Artifacts, native versus ranui 32 px frames, consistent titles/insets/gaps, enabled hover and disabled no-hover, coarse targets and long labels. Add focused tests for header composition and artifact dirty/busy back paths before implementing behavior changes. +- [x] Consolidate relevant rules in `styles/assistant.css` at their existing owner, replace undefined spacing/hover tokens, use tokenized type/spacing/radius/states, maintain content-sized editors, primary versus ordinary/destructive actions, and touch minima. +- [x] Add `lib/agent-plugin/ui/view-header.ts`; integrate Sources, Artifacts, Models and History owners without moving or duplicating state. Preserve artifact button registry, labels, dirty confirmation, asynchronous generation guards and focus restoration. Add destination label only where existing vocabulary is insufficient, updating all seven locales and types. +- [x] Run focused units and browser regression red/green, inspect screenshots and self-review; commit implementation and tests. Spec review, then quality review; resolve concrete findings and reverify. ## Chunk 2: Acceptance and delivery -- [ ] Run full unit coverage, lint/types, format and production build; run relevant UI/source/artifact/native browser workflows separately from timing-sensitive unit workloads. +- [x] Run full unit coverage, lint/types, format and production build; run relevant UI/source/artifact/native browser workflows separately from timing-sensitive unit workloads. - [ ] Verify light/dark/narrow/coarse/keyboard/long-label states and actual deployed preview. Save a compact screenshot deliverable. - [ ] Create the follow-up PR because #276 is merged, attach it, wait for all latest-head checks, fix failures and perform a final incremental review. Record authoritative CI/review results in the PR description; do not merge. + +## Local acceptance + +- Full coverage: 6,169 tests in 231 files passed; statements 86.77%, branches 82.89%, functions 87.51%, lines 89.33%. Lint, workspace declarations, TypeScript, formatting and production build passed. +- The 11-suite browser run passed 38 cases and exposed one superseded history-title alignment assertion. The revised assertion checks the header/body leading edge and the exact back-to-title gap; the complete long-history case then passed on a fresh build. All 39 targeted scenarios are verified, including native document readback and Undo, IME/dictation, queued requests, offline sources, artifact revisions and draft restoration. +- Independent specification review approved `a0bb0d3`; quality review found a masked ranui danger-color hook. `abb672f` restores that public hook and adds a real-component red/green regression for deletion, confirmation, hover, pressed and pending-disabled states. Quality re-review approved that fix. +- Root inspected Sources, Models, Conversations and Artifacts screenshots in light/dark and narrow layouts. The optional Google OAuth deployment remains unconfigured and is outside this visual increment; no real authorization or model-quality claim is made. +- Deployed-preview acceptance and latest-head remote CI remain pending until the follow-up PR is created.