## Summary
The apps did not set `<html lang>` to the UI locale. stage-tamagotchi
had no `lang`, and stage-web and stage-pocket kept `lang="en"`. The base
font stack has no CJK font. So Chromium picks the fallback font for CJK
text from the document language, and without a matching `lang`, the OS
language decides.
On a macOS with Japanese first in the language list, Simplified Chinese
settings labels mixed two fonts. Hiragino Kaku Gothic W3 rendered the
characters that it has. Simplified-only characters, such as 设, 备, and 对,
fell back to PingFang SC Medium, so they looked bold.
The document language now follows the UI locale. Chromium then picks one
font for each locale, for example PingFang SC for `zh-Hans` and PingFang
TC for `zh-Hant`.
## Changes
- `modules/i18n.ts` of each app sets `document.documentElement.lang` to
the initial locale.
- The code that applies a language change also sets it. This is
`useLanguage` in stage-tamagotchi, and the `settings.language` watcher
in `App.vue` of stage-web and stage-pocket.
- `use-language.test.ts` moves to browser mode as
`use-language.browser.test.ts`. `useLanguage` now uses `document`, and
the node test project has no DOM. The test now uses the real
`localStorage` instead of a `globalThis` stub.
## Verification
Measured on macOS with the language list `ja-US, en-US, zh-Hans-US`. The
Electron 43 build of this repository rendered one Simplified Chinese
label under each `lang` on a test page. `CSS.getPlatformFontsForNode`
reported these fonts:
| `lang` | Fonts for "向所有设备开放 配对令牌" |
| --- | --- |
| none, `en` | Hiragino Kaku Gothic ProN W3 ×8, PingFang SC Medium ×3 |
| `zh-Hans`, `zh-CN` | PingFang SC Medium ×11 |
| `zh-Hant` | PingFang TC Medium ×11 |
- The commit hook ran `moeru-lint --fix`.
- Typecheck and tests did not run locally. CI runs them, including the
moved browser test.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
The settings search from #3112 decided at build time what it lists. Its
Vite plugin found `<page>.search.ts` files by name and read provider
definitions to find their views. The route meta fixed the settings of
each page. So a feature flag or a cloud policy could not add a feature
to the search or remove one.
This PR moves that decision to runtime. The build reads facts of the
templates only. Modules register to a registry, as services do with
injeca.
The PR also adds arrow keys to the results and makes the search field
larger.
## How it works
- **Registry**: `settingsSearch` from
`@proj-airi/stage-ui/libs/settings-search`.
- A module holds settings routes. A route also holds the routes below
it, unless another module names one of them.
- A route can list runtime items, such as MCP servers. It can also give
a condition to a setting on its page.
- `when` turns off a whole module, for example behind a feature flag.
Search then lists none of its routes, settings, or items. Search reads
`when` each time the results change.
- A route is in the search only when a module holds it, and every module
that holds it is on.
- **Composition**: each app provides its modules in `main.ts`.
- `settingsSearchModules` from `@proj-airi/stage-pages/settings-search`
has the modules of the shared pages.
- An app that replaces a shared page provides its own module under the
same name.
- stage-tamagotchi adds MCP and Home Assistant. kirie adds MCP, and its
data page lists no items. stage-web adds `/settings/characters`.
- **Build**: `createSettingsSearch` keeps the template scan, the gates,
and the row markers.
- It no longer finds `.search.ts` files or reads `defineProvider`. The
virtual provider table module is gone.
- A `?settings-search` import gives the settings of one component.
Providers list the settings of their views in `viewSearchEntries`.
- It writes `searchItemRows` when a page marks rows of runtime items.
- **Search**: `useSettingsSearch(query)` reads the registry only. The
provider pages moved from it to the providers module.
- **Checks**: in development, the settings home reports routes that no
module holds. It also reports pages that mark item rows when their
module lists no items. A production build no longer checks the item
lists, because the build does not know the modules.
- **Arrow keys**: the results use the roving focus of reka-ui.
- Tab enters at the last focused result. Home and End go to the first
and the last result.
- The arrow keys move by position, because the cards stand in columns.
Up and Down stay in the column. Left and Right go to the nearest column.
- **Search field**: `SettingsSearchField` gets `size`.
- The default `lg` has the height and the text size of the result rows
below it.
- The character cards page uses `md`, at the height of its sort select.
## Visual changes
Captured on stage-web at 1440 × 900. The before image comes from the
first commit of this branch, where `SettingsSearchField` is the same as
on `main`.
| Before | After |
|---|---|
|

|

|
| Settings / Search results for "model" | Settings / Search results for
"model" |
## Verification
- `pnpm exec vite build` in `apps/stage-web`, three times: passed.
- The `?settings-search` modules hold the settings of the Sherpaw and
Apple Speech views.
- `searchItemRows` is on 14 routes.
- With the hashes removed from file names, two builds differ only in the
build time from `unplugin-info`, and in `sw.js`, which lists the hashed
files.
- The commit hook ran `moeru-lint --fix` on each commit.
- New unit test: `packages/stage-ui/src/libs/settings-search.test.ts`.
CI runs it with the typecheck.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
This PR ports the floating chat page of stage-tamagotchi from #2663 to
Kirie. The port includes the later danmaku style (#2704, #2809) and dock
(#3060). It does not port the native glass background (#3020) or the
fullscreen mode (#2926).
The Godot host follows the Electron main process:
`floating-chat-host.cs` matches `floating.ts`,
`floating-chat-placement.cs` matches `floating-placement.ts` with its
tests, and `chat-mode-switch.cs` matches `mode-switch.ts`. The
preferences use the Electron defaults in `user://chat-window.cfg`. These
parts of Kirie differ from Electron, or needed a fix to work:
- **Attached chat during a drag (macOS)**: the window server drags the
main window by itself. A chat that followed the main window on each
frame moved about 9 times a second, while the main window moved at the
display rate. On macOS, an attached chat is now a child window of the
main window (`Window.Transient`, which calls `addChildWindow`), so the
window server moves both. Godot does not link a window that stays on
top, so the chat drops its own pin and takes the level of the main
window. Other platforms follow on each frame, as Electron does.
- **Pointer move and resize**: under CEF off-screen rendering,
`window.screenX` and `event.screenX` do not give the window position.
The move handle and the resize edges start the native gestures
(`beginMove`, `beginResize`). Arrow keys use the host contracts
`chat-floating:move-by` (new) and `chat-floating:resize-by`.
- **Draft on a mode switch**: the Kirie Eventa adapter drops a reply
without a body. In the Electron contract, `collect-draft` and
`take-draft` answer `undefined` when there is no draft. That reply never
reaches the other side, so the switch waits for its timeout and fails.
In Kirie, the draft travels inside `{ draft }`, and the host passes it
on without reading it.
- **Frosted menus in the floating chat**: a page cannot blur the desktop
behind a transparent window, so the voice, send, and danmaku menus
showed other windows through their fill. In the floating chat window,
these menus use the same fill without transparency. The rule is in
`chat-floating.vue` and carries a `NOTICE`.
- **Settings from a chat window**: only the main window answered the
request to open settings. So **More Hearing settings…**, **Open
Hearing**, and **Open provider settings** did nothing in a chat window.
`SettingsWindowManager.Attach` now registers it for each chat window,
the same way `ChatWindowManager.Attach` serves Spotlight.
- **Microphone permission in pages**: Kirie answers each CEF microphone
request itself, and CEF does not store that answer as a site permission.
So `navigator.permissions.query({ name: 'microphone' })` returned
`prompt` after access was granted, and the voice menu never started its
level meter. `installMicrophonePermissionStatus()` answers this query
from `MicrophonePermissionService`, as the permission check handler does
in Electron. Other permissions keep the browser answer.
- **Microphone requests from chat windows**: the host denied every
microphone request that did not come from the main window. Chat windows
now pass their requests to `MicrophonePermissionService`, so **Listen
continuously** in the voice menu can ask for access.
- **Shared danmaku feed composables**: both chat hosts need the same
read time expiry, feed settings, and speech output state. These four
composables move from the Electron app to
`packages/stage-ui/src/composables`, and both hosts import them from
there. The expiry test moves with them.
## Measurements
Temporary probes, not in this PR.
**Attached chat during a drag.** A CoreGraphics window list recorder
read the bounds of both windows every 2 ms during a drag on macOS:
| | Main window updates | Chat updates | Chat distance from the main
window edge |
|---|---|---|---|
| Follow on each frame | every 8.4 ms (median) | every 108 ms (median),
525 ms at most | 49 to 112 pt (median), 857 pt at most |
| Child window | every 8.4 ms (median) | every 8.4 ms (median) | 0 pt in
318 of 318 samples, with two side changes |
**Draft reply format.** A harness connected the real `@moeru/eventa`
1.0.2 to the real `GdKirie.EventaAdapter` 0.15.0. A reply of `undefined`
was dropped with `Dropped unregistered or invalid Kirie Eventa message`.
`{ draft: undefined }` reached the host as `null`, and `{ draft }`
reached it unchanged.
**Level meter.** A CDP probe read the microphone permission in the main
page and the floating chat page, and listened on the speech bus without
posting. Before, every page reported `prompt`. After, every page
reported `granted`. With the voice menu open, the chat window sent 9
`level-monitor` requests, and the main window sent 187 `input-level`
messages.
## Verification
Manual test in `kirie dev` on macOS:
| Scenario | Result |
|---|---|
| Drag the main window with an attached chat | The chat stays at the
edge of the main window, also when it changes sides |
| **More Hearing settings…** in the voice menu of the floating chat |
Settings opens at `/settings/modules/hearing` |
| Open the voice menu over other windows | The menu fill is opaque |
| Open the voice menu and speak | The level meter moves |
| Switch between the legacy and the floating chat with an unsent draft |
The new window shows the same conversation and the draft |
I did not test Windows or Linux.
- `pnpm -F @proj-airi/stage-tamagotchi-kirie typecheck` passed.
- `pnpm -F @proj-airi/stage-tamagotchi typecheck` passed.
- `pnpm -F @proj-airi/stage-ui typecheck` passed.
- `pnpm -F @proj-airi/stage-ui exec vitest run
src/composables/use-chat-feed-expiry.browser.test.ts`: 15 tests passed.
- `pnpm -F @proj-airi/stage-tamagotchi exec vitest run
src/renderer/components/chat-window/chat-danmaku-feed-menu.browser.test.ts`:
3 tests passed.
- `dotnet build
tests/StageTamagotchiKirie.Tests/StageTamagotchiKirie.Tests.csproj`
passed.
- `dotnet run --project
tests/StageTamagotchiKirie.Tests/StageTamagotchiKirie.Tests.csproj
--no-build`: `StageTamagotchiKirie.Tests passed.` The placement tests
are copies of `floating-placement.test.ts`.
- `dotnet format StageTamagotchiKirie.csproj --verify-no-changes`: no
changes.
- The `dotnet` commands ran from `apps/stage-tamagotchi-kirie`.
- The commit hook ran `moeru-lint --fix` on the changed files.
- I did not run the other Vitest tests locally. CI runs them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
**Edit** on a needs-attention item opened a page, and the person had to
find the setting there. For the active character, it opened the module
page, which shows the selection of the running modules, not the
selection of the card.
Each item now opens at the row to change, scrolled into view and
highlighted, as a settings search result opens it:
| Item | Opens at |
|---|---|
| A character without a speech model or voice | The character editor,
`modules` section, the speech model or voice row |
| A character whose module provider cannot be used | The character
editor, `modules` section, the provider row of that module |
| A provider that is not set up, or that failed its check | The basic
section of the provider page |
| Voice input without transcription | The provider list of the Hearing
page, or the model row when a provider is selected |
- An item of a character opens the editor of that character, also for
the active character.
- A page without the named row opens at the top, so it shows no notice
of a missing setting.
- The character editor runs the same reveal as the settings layouts.
- The desktop settings layout runs the reveal too. Without it, a
settings search result on the desktop opened its page at the top.
## Verification
Manual test in the stage-web dev server:
- Character voice item: opens
`/settings/airi-card/default/edit?section=modules&setting=settings.pages.card.speech.voice`.
The voice row is in view and highlighted.
- Provider item with a failed check: opens the provider page with the
basic section highlighted.
- Voice input item without a provider: opens the Hearing page with the
provider list highlighted.
Checks:
- `pnpm -F @proj-airi/stage-ui exec vitest run
src/composables/use-settings-needs-attention.test.ts`: 3 tests passed.
The new test fails when the active character opens the module page
again.
- `pnpm -F @proj-airi/stage-ui typecheck` passed.
- `pnpm -F @proj-airi/stage-pages typecheck` passed.
- I did not run the desktop app. Its layout calls the reveal as the
Kirie layout does.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
The settings home lists the settings that keep a feature in use from
working. #3131 limited it to providers in use. This PR adds the other
cases where a feature in use stops working, and nothing tells the person
why.
- **Character without a speech model or voice**: the speech of a
character has a usable provider, but no model or no voice.
`createOutput` throws, `Stage.vue` logs the error, and the replies stay
text only.
- **Provider that a module cannot use**: a character, or the Hearing
module, selects a provider that its module page does not list, for
example a removed provider. Removing a provider clears only the defaults
and the active character, so other characters keep it. The list shows
it, and the character keeps the selection, because the person can add
the provider again.
- **Voice input without transcription**: in the Always on mode, the
microphone listens, but the voice store starts an input only when
`hearing.configured` is true. So speech starts nothing. Push to Talk and
dictation already explain this when the person presses them, so they do
not show here.
- An empty field that a catalog fills later is pending, not missing. The
store picks a voice for an official provider, and a streaming provider
takes its default model, after the catalog loads. So the list does not
show an item before the catalogs load.
- `speechFieldStatus()` in the speech store gives the speech check.
`createOutput` resolves the selection the same way, so the two cannot
disagree.
- Each item has a title, a detail, and a blocking flag. A row in a list
of several items shows the title, so "not set up" now shows in that list
too. The list title for several items is "{count} items need attention".
- **Edit** opens the module page for the active character and for
Hearing, because these pages edit the active character. For another
character, it opens the character editor at its modules.
- The provider checks page becomes the developer page of this list,
`/devtools/needs-attention`. It lists every item, also the dismissed
ones. It shows the state that each kind of item reads: provider checks,
the provider of each module selection, the speech of each character, and
the voice input. It can make the list show each kind on purpose, and a
simulated item says so in its detail.
## Verification
Manual test in the stage-web dev server, with test local storage:
| Scenario | Result |
|---|---|
| The active character uses MiniMax Speech, with no model and no voice |
"ReLU has no speech model or voice", detail "Replies are text only ·
MiniMax Speech" |
| Only the voice is missing | "ReLU has no voice" |
| The model and the voice are set | No item |
| Two characters lack them | "2 items need attention". Each row shows
its title |
| The provider is not configured | Only "MiniMax Speech is not set up" |
| A character selects ElevenLabs for speech and OpenAI for chat, both
not added | Two items, for example "The Speech provider of Tester cannot
be used". No speech item for Tester |
| Hearing selects a transcription provider that is not added | "The
Hearing provider cannot be used" |
| Always on, microphone on, no transcription provider | "Voice input is
on, but no transcription provider is selected". It goes away in Push to
Talk, or with the microphone off |
| Edit on the active character, on another character, and on a Hearing
item | `/settings/modules/speech`,
`/settings/airi-card/<id>/edit?section=modules`,
`/settings/modules/hearing/transcriber` |
| `speechFieldStatus()`: official voice and streaming model, before and
after their catalogs load | Pending before, missing after when still
empty. A default model or a config value fills the field |
| Developer page: treat as no voice, dismiss on the home, undo | The
item shows as simulated, then shows as dismissed with a count of 1, then
shows again |
| Developer page: treat voice input as on without transcription, and
treat a usable Hearing provider as unusable | Both items show, with
"Simulated from the developer tools" in the detail |
The Browser pane blocks the microphone. For the voice input case, a
silent local stream stood in for `getUserMedia`. I tested in Simplified
Chinese. The table uses the English strings.
- `pnpm -F @proj-airi/stage-ui typecheck` passed.
- `pnpm -F @proj-airi/stage-pages typecheck` passed.
- The commit hook ran `moeru-lint --fix` on the changed files.
- I did not run the tests locally. CI runs them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
The settings home lists the providers whose last check in this session
failed. The list sits under the search field and scrolls with the page,
because it is secondary to the search.
- **One provider** shows in one card: the title, one quiet line with the
reason of the check, and **Edit** and **Dismiss**. The quiet line also
names the modules that use the provider, if any.
- **More providers** share one summary line with their icons and the
modules that they affect. It opens the list with the same rows.
- **Edit** opens the settings page of the provider. **Dismiss** hides
the provider on this device until its config or its failure reason
changes.
- A provider shows only after a check in this session failed. The stored
status from the last session does not show it before the startup check.
- A provider without a brand icon shows the icon of its module kind.
## Changes outside the list
- **Provider store**: it keeps the failure reason and the recent check
times of each provider. It can make a provider fail its check on
purpose. That choice is runtime state, so it ends when the app quits.
- **Developer tools**: a new page, `/devtools/provider-checks`, and an
entry on each developer page.
- It shows when the checks run, with or without a sign-in, and how often
a provider checks itself.
- It checks all providers now, fails chosen providers on purpose, and
shows dismissed providers again.
- **Provider brand icons**: `uno.config.ts` now scans
`provider-inference`. The brand icon classes were only in its built
files, so they had no CSS since #2444. This also fixes the empty icons
on the providers page.
- **Provider routes**: `providerSettingsRouteOf` gives the settings page
of a provider. Most definitions have no `to`.
- Search skipped those providers. It now finds them.
- The provider cards, search, and **Edit** use the same helper, so they
open the same page.
- The web, pocket, desktop, and Kirie settings homes use the new list.
## Visual changes
The settings home had no such list before, so the before images are
absent.
| Before | After |
|---|---|
| absent |

|
| Settings / Home, one provider failed | Settings / Home, one provider
failed |
| absent |

|
| Settings / Home, two providers failed, list open | Settings / Home,
two providers failed, list open |
## Verification
- Captured on the stage-web dev server with Playwright. OpenAI and
DeepSeek were added without an API key. **Check all now** on the
provider checks page failed both with `API key is required.`, and the
settings home showed them. **Dismiss** removed DeepSeek and left the
one-provider card.
- The commit hook ran `moeru-lint --fix` on each commit.
- Typecheck and tests did not run locally. CI runs them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
This PR adds the registration foundation for Extension-hosted Kits. An
Extension can declare provided Kits in its manifest and fill those
declarations during root setup. The Host publishes the complete set only
after setup succeeds.
`KitProviderRegistry` now owns Kit identity, source composition,
generation numbers, transactions, and exact cleanup leases. Host
descriptors, Host Client factories, and Extension endpoints keep
separate payload models, while one slot per Kit ID enforces shared
ownership rules.
This is a registration-only phase. It does not add Consumer routing,
method calls, event delivery, permissions, remote transport, or
draining. Phase 2 is already part of `main`, so this PR is independent
rather than stacked.
## Change map
| Module | Before | After | Description |
| --- | --- | --- | --- |
| Kit authoring contract | Extensions could declare provided Kit
metadata, but had no typed runtime Contract or Provider endpoint |
`defineKitContract`, typed methods and events, exposure policies, and
exact Provider handlers | Keeps authoring data serializable and captures
caller-owned input before validation |
| Provider registry | Host descriptors and Client factories used
separate stores and ID-based cleanup | One `KitProviderRegistry` slot
union owns Host sources, Extension transactions, generations, tokens,
and leases | Centralizes collision, publication, snapshot, and
stale-cleanup policy |
| Extension Host lifecycle | Setup had no Provider transaction | Setup
reserves declarations, fills them through `ctx.kits.provide`, commits
atomically, and rolls back on failure | Makes pending Providers
invisible and ties cleanup to the Extension session |
| Built-in Kit runtime | Built-in Kits registered through separate
helper paths | One source list drives manifest declarations and exact
Host-source leases | Keeps declaration, registration, rollback, and
disposal order aligned |
| Examples and decisions | Activation Planner examples only covered
Host-provided Kits | Adds a registration-only hosted Provider example
and an ADR | Gives reviewers and Kit authors a runnable contract and the
ownership rationale |
## Architecture and behavior
The transaction is correlated by the globally unique Extension
`sessionId`. Private transaction tokens isolate pending slots and
rollback. Separate registration tokens isolate Provider handles and
leases after publication.
```mermaid
sequenceDiagram
participant H as ExtensionHost
participant R as KitProviderRegistry
participant E as Extension setup
H->>R: beginExtensionSession(owner, declarations)
R-->>H: pending transaction
H->>E: setup(ctx.kits.provide)
E->>R: provide(contract, handlers)
R-->>E: exact Provider handle
alt setup succeeds and every declaration is filled
H->>R: commit()
R->>R: validate and build all ready records
R->>R: publish all records synchronously
R-->>H: Provider snapshots and session lease
else setup or commit fails
H->>R: rollback()
R->>R: remove matching pending reservations
end
H->>R: session lease dispose before cleanup callbacks
R->>R: withdraw only matching registration tokens
```
Changed behavior scenarios:
- `HK-1`: Host descriptor and Client factory sources can compose only
when Kit ID and exact version match.
- `EK-1`: Extension setup reserves every declared Provider before any
Extension code runs.
- `EK-2`: A successful commit publishes every Provider in one
synchronous operation.
- `EK-3`: Setup or commit failure rolls back pending reservations
without advancing generations.
- `EK-4`: Stop and reload withdraw the session lease before Extension
cleanup callbacks.
- `EK-5`: Stale handles and leases cannot remove replacement
registrations.
## Boundaries and risks
| Invariant or boundary | Failure mode | Protection | Evidence or gap |
| --- | --- | --- | --- |
| Caller-owned Contract and Provider input is not stored directly |
Getters, proxies, or later mutation change accepted state | Capture
external properties once, then validate and freeze accepted models |
Plugin SDK Contract and Provider registry tests |
| A multi-Kit session is visible as one unit | A partial Provider set
becomes ready | Pending slots plus a validation/build pass before the
synchronous publication pass | Provider registry transaction tests |
| Cleanup affects only its own registration | A stale handle removes a
reloaded Provider | Exact source, transaction, and registration tokens |
Stale handle, reload, and lease tests |
| Host descriptor and Client factory payloads stay distinct | A shared
ID silently combines incompatible versions or duplicate source kinds |
One slot checks exact version and rejects duplicate current sources |
Host source composition tests |
| Built-in runtime cleanup keeps dependencies valid | Runtime state is
destroyed while a Client factory remains available | Dispose Host leases
before destroying the built-in runtime, with reverse-order rollback on
install failure | Built-in Kit runtime tests |
| Consumer execution stays outside Phase 3 | Registration code starts
routing calls or events early | Public API stores endpoints but exposes
no invocation or event transport | Verified by diff and both Spec
reviews |
## Verification
```text
pnpm -F @proj-airi/plugin-sdk exec vitest run
# 15 files passed, 163 tests passed
pnpm exec vitest run apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts
# 1 file passed, 66 tests passed
pnpm exec vitest run apps/stage-tamagotchi/src/main/services/airi/plugins/kits/index.test.ts
# 1 file passed, 5 tests passed
pnpm typecheck
# 52 tasks passed
pnpm lint
# 0 errors, 651 repository warnings
sem diff --staged --no-cosmetics -v --file-exts .ts .tsx
# Completed before each commit. No unresolved caller was found.
git diff --check upstream/main...HEAD
# Passed
```
The final fixed-range code reviews reported 0 Standards findings and 0
Spec findings. No visual evidence is needed because this PR changes
Plugin SDK contracts, main-process lifecycle code, tests, examples, and
documentation without changing user-visible UI.
## Rollout and follow-up
This PR has no migration or persisted-data change. Existing Host
descriptor inputs are intentionally validated more strictly, as recorded
in the ADR. Consumer routing, invocation, events, permissions,
transport, and draining remain separate follow-up work.
## Summary
Kirie uses its own settings layout and settings home. The settings
search from #3112 did not reach it. This PR adds the search to Kirie.
- **Plugin**: `kirie.config.ts` adds `createSettingsSearch`, as
stage-web and Electron do.
- **Settings home**: a copy of the Electron settings home, with the
search field and the results.
- The copy also drops the navigation guard that #3100 removed from
Electron. With page transitions on, that guard blocks every link, and
search results too.
- **Runtime items**:
- Kirie lists saved MCP servers and display models. The MCP server rows
get their search markers.
- The pages from stage-pages bring their own providers, for example
cards, memories, and voices.
- Kirie has no Home Assistant page and no downloaded models section, so
these items are not in its results.
- **Server channel QR card**: the collapsible triggers get
`aria-expanded`, so search can open them.
- **Settings layout**:
- The Kirie settings layout scrolls to the row of a search result and
highlights it.
- The page header moves out of the scroll container. `PageHeader` is
sticky, so the search field slid under it. Now the field sticks right
below the header, as in the shared layout.
- `useRestoreScroll` restores the position after the page renders, in
the same flush. Before, it wrote the position after `nextTick`, and that
stopped the scroll of the search reveal.
## Verification
- On the running `kirie dev` server, the changed files compile, and
`virtual:settings-search-providers` lists 15 providers.
- The commit hook ran `moeru-lint --fix`.
- Typecheck and tests did not run locally. CI runs them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
The character name button in the floating chat dock did not look like
the other dock buttons.
- **Hover.** The name button had a theme-color hover fill and dark
theme-color text. The other dock buttons use the neutral hover fill, and
their icon takes the theme color. The name button now uses the same
hover. Its text and icon take the theme color together.
- **Icon.** The chat icon sat on a gray disc at rest. No other dock icon
has a disc. The icon now has no disc, the same light gray at rest as its
neighbors, and the same size.
The button keeps its height and its icon position, so it stays in line
with the other dock buttons.
## Verification
- The pre-commit hook ran `moeru-lint --fix` on the changed file.
- Captured the dock from the merge base and from this branch with the
same Vishot scenario, with the pointer on the name button.
## Visual changes
| Before | After |
|---|---|
|

|

|
| Floating chat / Dock, pointer on the character name | Floating chat /
Dock, pointer on the character name |
🤖 Generated with [Claude Code](https://claude.com/claude-code)