diff --git a/docs/design-system.md b/docs/design-system.md index 76b358b68..a61449f03 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,8 +1,9 @@ # 页面版式规范(业界基准与本站取值) 日期:2026-08-22 -适用:所有用户可见页面(落地页、生成页、`/history`、demo、404)。编辑器本身(`/editor`) -是全屏应用,不在此列。 +适用:所有用户可见页面(落地页、生成页、`/history`、demo、404),以及 `/editor` 的应用壳与 +AI 工作区。下述站点列宽不套用到全屏编辑器;AI 工作区的布局与验收见本文末尾的应用布局契约。 +OnlyOffice 内部界面由 vendor 持有。 审计脚本:`node bin/design-audit.mjs `——渲染每个页面,打印下面这些数字; 加 `--all` 则把每种页型在**七种语言**下各量一遍(默认只量英文,那是本规范写作时的基准)。 @@ -209,3 +210,93 @@ Two rules are mechanical because both fail silently in review: in a transition also fade on a theme flip — each surface at its own duration while the page snaps. Bare durations (`transition: 0.15s`) mean `all` and fail the same way. The documented `var(--…-transition)` hook is the opt-in escape hatch and is not judged. + +## Assistant application layout contract + +The canonical composition rules are owned by +[ranui DESIGN section 11](https://github.com/chaxus/ran/blob/main/packages/ranui/docs/DESIGN.md#11-composition--the-page-not-the-component). +This section supplies the application's geometry and browser acceptance; the site's marketing +column widths do not apply inside the assistant. `styles/assistant.css` owns the shared values. + +| Geometry | Product contract | Acceptance subject | +| --------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| Content rail | 16px from the panel's inside edge | Header title text, onboarding copy, unframed copy, section labels, field/list outer frames | +| Header title | Text-sized target with 8px inline padding; leading hit area extends into the gutter | DOM Range text left, actual button width, hover/focus area and neighboring actions | +| Back navigation | Center a 16px SVG viewport in the 32px fine / 44px coarse pointer target; the viewport returns to the content rail | SVG viewport and full target bounds, not painted path bounds | +| Subpage heading | An explicit navigation column followed by an 8px gap; shared by every subpage and nested artifact state | Repeated title rail: 48px fine / 54px coarse, relative to the panel's inside edge | +| Framed fields | Outer frame follows the content rail; internal text retains its control inset | Native frame and real ranui shadow parts are separate subjects | +| Vertical groups | 8px internal, 16px group and 24px section spacing | Actual related-region edges; empty status paragraphs reserve no anonymous margins | + +The title's flexible trailing filler is layout space, not a clickable/hovering title surface. +At panel widths up to 360px, the runtime header gives the readable title its own row above +the five action slots. Coarse input retains 44px targets without constraining the title to +an icon's flex basis. Back focus outlines use zero offset so their full 2px ring remains +inside the scrolling view. Populated result lists keep a 16px boundary before Import. +Back navigation is a declared hierarchy, not an unexplained extra body inset. Centered empty +states remain centered within their content frame. Frame, text, icon viewport and hit area +must not be substituted for each other during review. CSS token compliance alone cannot prove +these relationships. Do not nudge individual pages or SVG paths to compensate for a shared +layout error. + +Apply this contract to onboarding/chat, Models, Conversations, Sources and Artifacts, including +list/import/editor, empty/populated states, statuses and disclosures. Layout owners retain +callbacks, focus return, busy guards and drafts. New sibling views must join the same checks. + +`test/e2e/agent-layout-rails.spec.ts` and `test/e2e/lib/layout-geometry.ts` read actual text +Ranges and layout frames after fonts and async view content settle. They cover 432px desktop +panels, 360px/320px windows, English/Portuguese, both themes and coarse input, with nested +artifact import/editor/return paths. The existing sidebar test also measures title text rather +than its padded button rectangle. Relevant focus, navigation, workspace and source/artifact +suites remain required alongside the geometry assertions. + +Acceptance must reject a text-only 8px drift even when the outer frame stays fixed, a title +target expanded into free toolbar space, and a sibling view shifted off its content rail. +Review screenshots with these measured relationships; a stored screenshot without assertions +or inspection is not acceptance evidence. + +### Settings command groups + +History & data is already a named section inside the assistant pane. Restore, Import and +Export use content-sized native secondary buttons in one column: each button has a restrained +resting surface and functional inset stroke, with no enclosing group frame, group fill, +raised shadow or row separators. Keep 8px between buttons and 16px before deletion commands. +A recognizable button shape must be visible before hover in both themes and on touch screens; +verb labels and hover feedback alone do not establish initial discoverability. + +The declared columns are 16px from the panel’s inner edge for both checkbox and 16px icon +viewports, and 40px for checkbox/action text Ranges. Each command target extends 8px into the +owned inline-start gutter, with 8px inline padding and icon/label gap; target frames need not +match visible columns. Keep targets inside the pane, at least 44px high, and non-overlapping. +Long translations wrap without shifting the columns or overflowing the pane. The save-history +checkbox uses the monochrome primary role. + +Deletion commands share the secondary control shape with readable danger text/icons; only +explicit confirmation has a solid danger fill. Hover, press, disabled and keyboard focus +remain distinct. Draft, confirmation, busy and focus-return behavior remain owned by the +existing controls. Acceptance checks actual renders as well as shared checkbox/icon/text +geometry, resting surface difference from the canvas, text contrast and real interactions. + +Review boundaries in the full pane. The panel/editor edge owns the independent sidebar; +the search/input frames identify editable values; the single History & data section divider +separates task regions; button surfaces identify action targets; keyboard outlines identify focus. +An inner card or more row rules +would repeat those relationships. Models picker frames, Sources search/attachment controls +and Artifacts form fields keep their functional control boundaries. Do not remove every +border globally, add transparent borders to preserve obsolete card insets, or require a +decorative group outline in acceptance. + +### Models, Sources and Artifacts + +Apply the same boundary review to each complete view and its expanded states: + +| Surface | Flat composition | Functional boundaries retained | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Models | Device/service containers, cached entries and preferences use headings and the existing group gap; no extra disclosure/row rules | Model/source pickers, endpoint/custom fields, focus and explicit load/connect/delete states | +| Sources | Separate the attached list from search by 16px; keep removal beside the title while the excerpt expands; no result card or remove-button outline | Search input, native disclosure and bounded excerpt scrolling; Attach file is the primary entry action | +| Artifacts | Full-width saved-result rows; quiet ordinary review/export/page/revision controls; no blanket button outlines | Import/Save/Use in draft primary roles, editable fields, structured table row relationships, revision input/diff, focus and explicit destructive confirmation | + +Use application danger foreground roles for quiet destructive labels and their hover/active +states. Disabled commands keep their own disabled role and do not inherit an enabled fill. +Do not infer that every control is a secondary outlined button from its ancestor view class. +Role-specific styling belongs to the actual action. The full state inventory, renders and +verification are recorded in [the workspace boundary audit](explorations/2026-10-20-workspace-boundary-audit.md). diff --git a/docs/explorations/2026-10-18-assistant-design-audit.md b/docs/explorations/2026-10-18-assistant-design-audit.md index ef0462d43..03564c2c9 100644 --- a/docs/explorations/2026-10-18-assistant-design-audit.md +++ b/docs/explorations/2026-10-18-assistant-design-audit.md @@ -157,6 +157,11 @@ frontend operation are unaffected. ## History-action affordances (2026-10-19) +The independent outline treatment described below was refined on 2026-10-20 into +[grouped command rows](2026-10-20-assistant-layout-acceptance.md#follow-up-grouped-history-commands). +The earlier checks document the original affordance fix; individual command borders are +not the current acceptance requirement. + 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 diff --git a/docs/explorations/2026-10-20-assistant-layout-acceptance.md b/docs/explorations/2026-10-20-assistant-layout-acceptance.md new file mode 100644 index 000000000..738eb39d0 --- /dev/null +++ b/docs/explorations/2026-10-20-assistant-layout-acceptance.md @@ -0,0 +1,201 @@ +# Assistant layout contract acceptance + +Date: 2026-10-20 + +## Result and scope + +The assistant now shares a 16px content rail across its header, onboarding, Models, +Conversations, Sources and Artifacts. Navigation headings use a declared second rail +(48px for fine input, 54px for coarse input), while the back icon viewport returns to +the content rail. A framed control's text keeps its own inset; its frame, text and +hit area are measured separately. + +The companion [ran PR #434](https://github.com/chaxus/ran/pull/434) defines the reusable +composition and acceptance rules. It changes guidance and test examples, so this +consumer does not need a dependency upgrade. Product dimensions remain in +`styles/assistant.css` and [the application design contract](../design-system.md#assistant-application-layout-contract). + +## Inspected surfaces + +| Surface | Rendered acceptance | +| ---------------------- | ------------------------------------------------------------------------------------------------------------- | +| Onboarding | Header, welcome heading and explanatory text share the content rail | +| Runtime header/chat | Actual title text and compact hover area; visible actions; draft retained on return | +| Models | Heading/navigation rails, picker frames and actual ranui selection-item text; internal control inset retained | +| Conversations | Search frame, empty state, populated conversation text and History & data disclosure | +| Sources | Intro/search group gap, search frame, attached reference preview, expanded excerpt and status | +| Artifacts list | Empty explanatory text, populated list, Import action and their group gap | +| Artifact import/editor | Shared header after async refresh, form labels/fields, save and return to list/chat | + +The geometry matrix exercises a 432px desktop panel, 360px and 320px windows, +English and Portuguese, both themes, fine and coarse input, and long synthetic +translations. Header targets stay inside the panel, do not overlap, and retain +44px minimum dimensions with coarse input. At widths up to 360px the readable +title occupies a row above the five actions. Keyboard checks establish +`:focus-visible`, inspect the complete back-button outline and return with Enter. + +Representative current renders: + +![Models at 320px with Portuguese labels and a readable two-row header](../media/assistant-layout/2026-10-20-models-320-light.png) + +![Populated Artifacts in dark mode with a separate Import group](../media/assistant-layout/2026-10-20-artifacts-432-dark.png) + +## Failures caught before the fixes + +| Evidence | Before | Accepted result | +| -------------------------- | ---------------------------------------------------------- | --------------------------------- | +| Title text Range | 24px despite a correctly placed outer button | 16px | +| Welcome copy | 28px | 16px | +| Title hover | Expanded into toolbar filler | Text width plus 16px padding | +| Narrow translated header | Truncated by five adjacent actions | Readable title above action row | +| 432px coarse header | Title inherited a 44px icon flex basis | Title keeps its own content basis | +| Coarse back focus | 2px gutter could not contain a 2px outline plus 2px offset | Zero offset; full outline visible | +| Sources intro/search | No group separation | 16px | +| Populated artifacts/Import | No group separation | 16px | + +The tests also inject an 8px text-only drift while retaining the same outer width, +expand the title into flexible filler, and shift a sibling view's padding. Each +mutation must fail the acceptance assertion, and restoration must pass. This +prevents an aligned wrapper or a stored screenshot from becoming false evidence. + +## Verification + +- `pnpm test:coverage`: 252 files and 6,801 tests passed. Coverage: statements + 87.79%, branches 83.94%, functions 88.77%, lines 90.25%. +- `pnpm lint:ts`, `pnpm format:check`, `pnpm build` and `git diff --check` passed. + Archive verification retained all 1,511 immutable evaluation artifacts. +- The related Chromium navigation/workspace/source/artifact/history regression run + passed 23 tests. The expanded final layout suite adds populated conversation and + source disclosure coverage; its seven cases run in Chromium, Firefox and WebKit. +- Final layout acceptance: Chromium 7 passed; Firefox/WebKit 14 passed. All three + browsers ran with retries disabled. +- Review findings about coarse title sizing and focus clipping were fixed and + checked against a fresh production preview. + +The first full coverage attempt overlapped browser work and hit an existing 5s +file-read timeout; the isolated complete rerun passed. A newly added populated +conversation fixture initially omitted legacy session metadata; correcting the +fixture made that case pass without a production logic change. + +These checks cover assistant composition and related interactions. Native browser +zoom and the full repository browser/Docker suite are outside this focused run. +Persistence, model loading, document writes and dependency versions are unchanged. + +## Follow-up: grouped history commands + +History & data uses content-sized native command buttons in one column. Restore/Import/Export +share the checkbox's icon/control and text columns, with 8px between buttons and a 16px gap +before destructive commands. Each button owns a quiet resting surface and functional stroke; +there is no inner group outline, group fill, row rule or deletion divider. Hover/press, +keyboard focus, disabled states and explicit danger confirmation remain distinct. + +Earlier framed groups and inset separators repeated the pane's existing boundary. Removing +those enclosures was appropriate, but the subsequent all-flat treatment also erased resting +affordance. It measured alignment only within the command list, missing adjacent checkbox +columns. The current revision retains the simplified grouping while restoring each native +button's own functional shape. Acceptance records the complete pane as well as the command +region, with the pointer away, and checks shared visible columns rather than equal row widths. + +The runtime baseline after adding icon/label rows passed all 252 unit files / 6,801 tests +(including 274 focused history/panel/write tests), lint/typecheck and the production build. +Two existing non-fatal `PromiseRejectionHandledWarning` messages were printed. The subsequent +composition and affordance revisions change CSS, browser acceptance and prose; runtime logic is unchanged. +The coverage percentages above belong to the earlier layout-only coverage run. + +## Revision scope: remove redundant command enclosures + +The user rejected the remaining nested-box appearance. This revision applies ran's existing +composition rules (avoid redundant nested containers; establish hierarchy with type and spacing) +to the complete settings pane rather than refining a cropped card in isolation. +The starting evidence below refers to document `515e7ba6` and ran `911ee0853`. + +| Assumption | Evidence | Decision | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Existing guidance already discourages decorative nested frames | Code fact: ran `packages/ranui/docs/DESIGN.md`, composition and spacing sections | Clarify how this wins over the optional framed-command example | +| History has a section divider, a transfer frame, row separators and a deletion divider | Code fact: `styles/assistant.css`, history options/transfer/danger rules; renders above | Retain the major section boundary; remove the three redundant command treatments | +| Group framing is currently required by acceptance | Code fact: `test/e2e/agent-history-actions.spec.ts`, resting group assertions | Verify full-row geometry and interactions without mandating an enclosure | +| Native controls own persistence, confirmation and focus | Code fact: `lib/agent-plugin/ui/history-controls.ts` | Leave runtime code unchanged | + +Allowed changes: ran's `DESIGN.md`, `INTERFACE_GUIDE.md`, English/Chinese design-guide +entry points; document's assistant CSS, existing history-action browser acceptance, +design contract, this acceptance record and history command/full-pane screenshots. +Forbidden changes: tokens, component defaults/APIs, dependency versions, vendor files, +history/state/storage code and unrelated site layouts. + +Compatibility red lines: keep the panel boundary, field borders, keyboard focus outlines, +44px targets, icon/label columns, accessible names, destructive confirmation and draft/focus +return behavior. The composition uses a single command column with one 16px gap before +destructive actions. It needs no replacement card, muted group fill, raised shadow or new +abstraction. Native buttons may own a functional surface; removing group wrappers must not +remove the cue that an individual control is clickable. + +Acceptance: inspect the whole pane and its command region in both themes and at desktop, +360px and 320px widths; use the existing English/Portuguese, pointer/keyboard, disabled, +contrast, download, touch and confirmation checks. Verify the documentation and formatting +gates in ran. The review must explain the purpose of each remaining stable boundary. + +## Remaining boundary review + +| Surface | Boundary retained | Distinct purpose | +| ---------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Assistant/editor | Panel edge | Independent sidebar and resize boundary | +| Models and Sources | Picker/search/input frames | Values that can be edited or selected | +| Conversations | Search field and one History & data section rule | Search input and a major change from conversation browsing to data settings | +| History command groups | No group enclosure; individual secondary button surfaces | Type/proximity express grouping; each button shape identifies an actionable target | +| Command focus | Keyboard outline | Transient focus state, not decorative containment | +| History deletion | Solid confirmation button | Explicit destructive commitment after the existing confirmation step | +| Artifacts | Form/control boundaries and task-specific save/apply hierarchy | Editable values and explicit document/result operations | + +![Complete Conversations pane with visible command buttons](../media/assistant-layout/2026-10-20-history-pane-light.png) + +![History command buttons in light mode](../media/assistant-layout/2026-10-20-history-commands-light.png) + +![History command buttons in dark mode](../media/assistant-layout/2026-10-20-history-commands-dark.png) + +![Portuguese command buttons at 320px](../media/assistant-layout/2026-10-20-history-commands-320-dark.png) + +Earlier flat-composition verification (before the affordance correction below): the history/layout Chromium suite passed all 10 cases; +the Firefox/WebKit history-action suite passed all six cases. Retries were disabled. +The updated row-edge acceptance first failed on the old enclosure's 2px reduction in +available target width, then passed after the redundant frame/insets were removed. +Ran documentation/link/eight-locale checks and changed-file formatting passed. + +## Resting affordance and shared control columns + +User review exposed two acceptance gaps in document `9f3c5a4c` / ran `320a91da6`. +Real Chromium measurements relative to History & data found the checkbox at 0px with its +label at 24px, while command icons started at 12px and command labels at 42px. The five +commands aligned with each other, but not with the adjacent control. Their resting background +also equalled the panel canvas, so icons and verbs could read as static content. + +The correction keeps the existing allowed file scope and runtime compatibility red lines. +It replaces the universal flat-row prescription in ran with context-dependent control affordance: +remove redundant wrappers, retain an individual standalone button's recognizable resting shape. +Document now uses 16px SVG viewports, 8px icon gaps and padding, and an explicit 8px inline-start +target extension into the owned gutter. Measured checkbox/icon edges are 16px from the panel +origin; all related text starts at 40px. The extension stays inside the pane and does not cause +horizontal scrolling. Content-sized buttons avoid a large empty full-row target. + +The new browser acceptance failed on the old geometry before implementation. Its first run +against the new surfaces caught light-mode danger text contrast of 4.475:1 and an 8px scroll +extension. Using the existing darker danger foreground role and limiting the extension to +inline-start corrects those actual failures. Surface differences are an automated guard, +not evidence that user discoverability has been measured; screenshots still require visual review. + +Final persisted-theme affordance verification: Chromium History 3 passed; Firefox/WebKit History 6 passed; +Chromium shared layout 7 passed, all without retries. The History cases cover both themes, +English/Portuguese at desktop, 360px and 320px, shared checkbox/icon/text columns, resting +surface distinction, readable danger states, disabled/hover/press, keyboard focus, Enter/Escape, +focus return, real export downloads and 44px touch targets. The renders above are current +Firefox browser captures, including the full pane. Visual review found no remaining +implementation issue; its stale-contract wording finding was corrected in this record and +the application design guide. Lint/typecheck, changed-file formatting, production preview +build, ran docs/eight-locale/link checks and whitespace checks passed. Runtime logic remains +unchanged; the prior 6,801-test runtime baseline was not rerun for this CSS/docs-only correction. + +Screenshot inspection also caught a theme-coverage gap: the media-emulated Firefox run +produced light renders under dark filenames. Its initial passing result did not establish +dark-mode coverage. The final History matrix selects the product's persisted `ran-theme` +choice at boot and asserts `data-ran-theme` before recording either theme. Final screenshots +replace those incorrect evidence files. This checks the rendered saved-theme setting; it does +not establish the cause of the Firefox system-media discrepancy. diff --git a/docs/explorations/2026-10-20-workspace-boundary-audit.md b/docs/explorations/2026-10-20-workspace-boundary-audit.md new file mode 100644 index 000000000..ba4e13c1d --- /dev/null +++ b/docs/explorations/2026-10-20-workspace-boundary-audit.md @@ -0,0 +1,108 @@ +# Models, Sources and Artifacts boundary audit + +Date: 2026-10-20. Baseline: `4ed5d28a`. + +## Scope and evidence + +The requested outcome is a consistent flat workspace across complete views and expanded +states. The user explicitly asks to apply the distinct-boundary standard beyond History. + +| Finding | Evidence grade | Decision | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Model device/service sections already have no card frame | Code fact: `styles/assistant.css`, `.agent-use-card` / `.agent-service-section`; runtime fact: baseline Models and service screenshots | Retain flat containers and functional picker/endpoint field frames | +| Cache rows and settings disclosures stack rules with existing gaps | Code fact: `.agent-model-cache`, `.agent-model-cache-row`, `.agent-preferences`; runtime fact: baseline Models | Use existing summary headings and spacing; remove decorative rules and duplicate group inset | +| Source rows touch the search field; each removal has its own outline | Runtime fact: baseline `sources.png` / `source-search-preview.png`; code fact: `.agent-source-row` and blanket workspace button border | Separate search/results with a group gap, top-align expanded previews and use a quiet removal command | +| All artifact/workspace commands inherit an outline | Code fact: shared `.agent-workspace-view button, .agent-artifacts button`; runtime fact: baseline text/table/slides/revision screenshots | Remove blanket command framing, give explicit primary/destructive roles their own states | +| Structured data and revision previews already use flat viewports | Code fact: `.agent-artifact-data-preview`, `.agent-artifact-workflow`; runtime fact: baseline structured/revision screenshots | Keep table row rules, bounded scrolling, diff feedback and field/focus cues | + +Baseline: all 13 existing source/artifact/revision/workspace browser cases passed. +The audit includes full-page screenshots, not just cropped buttons. + +## Allowed changes and compatibility + +Allowed: `styles/assistant.css`; semantic attach/remove classes in +`lib/agent-plugin/ui/sources-view.ts`; existing source/artifact/revision/workspace/layout +browser acceptance and screenshots; `docs/design-system.md`, this record, and media under +`docs/media/assistant-layout/`. + +Unchanged: runtime/store/provider ownership, persisted schemas, requests, model downloads, +document writes, public APIs, dependencies, foundation values and vendor code. Preserve +drafts, selection, async cancellation, downloads, native disclosure/keyboard semantics, +danger confirmation, disabled states, layout rails and 44px coarse-pointer targets. + +## Composition decisions + +- Models: rely on disclosure titles and the existing 16px section gap. Cached entries use + proximity rather than row rules. Endpoint and model fields retain their control frames. +- Sources: the search frame identifies editable input, the attached list is a separate + flat region, and an expanded excerpt keeps its own bounded scroll viewport. Remove + stays beside the source title instead of centering against the entire expanded excerpt. + Attach file is the explicit primary entry action. +- Artifacts: saved results are full-width flat rows. Import/Save/Use in draft have primary + roles; ordinary review/export/page/revision controls use quiet hover/focus surfaces. + Delete has a quiet danger role and explicit confirmation has a solid danger role. +- Data and editing: table row rules convey cell relationships; JSON/import fields and + the revision instruction remain identifiable inputs. No replacement card, added group + fill, nested shadow or additional shared component is needed. + +## Acceptance + +Run the existing complete source/artifact, AI revision, workspace and alignment suites. +Inspect local/service Models, empty/populated cached inventory, expanded preferences, +source attachment/search/preview/removal/offline/recovery, artifact empty/list/import, +text/table/slides/edit/export/revision/confirmation states. Check light/dark, desktop, +360px/320px, English/Portuguese, coarse input, focus, disabled and busy states. + +Keep boundary evidence and representative renders here. Verify type/lint, formatting, +the production build and relevant source-view/runtime tests if markup changes. Review +both ordinary and explicit destructive/primary states before updating the existing PR. + +## Inspected state inventory + +| View | States exercised | Boundary decision | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Models | Local and service configuration, cached GGUF entry, expanded preferences/custom fields, delete/cancel/pending, offline availability | No section/entry rules or outer cards; preserve actual picker/field/control surfaces and disabled/danger roles | +| Sources | Empty, attached file, open excerpt, search highlight, removal, attachment limit/error recovery, pending read cancellation and offline use | Search and results separated by a 16px gap; title and Remove stay together while the excerpt expands; no result card | +| Artifacts list/import | Empty, populated list, return to list, title/JSON import and validation recovery | Full-width flat list rows; explicit Import/Save entry roles; retain editable field cues | +| Artifact editor | Text and versions, dirty discard/cancel, export formats, delete confirmation/cancel and continued draft | Quiet ordinary commands, primary saves, quiet danger intent and solid destructive confirmation | +| Structured results | Editable table and slides, pagination, offline CSV export, complete payload preservation | Keep data row relationships and bounded viewport; remove outlines from page/export commands | +| AI revision | Instruction input, selection/full scope, waiting, proposal diff, accept/discard/stop, stale edits and conflict recovery | Flat disclosure/form/proposal with functional input and diff feedback; no nested card | + +## Representative renders + +![Expanded Models inventory and preferences](../media/assistant-layout/2026-10-20-models-expanded-light.png) + +![Expanded Models in dark mode](../media/assistant-layout/2026-10-20-models-expanded-dark.png) + +![Sources search and expanded reference](../media/assistant-layout/2026-10-20-sources-expanded-light.png) + +![Artifact text editor and expanded export](../media/assistant-layout/2026-10-20-artifacts-export-light.png) + +![Structured table in dark mode](../media/assistant-layout/2026-10-20-artifacts-table-dark.png) + +![Artifact revision proposal](../media/assistant-layout/2026-10-20-artifacts-revision-light.png) + +![Explicit artifact deletion confirmation](../media/assistant-layout/2026-10-20-artifacts-confirm-dark.png) + +## Verification + +- Baseline: 13 source/artifact/revision/workspace cases passed. +- Final Chromium: all 20 cases passed, including the seven alignment/onboarding cases. +- Final Firefox/WebKit: all 26 source/artifact/revision/workspace cases passed. +- All browser runs disabled retries. Rendered states cover both themes, desktop and + 360px/320px widths, English/Portuguese, keyboard return, coarse input and actual exports. +- `pnpm lint:ts`, production build, changed-file formatting and `git diff --check` passed. + Archive validation retained all 1,511 immutable evaluation artifacts. +- Independent review found no actionable issues. Whole views, expanded states, explicit + primary/danger/disabled states and the retained functional field/focus boundaries were reviewed. +- Final `pnpm test`: 252 files / 6,801 tests passed. The two existing non-fatal + `PromiseRejectionHandledWarning` messages were unchanged. + +Rendered follow-up found a remaining raised-button base on Models cache commands: making +the documented content part transparent left the ranui host's default shadow visible. +The application now uses the existing public `--ran-btn-box-shadow` and +`--ran-btn-surface-background` hooks to remove that decorative layer. A real-host shadow +assertion first failed with the inherited raised shadow, establishing the regression. +Functional component borders, primary fills, focus and coarse targets remain separate. +After that final adjustment, the cache use/delete/cancel/pending case passed again in +Chromium, Firefox and WebKit without retries; expanded Models captures were refreshed. diff --git a/docs/media/assistant-layout/2026-10-20-artifacts-432-dark.png b/docs/media/assistant-layout/2026-10-20-artifacts-432-dark.png new file mode 100644 index 000000000..b2a10e540 Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-artifacts-432-dark.png differ diff --git a/docs/media/assistant-layout/2026-10-20-artifacts-confirm-dark.png b/docs/media/assistant-layout/2026-10-20-artifacts-confirm-dark.png new file mode 100644 index 000000000..e3ecb74fb Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-artifacts-confirm-dark.png differ diff --git a/docs/media/assistant-layout/2026-10-20-artifacts-export-light.png b/docs/media/assistant-layout/2026-10-20-artifacts-export-light.png new file mode 100644 index 000000000..7f178dbb6 Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-artifacts-export-light.png differ diff --git a/docs/media/assistant-layout/2026-10-20-artifacts-revision-light.png b/docs/media/assistant-layout/2026-10-20-artifacts-revision-light.png new file mode 100644 index 000000000..0c5945f2c Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-artifacts-revision-light.png differ diff --git a/docs/media/assistant-layout/2026-10-20-artifacts-table-dark.png b/docs/media/assistant-layout/2026-10-20-artifacts-table-dark.png new file mode 100644 index 000000000..0f3d3aebb Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-artifacts-table-dark.png differ diff --git a/docs/media/assistant-layout/2026-10-20-history-commands-320-dark.png b/docs/media/assistant-layout/2026-10-20-history-commands-320-dark.png new file mode 100644 index 000000000..353feaca3 Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-history-commands-320-dark.png differ diff --git a/docs/media/assistant-layout/2026-10-20-history-commands-dark.png b/docs/media/assistant-layout/2026-10-20-history-commands-dark.png new file mode 100644 index 000000000..8102a20ff Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-history-commands-dark.png differ diff --git a/docs/media/assistant-layout/2026-10-20-history-commands-light.png b/docs/media/assistant-layout/2026-10-20-history-commands-light.png new file mode 100644 index 000000000..c00213596 Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-history-commands-light.png differ diff --git a/docs/media/assistant-layout/2026-10-20-history-pane-light.png b/docs/media/assistant-layout/2026-10-20-history-pane-light.png new file mode 100644 index 000000000..672aadf6c Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-history-pane-light.png differ diff --git a/docs/media/assistant-layout/2026-10-20-models-320-light.png b/docs/media/assistant-layout/2026-10-20-models-320-light.png new file mode 100644 index 000000000..cca8e722b Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-models-320-light.png differ diff --git a/docs/media/assistant-layout/2026-10-20-models-expanded-dark.png b/docs/media/assistant-layout/2026-10-20-models-expanded-dark.png new file mode 100644 index 000000000..b71778893 Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-models-expanded-dark.png differ diff --git a/docs/media/assistant-layout/2026-10-20-models-expanded-light.png b/docs/media/assistant-layout/2026-10-20-models-expanded-light.png new file mode 100644 index 000000000..8853bd953 Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-models-expanded-light.png differ diff --git a/docs/media/assistant-layout/2026-10-20-sources-expanded-light.png b/docs/media/assistant-layout/2026-10-20-sources-expanded-light.png new file mode 100644 index 000000000..ffd43d273 Binary files /dev/null and b/docs/media/assistant-layout/2026-10-20-sources-expanded-light.png differ diff --git a/docs/superpowers/plans/2026-10-20-assistant-layout-contract.md b/docs/superpowers/plans/2026-10-20-assistant-layout-contract.md new file mode 100644 index 000000000..b6628300e --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-assistant-layout-contract.md @@ -0,0 +1,44 @@ +# Assistant Layout Contract Implementation Plan + +> **For agentic workers:** execute the document tasks inline; the independent ran guidance +> and executable example run in a separate isolated worktree using parallel agents. + +**Goal:** enforce declared text, frame and navigation rails throughout the assistant. + +**Architecture:** shared host CSS owns geometry; existing view owners keep callbacks and +state. Real-browser tests measure text and rendered component parts independently of CSS. + +**Tech Stack:** TypeScript, ranui, CSS, Playwright, Vitest. + +**Spec:** `docs/superpowers/specs/2026-10-20-assistant-layout-contract-design.md` + +## Global constraints + +Preserve persistence, document writes, runtime ownership, focus and user drafts. Do not +alter foundation tokens, vendor code or dependencies. Keep multiple commits per PR. + +## Tasks + +- [x] Add browser geometry helpers and failing coverage for title text/hover and all subpage + rails, including nested artifact states. Run against the unchanged CSS and retain failures. +- [x] Correct shared header/navigation/content CSS and inherited paragraph spacing. Apply + the same owner rules to every inspected surface; run focused tests until green. +- [x] Validate narrow, themed, translated, coarse-pointer and long-text states. Run explicit + reverse checks for text-only inset, expanded title hover and sibling rail regressions. +- [x] Update the main design entry with the assistant contract and record the inspected + surface matrix, measurements, screenshots and actual verification outcomes. +- [x] Run complete unit, lint/type, format/token and production build checks. Review both + branches and address findings. + +Verification and the inspected matrix are recorded in +`docs/explorations/2026-10-20-assistant-layout-acceptance.md`. +Delivery uses separate, cross-linked PRs for the two repositories, preserving multiple +commits and attaching both PRs to the chat. + +## Review focus + +- A button's outer frame can align while its text drifts. +- A valid hit-area extension must not escape the panel or overlap adjacent controls. +- Native paragraph margins can double a shared header's intended group gap. +- Nested artifact views must reuse the same navigation/header geometry after async refresh. +- Long translations and coarse pointer targets must preserve visible actions and focus. diff --git a/docs/superpowers/specs/2026-10-20-assistant-layout-contract-design.md b/docs/superpowers/specs/2026-10-20-assistant-layout-contract-design.md new file mode 100644 index 000000000..401392f3e --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-assistant-layout-contract-design.md @@ -0,0 +1,71 @@ +# Assistant layout and acceptance contract + +## Intent and evidence + +The owner requested a cross-repository correction: strengthen ran's layout guidance and +acceptance, then apply it to all analogous document assistant surfaces with multiple commits. +The existing workspace specification requires a shared 16px inset. Browser measurements at +main `7bf478b7` found a 16px content/frame rail, a 24px header text rail, and a 56px subpage +heading rail. The title's 77px text occupied a 229px hover target. The existing sidebar test +compares button rectangles, so it cannot detect the extra 8px text inset. + +## Scope lock + +- ran owns general composition guidance and an executable browser acceptance example. +- document owns `styles/assistant.css`, focused assistant view composition if needed, + `docs/design-system.md`, assistant browser tests and their geometry utilities, and this + implementation record. Inspect chat/onboarding, Models, Conversations, Sources, Artifacts, + artifact import/editor, populated lists, disclosures, statuses and empty states. +- Preserve navigation/focus, drafts, request ownership, local-model opt-in, document writes, + save/recovery behavior, persistence schemas, vendor assets and library foundation values. +- No library release or dependency bump is needed for a host composition correction. +- Each repository gets its own cross-linked PR. Preserve multiple English conventional commits. + +## Layout decisions + +The host has one content rail, 16px from the panel's inside edge. Header title text, +unframed copy, section labels and field/list outer frames use that rail. Framed fields +retain their own text inset. Centered empty states remain centered within the shared frame. + +The title's hover target hugs its text with an 8px internal inset. Its leading hit-area +extension belongs to the header gutter; a separate flexible space keeps the action group at +the trailing edge. Long titles shrink and ellipsize without moving or covering the actions. + +Subpage back controls have an explicit navigation column. Their 16px SVG viewport shares +the content rail; a centered pointer target extends into the leading gutter. The heading +occupies a separate, repeated title rail after this column and its 8px gap. The target is +32px for fine pointers and 44px for coarse pointers. This is a named navigation hierarchy, +not a claim that the heading and body text have equal coordinates. The full target remains +inside the panel, including coarse input. The same composition applies to every subpage +and nested artifact state, with no per-page horizontal corrections. + +Rendered follow-up: panels at or below 360px place the title above the action row, retaining +its content-sized hover area and readable translated label. Coarse header icon sizing must +exclude the title's flex basis. Back focus rings use zero outline offset to stay within the +scrollport. Sources intro/search and populated artifacts/import are distinct 16px groups. + +Vertical relationships use 8px internal, 16px group and 24px section spacing. Empty status +paragraphs do not reserve anonymous margins. Artifact explanatory copy starts at the same +post-header group boundary as other subpage content; nested forms do not accumulate inset. + +## Acceptance + +- Measure after fonts and reachable async views settle. Compare visible text with DOM Range, + frames with bounding rectangles, and ranui field parts inside their real shadow roots. +- Header text, content frames and navigation SVG viewports share the declared content rail; + repeated heading rails and first-content group gaps agree across subpages. +- Title hover width hugs the rendered text plus declared padding; header actions remain + reachable. Focus outlines remain visible; back/close/title keyboard behavior is preserved. +- Exercise 320px, 360px and 432px panels, both themes, English and Portuguese, fine/coarse + input, and a long heading. Inspect actual screenshots at representative widths. +- Exercise artifact list, import, editor and return paths; source attach/status and history + populated/empty states. Preserve user text while navigating. +- A real text-only 8px inset mutation must fail while its outer box stays aligned. Additional + reverse checks must detect a flex-expanded title target and a displaced subpage content rail. +- Run focused browser suites, full unit tests, type/lint, token/format and production build. + +## Simplest alternative + +Use existing native controls, ranui parts and shared CSS composition. A new framework, +component API or site-wide token scale adds no necessary behavior. A screenshot-only patch +cannot enforce the same relationships in hidden, nested or translated states. diff --git a/lib/agent-plugin/ui/history-controls.ts b/lib/agent-plugin/ui/history-controls.ts index 998318b44..dc104ad9a 100644 --- a/lib/agent-plugin/ui/history-controls.ts +++ b/lib/agent-plugin/ui/history-controls.ts @@ -39,17 +39,39 @@ export function createHistoryControls(options: HistoryControlsOptions) { status.setAttribute('role', 'status'); const actions = document.createElement('div'); actions.className = 'agent-history-actions'; - const button = (className: string): HTMLButtonElement => { + const actionLabels = new Map(); + const button = (className: string, path?: string): HTMLButtonElement => { const item = document.createElement('button'); item.type = 'button'; item.className = className; + if (path) { + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + icon.setAttribute('viewBox', '0 0 24 24'); + icon.setAttribute('width', '18'); + icon.setAttribute('height', '18'); + icon.setAttribute('fill', 'none'); + icon.setAttribute('stroke', 'currentColor'); + icon.setAttribute('stroke-width', '1.6'); + icon.setAttribute('stroke-linecap', 'round'); + icon.setAttribute('stroke-linejoin', 'round'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + icon.innerHTML = ``; + const text = document.createElement('span'); + text.className = 'agent-history-action-label'; + item.append(icon, text); + actionLabels.set(item, text); + } return item; }; - const restore = button('agent-history-restore'); - const legacy = button('agent-history-import'); - const exported = button('agent-history-export'); - const remove = button('agent-history-delete-current'); - const clear = button('agent-history-delete-all'); + const restore = button('agent-history-restore', 'M3 11a9 9 0 1 1 3 8M3 5v6h6M12 7v5l3 2'); + const legacy = button('agent-history-import', 'M12 3v12m-4-4 4 4 4-4M4 15v5h16v-5'); + const exported = button('agent-history-export', 'M12 15V3m-4 4 4-4 4 4M4 15v5h16v-5'); + const remove = button('agent-history-delete-current', 'M9 8l6 6m0-6-6 6M20 11a8 8 0 0 1-8 8H4v-8a8 8 0 0 1 16 0Z'); + const clear = button('agent-history-delete-all', 'M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7'); + const labelAction = (item: HTMLButtonElement, key: keyof I18nMessages) => { + actionLabels.get(item)!.textContent = t(key); + }; actions.append( Div().class('agent-history-transfer-actions').children(restore, legacy, exported).build(), Div().class('agent-history-danger-actions').children(remove, clear).build(), @@ -77,11 +99,11 @@ export function createHistoryControls(options: HistoryControlsOptions) { cancel.disabled = busy > 0; legend.textContent = t('agentHistoryDataControls'); saveText.textContent = t('agentSaveHistory'); - restore.textContent = t('agentRestoreHistory'); - legacy.textContent = t('agentImportHistory'); - exported.textContent = t('agentExportHistory'); - remove.textContent = t('agentDeleteConversation'); - clear.textContent = t('agentDeleteHistory'); + labelAction(restore, 'agentRestoreHistory'); + labelAction(legacy, 'agentImportHistory'); + labelAction(exported, 'agentExportHistory'); + labelAction(remove, 'agentDeleteConversation'); + labelAction(clear, 'agentDeleteHistory'); const target = store.list().find((item) => item.id === deletion); question.textContent = t(deletion === 'all' ? 'agentDeleteHistoryConfirm' : 'agentDeleteConversationConfirm'); if (target) question.textContent += ` ${target.title || t('agentNewConversation')}`; diff --git a/lib/agent-plugin/ui/sources-view.ts b/lib/agent-plugin/ui/sources-view.ts index 42762e6a3..a9631c70f 100644 --- a/lib/agent-plugin/ui/sources-view.ts +++ b/lib/agent-plugin/ui/sources-view.ts @@ -75,6 +75,7 @@ export function createSourcesView(options: { return { wrapper, text }; }; const attachFile = button(() => local.click()); + attachFile.className = 'agent-source-attach'; const localField = label(local, ''); localField.wrapper.hidden = true; el.append( @@ -139,6 +140,7 @@ export function createSourcesView(options: { options.sources.remove(source.id); update(); }); + remove.className = 'agent-source-remove'; remove.textContent = labels.remove; remove.setAttribute('aria-label', `${labels.remove}: ${source.title}`); row.append(text, remove); diff --git a/styles/assistant.css b/styles/assistant.css index 71b66b22c..553a4d5a3 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -139,13 +139,18 @@ body.agent-panel-resizing #app iframe { align-items: flex-start; gap: var(--ran-space-2); } +.agent-history-save { + flex: 0 0 var(--ran-size-1); + width: var(--ran-size-1); + height: var(--ran-size-1); + margin: var(--ran-space-1) 0 0; + accent-color: var(--ran-color-primary); +} .agent-history-status { - margin: var(--ran-space-2) 0; + margin: var(--ran-space-2) 0 var(--ran-space-4); color: var(--ran-color-text-secondary); overflow-wrap: anywhere; } -.agent-history-transfer-actions, -.agent-history-danger-actions, .agent-history-confirmation { display: flex; flex-wrap: wrap; @@ -155,10 +160,16 @@ body.agent-panel-resizing #app iframe { display: flex; flex-direction: column; gap: var(--ran-space-4); + /* Own 8px of the panel gutter so the visible icon/label columns stay + aligned with the checkbox while each button keeps inline padding. */ + margin-inline-start: calc(-1 * var(--ran-space-2)); } +.agent-history-transfer-actions, .agent-history-danger-actions { - border-block-start: 1px solid var(--ran-color-border); - padding-block-start: var(--ran-space-4); + display: grid; + justify-items: start; + gap: var(--ran-space-2); + min-width: 0; } .agent-history-confirmation { margin-top: var(--ran-space-3); @@ -197,12 +208,48 @@ body.agent-panel-resizing #app iframe { border-color: var(--ran-color-border-active); background: var(--ran-color-bg-active); } +/* The pane establishes the region; only each native button owns a surface. + A resting control shape must remain visible without pointer discovery. */ +.agent-history-actions button { + width: fit-content; + min-height: var(--app-control-touch); + justify-content: flex-start; + gap: var(--ran-space-2); + padding: var(--ran-space-3) var(--ran-space-2); + border: 0; + border-radius: var(--ran-radius-sm); + background: var(--ran-color-bg-hover); + box-shadow: inset 0 0 0 1px var(--ran-color-border); + text-align: start; + font-size: var(--ran-text-label-1); + font-weight: var(--ran-text-label-weight); +} +.agent-history-actions button:enabled:hover { + background: var(--ran-color-bg-active); +} +.agent-history-actions button:enabled:active { + background: color-mix(in srgb, var(--ran-color-bg-active) 96%, var(--ran-color-text)); +} +.agent-history-actions button svg { + flex: 0 0 var(--ran-size-1); + width: var(--ran-size-1); + height: var(--ran-size-1); + color: var(--ran-color-text-secondary); +} +.agent-history-action-label { + min-width: 0; +} +.agent-history-actions button:disabled svg { + color: inherit; +} .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); + color: var(--app-danger-hover); +} +.agent-history-danger-actions button svg { + color: inherit; } .agent-history-options button:is(.agent-history-delete-current, .agent-history-delete-all):enabled:hover { - color: var(--app-danger-hover); + color: var(--app-danger-active); } .agent-history-options button:is(.agent-history-delete-current, .agent-history-delete-all):enabled:active { color: var(--app-danger-active); @@ -231,6 +278,9 @@ body.agent-panel-resizing #app iframe { outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } +.agent-history-actions button:focus-visible { + outline-offset: -2px; +} .agent-panel-key-label { font-size: var(--ran-text-label-3); color: var(--ran-color-text-secondary); @@ -803,6 +853,11 @@ r-dropdown[role='listbox'] { /* Product surfaces: each view owns the available space. */ .agent-panel { + /* Content, text and navigation have different boxes but named shared rails. */ + --agent-content-inset: var(--ran-space-4); + --agent-title-inset: var(--ran-space-2); + --agent-navigation-size: var(--app-control-height); + --agent-navigation-inset: calc((var(--agent-navigation-size) - var(--ran-size-1)) / 2); /* Status lines beside the composer (model state, readiness, write destination) line up with the composer's *text*, not its edge: footer padding plus the composer's own padding. Three rules must agree, so the @@ -836,12 +891,12 @@ r-dropdown[role='listbox'] { box-sizing: border-box; min-height: 56px; flex: 0 0 auto; - padding: var(--ran-space-2) var(--ran-space-4); + padding: var(--ran-space-2) var(--agent-content-inset); gap: calc(var(--ran-space-1) / 2); } .agent-panel-header .agent-panel-title { display: block; - flex: 1; + flex: 0 1 auto; width: auto; justify-content: flex-start; text-align: left; @@ -850,7 +905,11 @@ r-dropdown[role='listbox'] { font-size: var(--ran-text-copy-2); letter-spacing: -0.02em; min-width: 0; - padding-inline: var(--ran-space-2); + /* Extend the hit area into the gutter; text stays on the content rail. + Trailing free space belongs to layout, never to the title's hover fill. */ + padding-inline: var(--agent-title-inset); + margin-inline-start: calc(-1 * var(--agent-title-inset)); + margin-inline-end: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -864,7 +923,7 @@ r-dropdown[role='listbox'] { overflow: auto; flex: 1 1 auto; max-height: none; - padding: var(--ran-space-4); + padding: var(--agent-content-inset); gap: var(--ran-space-4); border-bottom: 0; scrollbar-width: thin; @@ -948,10 +1007,10 @@ r-dropdown[role='listbox'] { flex: 1; min-height: 0; overflow: auto; - padding: var(--ran-space-4); + padding: var(--agent-content-inset); } .agent-welcome { - padding: var(--ran-space-10) calc(var(--ran-space-6) + var(--ran-space-1)); + padding: var(--ran-space-6) var(--agent-content-inset); overflow: auto; } .agent-welcome h2 { @@ -1113,7 +1172,7 @@ r-dropdown[role='listbox'] { padding: var(--ran-space-4); } .agent-panel-header .agent-panel-title { - flex: 1; + flex: 0 1 auto; width: auto; font-size: var(--ran-text-copy-2); } @@ -1199,6 +1258,8 @@ r-dropdown[role='listbox'] { } .agent-panel-settings r-button { --ran-btn-padding: 0; + --ran-btn-box-shadow: none; + --ran-btn-surface-background: transparent; } .agent-panel-settings r-button::part(content) { box-sizing: border-box; @@ -1288,8 +1349,7 @@ r-dropdown[role='listbox'] { width: 100%; } .agent-model-cache { - border-top: 1px solid var(--ran-color-border); - padding-top: var(--ran-space-4); + min-width: 0; } .agent-model-cache > summary { cursor: pointer; @@ -1309,8 +1369,7 @@ r-dropdown[role='listbox'] { align-items: center; flex-wrap: wrap; gap: var(--ran-space-2); - padding-block: var(--ran-space-3); - border-bottom: 1px solid var(--ran-color-border); + padding-block: var(--ran-space-2); } .agent-model-cache-row > span { flex: 1 1 140px; @@ -1319,7 +1378,7 @@ r-dropdown[role='listbox'] { font-size: var(--ran-text-label-2); } .agent-cache-delete { - --ran-btn-color: var(--ran-color-danger); + --ran-btn-color: var(--app-danger-fill); } .agent-release-memory { align-self: flex-start; @@ -1328,8 +1387,6 @@ r-dropdown[role='listbox'] { .agent-preferences { color: var(--ran-color-text-secondary); font-size: var(--ran-text-label-3); - border-top: 1px solid var(--ran-color-border); - padding-top: var(--ran-space-4); } .agent-preferences .agent-settings-icon { display: none; @@ -2168,7 +2225,7 @@ r-dropdown[role='listbox'] { flex: 1; min-height: 0; overflow: auto; - padding: var(--ran-space-4); + padding: var(--agent-content-inset); } .agent-workspace-view[hidden], .agent-artifacts[hidden] { @@ -2231,11 +2288,6 @@ r-dropdown[role='listbox'] { cursor: pointer; text-align: start; } -.agent-workspace-view button, -.agent-artifacts button { - border: 1px solid var(--ran-color-border); - background: var(--ran-color-bg-elevated); -} .agent-panel .cui-composer .agent-sources-toggle { min-height: var(--ran-size-7); padding: var(--ran-space-1) var(--ran-space-2); @@ -2264,6 +2316,9 @@ r-dropdown[role='listbox'] { align-items: center; gap: var(--ran-space-2); margin-block-end: var(--ran-space-4); + /* The icon viewport returns to the content rail. Its centered hit target + occupies a separate leading navigation column, including coarse input. */ + margin-inline-start: calc(-1 * var(--agent-navigation-inset)); min-width: 0; } .agent-panel .agent-view-header h2 { @@ -2284,8 +2339,10 @@ r-dropdown[role='listbox'] { justify-content: center; box-sizing: border-box; flex: 0 0 auto; - min-width: var(--ran-size-7); - min-height: var(--ran-size-7); + width: var(--agent-navigation-size); + height: var(--agent-navigation-size); + min-width: var(--agent-navigation-size); + min-height: var(--agent-navigation-size); padding: var(--ran-space-2); border: 0; border-radius: var(--ran-radius-sm); @@ -2295,7 +2352,7 @@ r-dropdown[role='listbox'] { } .agent-panel .agent-view-back:focus-visible { outline: 2px solid var(--ran-color-primary); - outline-offset: 2px; + outline-offset: 0; } .agent-workspace-view button:active:enabled, .agent-artifacts button:active:enabled, @@ -2307,29 +2364,70 @@ r-dropdown[role='listbox'] { cursor: default; background: transparent; } -.agent-artifacts button:is([data-action='saveVersion'], [data-action='saveNew'], [data-action='accept']):enabled { +.agent-sources-view .agent-source-attach:enabled, +.agent-artifacts + button:is( + [data-action='import'], + [data-action='saveVersion'], + [data-action='saveNew'], + [data-action='accept'] + ):enabled { background: var(--ran-color-primary); border-color: 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 { +.agent-sources-view .agent-source-attach:hover:enabled, +.agent-artifacts + button:is( + [data-action='import'], + [data-action='saveVersion'], + [data-action='saveNew'], + [data-action='accept'] + ):hover:enabled { background: var(--ran-color-primary-hover); border-color: var(--ran-color-primary-hover); } .agent-artifacts - button:is([data-action='saveVersion'], [data-action='saveNew'], [data-action='accept']):active:enabled { + button:is( + [data-action='import'], + [data-action='saveVersion'], + [data-action='saveNew'], + [data-action='accept'] + ):active:enabled, +.agent-sources-view .agent-source-attach:active:enabled { background: var(--ran-color-primary-active); border-color: var(--ran-color-primary-active); } -.agent-artifacts button:is([data-action='delete'], [data-action='confirmDelete']):enabled { - color: var(--ran-color-danger); +.agent-source-remove:enabled, +.agent-artifacts button[data-action='delete']:enabled { + color: var(--app-danger-fill); +} +.agent-source-remove:hover:enabled, +.agent-artifacts button[data-action='delete']:hover:enabled { + color: var(--app-danger-hover); +} +.agent-source-remove:active:enabled, +.agent-artifacts button[data-action='delete']:active:enabled { + color: var(--app-danger-active); +} +.agent-artifacts button[data-action='confirmDelete']:enabled { + color: var(--app-danger-foreground); + background: var(--app-danger-fill); +} +.agent-artifacts button[data-action='confirmDelete']:hover:enabled { + background: var(--app-danger-hover); +} +.agent-artifacts button[data-action='confirmDelete']:active:enabled { + background: var(--app-danger-active); } .agent-artifacts > div > p { + margin: 0 0 var(--ran-space-4); font-size: var(--ran-text-label-3); color: var(--ran-color-text-secondary); line-height: 1.6; } .agent-artifacts-list button { + width: 100%; font-size: var(--ran-text-label-2); } @media (pointer: coarse) { @@ -2372,9 +2470,25 @@ r-dropdown[role='listbox'] { min-width: 0; overflow-wrap: anywhere; } +.agent-source-row { + align-items: flex-start; + padding-block: var(--ran-space-2); +} +.agent-sources-attached:not(:empty) { + margin-block-start: var(--ran-space-4); +} +.agent-sources-view .agent-source-preview > summary { + box-sizing: border-box; + min-height: var(--app-control-height); + padding-block: var(--ran-space-1); +} .agent-sources-attached:empty { display: none; } +.agent-sources-view > .agent-entry-note, +.agent-sources-status { + margin: 0 0 var(--ran-space-4); +} .agent-sources-summary { white-space: nowrap; overflow: hidden; @@ -2387,6 +2501,10 @@ r-dropdown[role='listbox'] { font-size: 0.875em; overflow-wrap: anywhere; } +.agent-sources-status:empty, +.agent-artifact-status:empty { + display: none; +} .agent-artifacts-list { display: flex; flex-direction: column; @@ -2394,6 +2512,9 @@ r-dropdown[role='listbox'] { padding: 0; margin: 0; } +.agent-artifacts-list:not(:empty) { + margin-block-end: var(--ran-space-4); +} .agent-artifact-editor > input { border: 0; border-radius: 0; @@ -2445,6 +2566,19 @@ r-dropdown[role='listbox'] { /* A narrow toolbar keeps a real language label when Stop joins Send. Use the select's public spacing hooks rather than losing its text lane. */ @container agent-panel (max-width: 360px) { + /* Keep the title readable when five touch targets no longer fit beside it. + A separate row preserves both a text-sized hover area and full targets. */ + .agent-runtime-panel .agent-panel-header { + display: grid; + grid-template-columns: repeat(5, var(--app-control-touch)); + justify-content: space-between; + } + .agent-runtime-panel .agent-panel-header .agent-panel-title { + grid-column: 1 / -1; + justify-self: start; + max-width: calc(100% + var(--agent-title-inset)); + margin-inline-end: 0; + } .agent-panel .cui-composer-bar, .agent-panel .cui-actions { gap: var(--ran-space-1); @@ -2715,6 +2849,8 @@ r-dropdown[role='listbox'] { .agent-panel .agent-panel-header button { min-width: var(--app-control-touch); min-height: var(--app-control-touch); + } + .agent-panel .agent-panel-header button:not(.agent-panel-title) { flex-basis: var(--app-control-touch); } .agent-panel .cui-composer .agent-sources-toggle, diff --git a/test/e2e/agent-history-actions.spec.ts b/test/e2e/agent-history-actions.spec.ts index 6020075d0..83537beee 100644 --- a/test/e2e/agent-history-actions.spec.ts +++ b/test/e2e/agent-history-actions.spec.ts @@ -1,5 +1,6 @@ import type { Locator, Page } from '@playwright/test'; import { expect, test } from './lib/l0'; +import { frameLeft, textGeometry } from './lib/layout-geometry'; async function openHistory(page: Page, locale = 'en') { await page.goto(`/editor?new=docx&locale=${locale}`); @@ -8,6 +9,7 @@ async function openHistory(page: Page, locale = 'en') { await page.locator('.agent-history-toggle').click(); await page.locator('.agent-history-management > summary').click(); await expect(page.locator('.agent-history-restore')).toBeEnabled(); + await page.evaluate(() => document.fonts.ready); } async function surface(button: Locator) { @@ -51,31 +53,64 @@ test.beforeEach(async ({ page }) => { }); for (const scheme of ['light', 'dark'] as const) { - test(`history commands have visible resting surfaces and safe deletion states in ${scheme}`, async ({ + test(`history command rows stay aligned and retain safe deletion states in ${scheme}`, async ({ page, browserName, }, info) => { test.setTimeout(120_000); await page.emulateMedia({ colorScheme: scheme }); + // Exercise the product's persisted theme choice as well as media emulation; + // a screenshot named "dark" must not silently accept a light token canvas. + await page.addInitScript((theme) => localStorage.setItem('ran-theme', theme), scheme); for (const locale of ['en', 'pt']) { await page.setViewportSize({ width: 1280, height: 900 }); await openHistory(page, locale); + await expect(page.locator('html')).toHaveAttribute('data-ran-theme', scheme); 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`) }); + await page.locator('.agent-runtime-panel').screenshot({ + path: info.outputPath(`history-pane-${locale}-${width}-${scheme}.png`), + }); + await options.scrollIntoViewIfNeeded(); + const section = (await options.boundingBox())!; + await page.screenshot({ + path: info.outputPath(`history-actions-${locale}-${width}-${scheme}.png`), + clip: { x: section.x - 8, y: section.y, width: section.width + 16, height: section.height }, + }); 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'); + const group = options.locator('.agent-history-transfer-actions'); + // Native button surfaces signal action before hover. Icon/checkbox and + // label columns align independently of the gutter-extended hit target. + const groupBox = (await group.boundingBox())!; + const optionsBox = (await options.boundingBox())!; + expect(groupBox.x).toBeCloseTo(optionsBox.x - 8, 1); + expect(await frameLeft(options.locator('.agent-history-save'))).toBeCloseTo(16, 1); + expect((await textGeometry(options.locator('label > span'))).textLeft).toBeCloseTo(40, 1); + const canvas = await page + .locator('.agent-runtime-panel') + .evaluate((el) => getComputedStyle(el).backgroundColor); + let previousBottom = 0; 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(css.background, 'resting button must differ from the canvas').not.toBe(canvas); expect(contrast(css.color, css.background)).toBeGreaterThanOrEqual(4.5); - expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(32); + const box = (await button.boundingBox())!; + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.x).toBeCloseTo(groupBox.x, 1); + expect(box.y).toBeGreaterThanOrEqual(previousBottom); + previousBottom = box.y + box.height; + const icon = button.locator('svg[aria-hidden="true"]'); + await expect(icon).toBeVisible(); + expect(await frameLeft(icon)).toBeCloseTo(16, 1); + const label = button.locator('.agent-history-action-label'); + const text = await textGeometry(label); + expect(text.textLeft).toBeCloseTo(40, 1); + expect(box.width).toBeLessThanOrEqual(text.textWidth + 41); + await expect(button).toHaveAccessibleName((await label.innerText()).trim()); expect(await button.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(1); } const transferBottom = Math.max( @@ -93,7 +128,7 @@ for (const scheme of ['light', 'dark'] as const) { const resting = await surface(restore); await restore.hover(); const hovering = await surface(restore); - expect(hovering.border).not.toBe(resting.border); + expect(hovering.background).not.toBe(resting.background); await page.mouse.down(); expect((await surface(restore)).background).not.toBe(hovering.background); await page.mouse.move(0, 0); @@ -114,12 +149,13 @@ for (const scheme of ['light', 'dark'] as const) { await danger.hover(); const dangerHover = await surface(danger); expect(dangerHover.color).not.toBe(hovering.color); - expect(dangerHover.border).toBe(restingDanger.border); + expect(restingDanger.borderWidth).toBe(0); + expect(dangerHover.borderWidth).toBe(0); 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(dangerActive.borderWidth).toBe(0); expect(contrast(dangerActive.color, dangerActive.background)).toBeGreaterThanOrEqual(4.5); await page.mouse.move(0, 0); await page.mouse.up(); diff --git a/test/e2e/agent-history-rendering.spec.ts b/test/e2e/agent-history-rendering.spec.ts index 3410e4a43..3edc91b4f 100644 --- a/test/e2e/agent-history-rendering.spec.ts +++ b/test/e2e/agent-history-rendering.spec.ts @@ -1,5 +1,6 @@ import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; +import { assertTitleRail, contentGeometry, textGeometry } from './lib/layout-geometry'; test('restores a long saved conversation and switches sessions with completed answer actions', async ({ page }) => { test.setTimeout(90_000); @@ -137,15 +138,16 @@ test('restores a long saved conversation and switches sessions with completed an 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(viewHeaderBox!.x - headerBox!.x)).toBeLessThanOrEqual(1); - const rowBox = await history.locator('.agent-conversation-title').first().boundingBox(); - expect(Math.abs(viewHeaderBox!.x - rowBox!.x)).toBeLessThanOrEqual(1); + await page.evaluate(() => document.fonts.ready); + await assertTitleRail(page.locator('.agent-runtime-panel .agent-panel-title')); + const geometry = await contentGeometry(history); + const row = await textGeometry(history.locator('.agent-conversation-title').first()); + // The Back hit area extends into the gutter; its SVG and row text share the content rail. + expect(geometry.contentLeft).toBeCloseTo(16, 1); + expect(geometry.iconLeft).toBeCloseTo(geometry.contentLeft, 1); + expect(row.textLeft).toBeCloseTo(geometry.contentLeft, 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); + expect(Math.abs(geometry.headingLeft - (geometry.backLeft + geometry.backWidth + 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') }); diff --git a/test/e2e/agent-interface-affordances.spec.ts b/test/e2e/agent-interface-affordances.spec.ts index 3edfedacd..6146c413e 100644 --- a/test/e2e/agent-interface-affordances.spec.ts +++ b/test/e2e/agent-interface-affordances.spec.ts @@ -1,18 +1,25 @@ +import type { Page } from '@playwright/test'; import { expect, test } from './lib/l0'; test.beforeEach(async ({ page }) => { await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); +}); + +async function openAssistant(page: Page) { 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-runtime-panel .agent-panel-title').click(); -}); +} for (const width of [360, 1280]) { for (const theme of ['light', 'dark'] as const) { test(`text controls and import fields remain recognizable at ${width}px in ${theme}`, async ({ page }, info) => { await page.setViewportSize({ width, height: 900 }); await page.emulateMedia({ colorScheme: theme }); + await page.addInitScript((value) => localStorage.setItem('ran-theme', value), theme); + await openAssistant(page); + await expect(page.locator('html')).toHaveAttribute('data-ran-theme', theme); for (const selector of ['.agent-panel-title', '.agent-task-action']) { const button = page.locator(`.agent-runtime-panel ${selector}`).first(); await button.hover(); @@ -26,7 +33,38 @@ for (const width of [360, 1280]) { await page.locator('.agent-workspace-toggle').click(); const artifacts = page.locator('.agent-artifacts'); const command = artifacts.getByRole('button', { name: 'Import structured result', exact: true }); - await expect(command).toHaveCSS('border-top-width', '1px'); + // Primary commands use a resting fill; only editable fields need a frame. + await page.mouse.move(0, 0); + const surface = await command.evaluate((el) => { + const css = getComputedStyle(el); + const luminance = (color: string) => { + const [r, g, b] = color + .match(/[\d.]+/g)! + .slice(0, 3) + .map(Number) + .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 foreground = luminance(css.color), + background = luminance(css.backgroundColor); + return { + background: css.backgroundColor, + canvas: getComputedStyle(el.closest('.agent-panel')!).backgroundColor, + contrast: (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05), + }; + }); + expect(surface.background).not.toBe('rgba(0, 0, 0, 0)'); + expect(surface.background).not.toBe(surface.canvas); + expect(surface.contrast).toBeGreaterThanOrEqual(4.5); + await command.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(command).toBeFocused(); + await expect(command).toHaveCSS('outline-style', 'solid'); + await expect(command).toHaveCSS('outline-width', '2px'); await command.click(); const title = artifacts.getByRole('textbox', { name: 'Title', exact: true }); const payload = artifacts.getByRole('textbox', { name: 'Import structured result', exact: true }); @@ -46,6 +84,7 @@ for (const width of [360, 1280]) { test(`speech overlays stay anchored and mutually exclusive at ${width}px`, async ({ page }, info) => { await page.setViewportSize({ width, height: 900 }); + await openAssistant(page); const privacy = page.locator('.agent-speech-privacy'); const toggle = privacy.locator('button'); const language = page.locator('.agent-speech-language'); @@ -81,6 +120,7 @@ for (const width of [360, 1280]) { test.describe('touch controls', () => { test.use({ hasTouch: true, viewport: { width: 360, height: 800 } }); test('assistant switch keeps a full touch target around its compact track', async ({ page }) => { + await openAssistant(page); await page.locator('.agent-panel-settings-toggle').click(); await page.getByText('More settings', { exact: true }).click(); const toggle = page.getByRole('switch', { name: 'Enable AI assistant', exact: true }); @@ -95,6 +135,7 @@ test.describe('touch controls', () => { await expect.poll(() => page.evaluate(() => localStorage.getItem('document-assistant-enabled'))).toBe('false'); }); test('structured import keeps a full touch target after labeling the title field', async ({ page }) => { + await openAssistant(page); await page.locator('.agent-workspace-toggle').click(); await page.getByRole('button', { name: 'Import structured result', exact: true }).click(); const title = page.getByRole('textbox', { name: 'Title', exact: true }); diff --git a/test/e2e/agent-layout-rails.spec.ts b/test/e2e/agent-layout-rails.spec.ts new file mode 100644 index 000000000..0fc5221f7 --- /dev/null +++ b/test/e2e/agent-layout-rails.spec.ts @@ -0,0 +1,259 @@ +import { expect, test } from './lib/l0'; +import { assertTitleRail, contentGeometry, frameLeft, selectionGeometry, textGeometry } from './lib/layout-geometry'; + +const variants = [ + { width: 1280, locale: 'en', coarse: false }, + { width: 1280, locale: 'pt', coarse: true }, + { width: 360, locale: 'en', coarse: false }, + { width: 320, locale: 'pt', coarse: true }, +]; + +test('assistant onboarding copy returns to the same content rail as its header', async ({ page }) => { + 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(); + const onboarding = page.locator('.agent-onboarding'); + await expect(onboarding).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + expect((await textGeometry(onboarding.locator('header strong'))).textLeft).toBeCloseTo(16, 1); + expect((await textGeometry(onboarding.locator('.agent-welcome h2'))).textLeft).toBeCloseTo(16, 1); + expect((await textGeometry(onboarding.locator('.agent-welcome > p').first())).textLeft).toBeCloseTo(16, 1); +}); + +test('layout acceptance rejects text-only drift, expanded hover and displaced sibling rails', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); + 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(); + const title = page.locator('.agent-runtime-panel .agent-panel-title'); + await expect(title).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + const baseline = await assertTitleRail(title); + // Total padding and the outer frame stay unchanged; only visible text moves. + await title.evaluate((el) => (el.style.paddingInline = '16px 0')); + expect((await textGeometry(title)).width).toBe(baseline.width); + await expect(assertTitleRail(title)).rejects.toThrow(); + await title.evaluate((el) => el.removeAttribute('style')); + await assertTitleRail(title); + await title.evaluate((el) => (el.style.flex = '1')); + await expect(assertTitleRail(title)).rejects.toThrow(); + await title.evaluate((el) => el.removeAttribute('style')); + const settings = page.locator('.agent-panel-settings'); + if (!(await settings.isVisible())) await page.locator('.agent-panel-settings-toggle').click(); + await expect(settings).toBeVisible(); + expect((await contentGeometry(settings)).contentLeft).toBe(16); + await settings.evaluate((el) => (el.style.paddingInlineStart = '24px')); + await expect((async () => expect((await contentGeometry(settings)).contentLeft).toBe(16))()).rejects.toThrow(); + await settings.evaluate((el) => el.removeAttribute('style')); + expect((await contentGeometry(settings)).contentLeft).toBe(16); +}); + +test('populated conversation and source disclosures return to the content rail', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent_history_default', JSON.stringify([{ role: 'user', content: 'Layout conversation' }])); + const now = Date.now(); + localStorage.setItem( + 'agent_sessions_v1', + JSON.stringify({ sessions: [{ id: 'default', title: 'Layout conversation', createdAt: now, updatedAt: now }] }), + ); + }); + 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-history-toggle').click(); + await page.locator('.agent-history-management > summary').click(); + await page.locator('.agent-history-import').click(); + const row = page.locator('.agent-conversation-title').filter({ hasText: 'Layout conversation' }); + await expect(row).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + expect((await textGeometry(row)).textLeft).toBeCloseTo(16, 1); + expect(await frameLeft(page.locator('.agent-history-management > summary'))).toBeCloseTo(16, 1); + await page.locator('.agent-panel-title').click(); + await page.locator('.agent-sources-toggle').click(); + const sources = page.locator('.agent-sources-view'); + await sources.locator('input[type="file"]').setInputFiles({ + name: 'layout-notes.txt', + mimeType: 'text/plain', + buffer: Buffer.from('A local reference excerpt.'), + }); + const preview = sources.locator('.agent-source-preview'); + await expect(preview).toContainText('layout-notes.txt'); + expect(await frameLeft(preview)).toBeCloseTo(16, 1); + await preview.locator('summary').click(); + await expect(preview.locator('pre')).toHaveText('A local reference excerpt.'); + expect((await textGeometry(preview.locator('pre'))).textLeft).toBeCloseTo(16, 1); + const status = sources.locator('.agent-sources-status'); + if (await status.isVisible()) expect(await frameLeft(status)).toBeCloseTo(16, 1); +}); + +for (const variant of variants) { + test.describe(`${variant.width}px ${variant.locale}`, () => { + test.use({ viewport: { width: variant.width, height: 900 }, hasTouch: variant.coarse }); + test('assistant text, navigation and content rails', async ({ page }, testInfo) => { + test.setTimeout(180_000); + 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=${variant.locale}`); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + const panel = page.locator('.agent-runtime-panel'); + const title = panel.locator('.agent-panel-title'); + await expect(title).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + for (const theme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme: theme }); + const titleBox = await assertTitleRail(title); + if (variant.width <= 360 || variant.coarse) + expect(titleBox.width).toBeGreaterThanOrEqual(Math.min(titleBox.textWidth + 16, variant.width - 24)); + await title.hover(); + await expect(title).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await title.click(); + await expect(panel).toHaveAttribute('data-view', 'chat'); + const originalTitle = await title.textContent(); + await title.evaluate((el) => (el.textContent = 'A long translated assistant workspace title '.repeat(4))); + await assertTitleRail(title); + const actions = await panel.locator('.agent-panel-header button').evaluateAll((buttons) => { + const bounds = buttons[0].closest('.agent-panel')!.getBoundingClientRect(); + return buttons.map((button) => { + const rect = button.getBoundingClientRect(); + return { + left: rect.left - bounds.left, + right: rect.right - bounds.left, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + panelWidth: bounds.width, + }; + }); + }); + for (const action of actions) { + expect(action.left).toBeGreaterThanOrEqual(0); + expect(action.right).toBeLessThanOrEqual(action.panelWidth); + if (variant.coarse) { + expect(action.width).toBeGreaterThanOrEqual(44); + expect(action.height).toBeGreaterThanOrEqual(44); + } + } + for (let index = 0; index < actions.length; index++) { + for (const next of actions.slice(index + 1)) { + const current = actions[index]; + const overlapX = Math.max(0, Math.min(current.right, next.right) - Math.max(current.left, next.left)); + const overlapY = Math.max(0, Math.min(current.bottom, next.bottom) - Math.max(current.top, next.top)); + expect(overlapX * overlapY, 'header hit areas must not overlap').toBe(0); + } + } + await title.evaluate((el, text) => (el.textContent = text), originalTitle); + await page.locator('.cui-input').fill('Preserve this draft while checking every view'); + + for (const [toggle, selector, frame] of [ + ['.agent-panel-settings-toggle', '.agent-panel-settings', '.agent-source-picker'], + ['.agent-history-toggle', '.agent-history-view', '.agent-history-search-row'], + ['.agent-sources-toggle', '.agent-sources-view', 'input[type="search"]'], + ['.agent-workspace-toggle', '.agent-artifacts', ':scope > div:not(.agent-view-header)'], + ]) { + await panel.locator(toggle).click(); + const view = panel.locator(selector); + const back = view.locator('.agent-view-back'); + await expect(back).toBeVisible(); + const geometry = await contentGeometry(view); + expect(geometry.contentLeft, selector).toBeCloseTo(16, 1); + expect(geometry.iconLeft, selector).toBeCloseTo(16, 1); + expect(geometry.headingLeft, selector).toBeCloseTo(variant.coarse ? 54 : 48, 1); + expect(geometry.backWidth, selector).toBe(variant.coarse ? 44 : 32); + expect(geometry.backLeft, selector).toBeGreaterThanOrEqual(0); + expect(geometry.overflow, selector).toBeLessThanOrEqual(1); + expect(await frameLeft(view.locator(frame).first()), selector).toBeCloseTo(16, 1); + const firstContent = view + .locator( + selector === '.agent-sources-view' + ? ':scope > .agent-entry-note' + : selector === '.agent-artifacts' + ? ':scope > div:not(.agent-view-header) > :is(p, ul)' + : frame, + ) + .first(); + const firstTop = await firstContent.evaluate((el) => el.getBoundingClientRect().top); + expect(firstTop - geometry.headerBottom, `${selector} first content group`).toBe(16); + const heading = view.locator('.agent-view-header h2'); + const originalHeading = await heading.textContent(); + await heading.evaluate( + (el) => (el.textContent = 'A translated workspace heading with several lines '.repeat(3)), + ); + expect((await contentGeometry(view)).overflow, `${selector} long heading`).toBeLessThanOrEqual(1); + await heading.evaluate((el, text) => (el.textContent = text), originalHeading); + if (selector === '.agent-panel-settings') { + for (const control of await view.locator('r-select:visible').all()) { + const selection = await selectionGeometry(control); + expect(selection.inset).toBeCloseTo(13, 1); + expect(selection.clipped).toBe(false); + expect(selection.partInside).toBe(true); + } + } + if (selector === '.agent-sources-view') { + const gap = await view.evaluate((el) => { + const excerpt = el.querySelector('.agent-entry-note')!.getBoundingClientRect(); + return el.querySelector('input[type="search"]')!.getBoundingClientRect().top - excerpt.bottom; + }); + expect(gap, 'source intro and search form a separate group').toBe(16); + } + await panel.screenshot({ path: testInfo.outputPath(`${selector.slice(1)}-${theme}.png`) }); + await page.keyboard.press('Tab'); + await back.focus(); + expect(await back.evaluate((el) => el.matches(':focus-visible'))).toBe(true); + const focus = await back.evaluate((el) => { + const style = getComputedStyle(el); + return { width: Number.parseFloat(style.outlineWidth), offset: Number.parseFloat(style.outlineOffset) }; + }); + expect(geometry.backLeft, `${selector} focus ring inside scrollport`).toBeGreaterThanOrEqual( + focus.width + focus.offset, + ); + await back.press('Enter'); + await expect(panel).toHaveAttribute('data-view', 'chat'); + await expect(page.locator('.cui-input')).toHaveValue('Preserve this draft while checking every view'); + } + + // Import and editor return states share the same header and content owner. + await panel.locator('.agent-workspace-toggle').click(); + const artifacts = panel.locator('.agent-artifacts'); + const importing = artifacts.locator('[data-action="import"]'); + await expect(importing).toBeVisible(); + expect(await frameLeft(importing)).toBeCloseTo(16, 1); + if (await artifacts.locator('.agent-artifacts-list > li').count()) { + const gap = await importing.evaluate((el) => { + const list = el.parentElement!.querySelector('.agent-artifacts-list')!; + return el.getBoundingClientRect().top - list.getBoundingClientRect().bottom; + }); + expect(gap, 'saved results and the import action form separate groups').toBe(16); + } + const emptyCopy = artifacts.locator(':scope > div:not(.agent-view-header) > p'); + if (await emptyCopy.count()) expect((await textGeometry(emptyCopy)).textLeft).toBeCloseTo(16, 1); + await importing.click(); + const form = artifacts.locator('.agent-artifact-import'); + const input = form.locator('input'); + await expect(input).toBeVisible(); + for (const field of await form.locator('input, textarea, label > span').all()) + expect(await frameLeft(field)).toBeCloseTo(16, 1); + expect((await contentGeometry(artifacts)).iconLeft).toBeCloseTo(16, 1); + await input.fill('Layout fixture'); + await form + .locator('textarea') + .fill( + JSON.stringify({ schemaVersion: 1, payload: { kind: 'text', text: 'A result retained on this device.' } }), + ); + await form.locator('[data-action="saveNew"]').click(); + const editor = artifacts.locator('.agent-artifact-editor:not(.agent-artifact-import)'); + await expect(editor.locator('textarea').first()).toBeVisible(); + expect(await frameLeft(editor.locator('input').first())).toBeCloseTo(16, 1); + expect((await contentGeometry(artifacts)).headingLeft).toBeCloseTo(variant.coarse ? 54 : 48, 1); + await panel.screenshot({ path: testInfo.outputPath(`artifact-editor-${theme}.png`) }); + await artifacts.locator('.agent-view-back').click(); + await expect(artifacts.locator('[data-action="import"]')).toBeVisible(); + await artifacts.locator('.agent-view-back').click(); + } + }); + }); +} diff --git a/test/e2e/agent-sidebar-layout.spec.ts b/test/e2e/agent-sidebar-layout.spec.ts index f042668e2..e080200f3 100644 --- a/test/e2e/agent-sidebar-layout.spec.ts +++ b/test/e2e/agent-sidebar-layout.spec.ts @@ -36,7 +36,9 @@ for (const width of [320, 1280]) { await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`empty-${width}.png`) }); const alignment = await page.locator('.agent-runtime-panel').evaluate((panel) => { const left = (selector: string) => panel.querySelector(selector)!.getBoundingClientRect().left; - return [left('.agent-panel-title'), left('.cui-empty'), left('.cui-composer')]; + const range = document.createRange(); + range.selectNodeContents(panel.querySelector('.agent-panel-title')!); + return [range.getBoundingClientRect().left, left('.cui-empty'), left('.cui-composer')]; }); expect(Math.max(...alignment) - Math.min(...alignment)).toBeLessThanOrEqual(1); await page.locator('.agent-panel-settings-toggle').click(); diff --git a/test/e2e/agent-sources-artifacts.spec.ts b/test/e2e/agent-sources-artifacts.spec.ts index 1d7f04494..140b72491 100644 --- a/test/e2e/agent-sources-artifacts.spec.ts +++ b/test/e2e/agent-sources-artifacts.spec.ts @@ -58,6 +58,8 @@ test('local sources and versioned artifacts remain explicit, compact and reviewe .locator('.agent-sources-view input[type=file]') .setInputFiles({ name: 'notes.md', mimeType: 'text/markdown', buffer: Buffer.from('Project reference') }); await expect(page.locator('.agent-source-row')).toContainText('notes.md'); + await page.locator('.agent-source-row summary').click(); + await expect(page.locator('.agent-source-preview pre')).toHaveText('Project reference'); await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('sources.png') }); await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); await expect(page.locator('.agent-sources-summary')).toHaveText('notes.md'); @@ -73,6 +75,7 @@ test('local sources and versioned artifacts remain explicit, compact and reviewe await expect(text).toHaveValue(source); await page.getByRole('button', { name: 'Save version', exact: true }).click(); await expect(page.locator('.agent-artifact-editor select option')).toHaveCount(3); + await page.locator('.agent-artifact-presentation > details > summary').click(); await page.setViewportSize({ width: 360, height: 700 }); for (const theme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: theme }); @@ -81,6 +84,11 @@ test('local sources and versioned artifacts remain explicit, compact and reviewe .toBeLessThan(2); await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`artifacts-${theme}.png`) }); } + await page.locator('.agent-artifacts [data-action=delete]').click(); + await expect(page.locator('.agent-artifacts [data-action=confirmDelete]')).toBeVisible(); + await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('artifact-delete-confirm.png') }); + await page.locator('.agent-artifacts [data-action=cancel]').click(); + await expect(text).toHaveValue(source); await page.getByRole('button', { name: 'Review in document', exact: true }).click(); await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); await expect(page.locator('.agent-plan-preview')).toBeVisible(); diff --git a/test/e2e/agent-workspace-consistency.spec.ts b/test/e2e/agent-workspace-consistency.spec.ts index b761e2d8c..6dd24e91a 100644 --- a/test/e2e/agent-workspace-consistency.spec.ts +++ b/test/e2e/agent-workspace-consistency.spec.ts @@ -161,7 +161,9 @@ 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('cached-model delete keeps its danger role through confirmation and pending deletion', async ({ + page, +}, testInfo) => { test.setTimeout(90000); await page.addInitScript(() => { localStorage.setItem('document-assistant-enabled', 'true'); @@ -178,7 +180,7 @@ test('cached-model delete keeps its danger role through confirmation and pending 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 }; + return { color: style.color, background: style.backgroundColor, shadow: getComputedStyle(button).boxShadow }; }, }); // This case checks the UI's pending deletion state. Use the real Cache API @@ -220,12 +222,20 @@ test('cached-model delete keeps its danger role through confirmation and pending 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' }); + await page.locator('.agent-preferences > summary').click(); + for (const theme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme: theme }); + await page + .locator('.agent-runtime-panel') + .screenshot({ path: testInfo.outputPath(`models-expanded-${theme}.png`) }); + } + await page.emulateMedia({ colorScheme: 'light' }); 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: Element): { color: string; background: string; shadow: string }; } ).assistantCommandStyle(button), ); @@ -238,8 +248,9 @@ test('cached-model delete keeps its danger role through confirmation and pending probe.remove(); return color; }, token); - const danger = await tokenColor('--ran-color-danger'); + const danger = await tokenColor('--app-danger-fill'); const disabled = await tokenColor('--ran-color-text-disabled'); + expect((await surface()).shadow).toBe('none'); await expect.poll(async () => (await surface()).color).toBe(danger); const idle = (await surface()).background; await remove.hover(); @@ -247,6 +258,7 @@ test('cached-model delete keeps its danger role through confirmation and pending expect((await surface()).background).not.toBe(idle); await remove.click(); await expect(remove).toHaveText('Confirm deletion'); + await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('models-confirm-delete.png') }); expect((await surface()).color).toBe(danger); const cancel = row.locator('.agent-cache-cancel'); expect((await surface(cancel)).background).toBe('rgba(0, 0, 0, 0)'); diff --git a/test/e2e/lib/layout-geometry.ts b/test/e2e/lib/layout-geometry.ts new file mode 100644 index 000000000..756c03044 --- /dev/null +++ b/test/e2e/lib/layout-geometry.ts @@ -0,0 +1,92 @@ +import { expect, type Locator } from '@playwright/test'; + +/** Read text independently of its control's padding and flexible layout frame. */ +export async function textGeometry(locator: Locator) { + return locator.evaluate((element) => { + const panel = element.closest('.agent-panel')!; + const origin = panel.getBoundingClientRect().left + Number.parseFloat(getComputedStyle(panel).borderLeftWidth); + const frame = element.getBoundingClientRect(); + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const range = document.createRange(); + let first: Text | undefined; + let last: Text | undefined; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (!node.textContent?.trim()) continue; + first ??= node; + last = node; + } + if (!first || !last) throw new Error('The measured control has no text'); + range.setStart(first, 0); + range.setEnd(last, last.length); + const text = range.getBoundingClientRect(); + return { + left: frame.left - origin, + right: frame.right - origin, + width: frame.width, + height: frame.height, + textLeft: text.left - origin, + textWidth: text.width, + }; + }); +} + +/** The same acceptance guard is exercised against deliberate live CSS regressions. */ +export async function assertTitleRail(title: Locator) { + const geometry = await textGeometry(title); + expect(geometry.textLeft).toBeCloseTo(16, 1); + expect(geometry.width).toBeLessThanOrEqual(geometry.textWidth + 17); + return geometry; +} + +export async function contentGeometry(view: Locator) { + return view.evaluate((element) => { + const panel = element.closest('.agent-panel')!; + const origin = panel.getBoundingClientRect().left + Number.parseFloat(getComputedStyle(panel).borderLeftWidth); + const left = (node: Element) => node.getBoundingClientRect().left - origin; + const header = element.querySelector('.agent-view-header')!; + const back = header.querySelector('.agent-view-back')!; + const heading = header.querySelector('h2')!; + const style = getComputedStyle(element); + return { + contentLeft: left(element) + Number.parseFloat(style.paddingLeft), + headingLeft: left(heading), + iconLeft: left(back.querySelector('svg')!), + backLeft: left(back), + backWidth: back.getBoundingClientRect().width, + overflow: element.scrollWidth - element.clientWidth, + headerBottom: header.getBoundingClientRect().bottom, + }; + }); +} + +/** Frame alignment is distinct from a framed field's deliberately inset text. */ +export async function frameLeft(locator: Locator) { + return locator.evaluate((element) => { + const panel = element.closest('.agent-panel')!; + return ( + element.getBoundingClientRect().left - + panel.getBoundingClientRect().left - + Number.parseFloat(getComputedStyle(panel).borderLeftWidth) + ); + }); +} + +/** ranui/testing's documented closed-root seam; inspect the real public part. */ +export async function selectionGeometry(control: Locator) { + return control.evaluate((element) => { + const root = (element as HTMLElement & { _shadowDom: ShadowRoot })._shadowDom; + const label = root?.querySelector('[part~="selection-item"]'); + if (!label?.textContent?.trim()) throw new Error('The ranui selection has not rendered'); + const frame = element.getBoundingClientRect(); + const part = label.getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(label); + const text = range.getBoundingClientRect(); + return { + inset: text.left - frame.left, + clipped: text.right > part.right + 1, + partInside: part.left >= frame.left && part.right <= frame.right, + }; + }); +}