* feat(ui): publish carapace embed font stacks to MCP apps
Adopt the carapace embed contract's sandbox-safe font stacks: publish
--font-sans for the first time and switch --font-mono from the host's
JetBrains-led token to the embed mono stack. Both are static, system-
resolvable values byte-identical to carapace candidate/embed.css, since
the sandbox font-src policy silently drops brand faces.
* feat(ui): emit data-theme-resolved alongside theme attributes
Carapace CSS selects on [data-theme-resolved]; keep it in lockstep with
data-theme-mode at boot (index.html IIFE) and on every runtime theme
change (bootstrap publish path). Rename applyStartupPresentation to
applyThemePresentation: it runs on every theme change, not just startup.
* fix(ui): resolve undefined --success/--warning tokens and drop dead hex fallbacks
--success and --warning were never defined (real tokens: --ok/--warn),
so sites with literal fallbacks rendered off-palette one-off colors and
sites without them silently dropped declarations (invalid color-mix in
the chat sidebar warn banner). Map all uses to the semantic tokens
across board, chat sidebar/layout, layout, and components styles, and
strip the stale dead var() hex fallbacks in these global stylesheets.
Code-syntax palette hexes in the file view are documented as deliberate.
Shadow-DOM components carried fallback colors that never matched the
actual tokens: panel-tab-strip fell back to teal #4ec9a8 for the coral
--accent, resizable-divider to Bootstrap blue #007bff, and --border to a
generic #333. Align all fallbacks with the claw-family values in
styles/base.css so token-less rendering matches the product palette.
Also document the intentionally dark image-lightbox chrome and the
terminal theme's hand-mirrored token values, and regenerate the drifted
values in ui/docs/design-system/color-tokens.md (--muted, --muted-strong,
--primary, --danger, --info, light status colors) from base.css truth.
Locked script/command automations rendered their source in a plain textarea. They now render as a resizable, highlighted read-only code block reusing the existing highlight.js path.
* perf(ui): refresh the session list once per batch action
Batch sidebar actions patched each row and then forced a full sessions.list
replacement per row, so archiving a nine-row multi-select paid nine list
rebuilds on top of nine patches. Measured against the gateway handlers on a
60-session store, sessions.list costs ~2.9s versus ~176ms for sessions.patch.
Batch rows now pass deferListRefresh and each batch helper issues one refresh
per owning agent after its last row; pushed sessions.changed events keep rows
reconciled while the batch runs. Single-row actions are unchanged.
* test(ui): prove batch archive costs one list refresh in the browser
Adds a Control UI e2e case that cmd-clicks three sidebar rows, archives the
selection from the batch menu, and asserts the Gateway saw three sessions.patch
calls in row order plus exactly one sessions.list. It holds past the batch so a
late per-row refresh would still be caught.
Before the batch-refresh change this flow issued one full sessions.list per
archived row.
* feat(ui): repair settings titles, scope form-unsafe banner, consolidate advanced tier
- Advanced/Notifications settings headers rendered raw i18n keys
(tabs.advanced/tabs.notifications); configPageTitle now resolves through
the nav registry titleForRoute, deleting the drifting key map.
- Form-unsafe banner is value-aware (only when the user's config has a value
at a form-unsupported path in the active scope), names the paths, offers an
Open Raw editor action, and sits in the content column instead of full-bleed.
- Advanced tier collapses to one synced toggle: hidden advanced fields render
a ghost row that enables the toggle; the Advanced page always reveals and
hides the toggle; per-section details state and the controlled-open dance
are deleted; toggle visibility mirrors the renderer's tier split.
- schema.tags no longer tags facet-less paths as "advanced" (common fields
like update.channel wore a lying chip); the chip is no longer rendered in
form rows while tag:advanced search keeps working.
* fix(config): dev bootstrap writes canonical agents.entries; finish stale agents.list sweep
- openclaw gateway run --dev failed on a fresh state dir: the bootstrap wrote
the retired agents.list array and validation rejected its own config
(Unrecognized key: list). It now writes the keyed agents.entries record;
new dev.test.ts validates the written shape against the zod schema.
- Revive the dead gateway hot-reload rule: prefix agents.list never matched
canonical config diffs, so per-agent heartbeat edits fell through to the
agents:none tail rule; the rule now matches agents.entries.
- config set replacement protection moves from the dead agents.list array
to the agents.entries map (joins plugins.entries/auth.profiles family).
- Sweep remaining stale agents.list config-path strings in hints, fix-it
messages, elevated gates, doctor hints, audit text, and type docs to
agents.entries.*; RPC ids, the internal list projection, and doctor
legacy-migration references intentionally keep the old name.
* test(ui): derive nav i18n audit from route registry; fix stale bootstrap hint assertion
- Knip flagged navigationCopyEntries (test-only export); the audit now walks
ALL_ROUTES through prod titleForRoute/subtitleForRoute and rejects raw
dotted-key output, so no export exists solely for the test.
- commands-context-report expected the retired agents.list[] hint wording.
* fix(config): align rebase resolution with main's roster-aware diagnostic
- tool-policy-diagnostic: keep main's test expectations (dotted
agents.entries.<id> paths from the roster-aware implementation); our
pre-rebase bracket-style assertions no longer match any code.
- Reapply the placeholder sweep the conflict resolution dropped:
generic agents.list[] fallbacks in tool-policy-diagnostic and the audit
sandbox-mode hint now say agents.entries.*.
* fix(ui): meet WCAG AA for Control UI status tokens
Status labels (--ok/--warn/--danger/--info) are rendered as text on their
own 8% -subtle tint. Composited over --card and --bg, several pairs fell
below WCAG AA 4.5:1 -- every light-mode token, and --danger/--info in dark.
Retune the failing bases one Tailwind step and re-derive the matching
-subtle/-muted rgba so each pair clears 4.5:1 on card and bg across all six
theme families. Add the missing --info-subtle and adopt it in
.session-avatar--group, whose --ok/--warn siblings were already tokenised.
Non-text uses (status dots, meters, chart bars) only gain contrast, and the
one solid --danger fill carrying white text improves 4.83:1 -> 6.47:1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(ui): state the bound the status contrast audit actually proves
The audit comments named their card/bg bound but not what falls outside it, so
they read as a general guarantee at the exact site the next contributor will
trust. Status labels also land on hover, muted and input surfaces, and one of
those is reachable with real text: the plugins error row sits on a hovered
background at 4.28:1 under dash-light. It improves on the 3.23:1 it replaced,
but it is not AA and the comment should not imply otherwise.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* refactor(media): read persisted media facts first across all consumers
PR 2 of the media legacy retirement program. Gateway display projection,
Control UI (history filtering, attachment rendering, initial-turn
handoff), embedded and plugin-harness hydration, replay/prune, late-media
reconstruction, hooks, media understanding, and trajectory capture now
read nested __openclaw.media facts first. Legacy top-level Media* fields
survive only in the narrow persisted-message compatibility reader (rows
predating the PR-1 dual-write) and the public SDK projection.
Non-goals pinned by tests: rendered batches, reply-payload dedupe,
durable session/outbound queues, and lowercase ReplyPayload.mediaUrl(s)
are untouched contracts.
Consumer matrix covers {legacy-only, facts-only, both-equal,
both-conflict, sparse, type-only, media-only} inputs across every
consumer family; prompt/cache goldens byte-identical; 131-file legacy
consumer sweep run individually (two failures reproduce unchanged on
clean main); 1,047 broad media tests and the full UI lane (5,553) green.
* refactor(media): drop caller-less staging predicate and localize empty-user-row classification
The consumer flip left hasStagedMediaProjection without production
callers (facts-only staging uses hasStagedMediaFacts); delete it and
retest the staged merge matrix against the facts predicate. Unexport
LEGACY_MEDIA_CONTEXT_KEYS (internal only; the derived type stays public).
Move isEmptyUserTextOnlyMessage beside the other message-shape
classifiers in ui/src/lib/chat/message-extract.ts where chat-history
imports it cross-module.
* chore(ui): drop unused hasTranscriptMediaFacts import after predicate move
* chore(ui): localize hasTranscriptMediaFacts after its last external consumer moved in
* style: format staged-media matrix assertion
* feat(ui): publish host style variables to MCP apps
An embedded MCP app received only the `theme` string, so it knew which
appearance was active but nothing about what that appearance resolves to.
Every app therefore had to ship its own palette, and an app installed through
a plugin looked like itself rather than like the surface hosting it.
Publish the Control UI theme as `hostContext.styles.variables`, the field the
MCP Apps specification defines for exactly this. The key set is closed by the
specification, so the mapping is a table from Control UI custom properties to
specification keys; the canonical meaning of each key lives in the carapace
embed contract.
Only keys Control UI can honestly source are published. The specification
lets a host publish any subset and apps resolve the rest from their own
fallbacks, so omitting is preferable to inventing. The body font is
deliberately omitted: it leads with a webfont, and an embedded app may load
fonts only from resource domains it declares itself, so publishing it would
silently resolve to an arbitrary system face. Apps own their sans stack until
Control UI adopts the carapace embed tokens, which define a sandbox-safe one.
Values are read as computed custom properties so nested references are
substituted before crossing into the app's separate origin, where a Control
UI token name would have nothing to resolve against.
Live updates come free: the existing theme subscription already re-sends host
context, and `theme` continues to be published alongside this.
* fix(ui): align MCP app host theme semantics
System-notice rows (local slash-command output such as /help) now render
through the shared sanitized markdown pipeline (code-block chrome off,
DOMPurify allowlist, breaks preserved) instead of literal pre-wrap text,
so authored bold/inline-code formatting displays properly. Block content
(lists, pre, blockquote) centers as a block but reads left-aligned.
Follow-up to #112938.
* feat(ui): unify sidebar footer into full-width identity card
Footer becomes one card (avatar + name + chevron) mirroring the top agent
card; identity menu gains email header, Settings (with platform shortcut
hint), and Usage; Usage leaves default sidebar pins; settings shortcut now
also binds Ctrl+Shift+Comma; offline state lives in the card subtitle and
menu retry action and the card persists while disconnected.
* test(ui): align persisted-sidebar fallback default with usage removal
* feat(ui): merge creator avatar into sidebar leading slot and move creator filter into Threads menu
The per-row creator chip previously rendered next to the leading state
indicator, so rows with a known creator started their titles at a
different x-offset than rows without one. The avatar now occupies the
single fixed leading slot: unread renders as a corner badge on it,
running as a spinner ring, and open/merged PR state as a colored corner
badge, while attention and pinned icons keep the slot. Child rows keep
their status badges and no longer render owner chips.
The standalone 'Filter by creator' select above the session list is
gone; the Threads funnel menu gains a People radio section with owner
chips, and the funnel trigger shows an accent dot while a creator
filter is active. The empty Threads header stays visible when ownership
chrome is active so the filter can always be cleared. Mock dev fixtures
gain two creator identities so the ownership chrome is demonstrable.
* test(ui): guard parent row spread in child-avatar ownership case