Project-scoped MCP servers written to a directory that never hosted a
session disappeared from the desktop settings list after an app restart:
the discovery set (cwd + recent projects + /api/mcp/project-paths) only
enumerated registry entries with local-scope servers, and .mcp.json files
leave no trace in the global config.
- register the target project when addMcpConfig writes a project-scoped
server, treat on-disk .mcp.json as the source of truth in project-paths,
and self-heal registry entries for pre-existing files on first browse
- stop removeMcpConfig from mutating the shared safeParseJSON cache entry:
removals poisoned every later parse of byte-identical .mcp.json content,
so a server moved between projects parsed as already deleted; add
safeParseJSONWithoutCache for callers that edit the parsed value, switch
the settings raw-update fallback to it, and make
filterInvalidPermissionRules pure for the same reason
- fetch the full known-project set when PluginDetail refreshes the MCP
store instead of overwriting the list with a one-project view
On a phone the H5 client rendered under a gray band where the status bar
sits, instead of running its own background up to the top edge the way
other mobile sites do.
iOS WebKit reads the viewport meta once, while parsing the document.
touchH5.ts rewrote it at runtime to pin the scale and carried
viewport-fit=cover along, but that rewrite cannot turn safe areas on
retroactively — so every env(safe-area-inset-*) in globals.css resolved to
0px and the browser painted its own chrome color behind the status bar.
Declaring cover in index.html is what actually takes effect.
Add a theme-color meta on the same pass. With the page now running under
the status bar, the browser chrome needs to match the palette rather than
guess at it; the pre-hydration script sets it before first paint and
applyTheme() keeps it in step, including for a palette another window
picked.
The sidebar reported "Optimizing history N/M" above the session list while
the SQLite index built. Indexing is background housekeeping the user cannot
act on, and the counter sat there for the whole build.
Drop the visible progress row and narrow the live region to `degraded` —
the one state a user can perceive, where history really is served the slow
way. Building/ready/off now stay silent for screen readers too, so the
behavior is the same regardless of how the sidebar is read.
The four locale strings this leaves unused are deleted in all five
languages, with a resurrection guard alongside the existing one for the
removed installed-skills keys.
Launching from Finder or the Dock leaves the main process with stdio that has
no reader. Writing there fails asynchronously with EPIPE from inside the stream
machinery, and with no `error` listener Node escalates it to an uncaught
exception — which Electron shows as "A JavaScript error occurred in the main
process".
This is reachable in ordinary use: the sidecar exit handler logs a line every
time a sidecar dies, so any crashed or killed sidecar could raise that dialog.
Guarding that one call site would not help, since the main process has ~25
console call sites and all of them write to the same two streams. A try/catch
at the call site cannot help either, because the throw happens off-stack.
Install the guard on stdout/stderr before anything logs. Losing a diagnostic
line is acceptable; killing the user's session over one is not — real
diagnostics already persist to a file through appendHostDiagnostic.
The model catalog only decides which rows appear in the model picker, but
`/api/models` and `/api/models/current` sit inside the `settingsStore.fetchAll`
gate that blocks the desktop first paint. With an unreachable upstream, every
cold start waited out the full 5s request timeout before rendering, then handed
back the bundled fallback that was available for free all along. Nothing was
cached on failure either, so each later call paid the timeout again.
Both official providers had grown their own copy of the same cache, so the fix
is one shared strategy instead of two patches:
- default path is stale-while-revalidate — answer from cache (even stale) or
the bundled fallback, and refresh in the background
- a failed refresh is remembered for a backoff window, so an unreachable
endpoint is not re-dialed on every call
- concurrent callers collapse into a single upstream request, since
/api/models and /api/models/current are requested in the same tick
- forceRefresh keeps its blocking semantics for explicit refreshes
Measured against a real unreachable endpoint: 5000ms -> under 1ms.
The regression tests use an endpoint that never settles, so restoring the
blocking await makes them hang rather than quietly slow down.
The trace list lives inside Settings, but opening a row jumps to a
sibling top-level tab with no return path: the detail header only
offered copy/refresh/open-window, so getting back to the list meant
finding a tab labelled "Settings" — which does not match the mental
model of someone reading a trace.
Add a "back to list" control to the detail header that returns to the
Settings trace section and closes the tab it came from, mirroring
returnFromWorkbench. It stays available in the loading and error states,
where being stranded hurts most.
Along the same path:
- Tag trace tabs with the account_tree glyph, so the title can carry the
session name instead of a truncated "Model trace: " prefix.
- Scroll the selected Settings rail entry into view. Settings remounts on
re-entry with the rail scrolled to the top, which left the selected
section highlighted off-screen after returning.
- Drop the row action that duplicated the row click.
- Fall back to a browser tab for "open in separate window" outside the
desktop shell, where it was a dead button.
Navigation is consolidated in lib/traceNavigation.ts so the list, deep
links, and the return path share one definition.
At 2000x1255 it was the odd one out in the README grid: that cell's caption
sat higher than the two beside it, which is the whole reason the shot was
swapped in. Cutting the sidebar column away at the panel divider (x=480) and
shaving the empty 31px off the top and bottom margins lands exactly on the
2000x1436 frame the other five use, so the grid lines up and the tour stage
fills edge to edge.
Nothing in the UI was cut — the tab bar, the project chip and the zoom control
all sit inside the kept region, and the crop leaves the conversation next to
the browser preview, which is what this shot is about. The `--wide` special
case added for the taller frame goes away with it.
The old workspace-preview shot was cramped at the size the home page and the
README render it. The new one is a full-window capture with the element picker
open — exactly what the copy next to it promises.
It is wider than the other seven shots (2000x1255 against 2000x1436), and the
tour stage pins `aspect-ratio` on the image itself, so dropping it in would
have stretched it. Each tab now carries its own shot shape — `wide` here,
`tall` for the phone screenshot that used to be matched by id — while the
stage keeps its 2000/1436 height, so the panel still doesn't jump between
tabs.
Collapsing directory, branch and worktree into one pill was supposed to end
with the location holding still: editable while the session is a draft,
read-only afterwards, same row either way. It did not. The condition read
`isHeroComposer`, and ActiveSession renders the hero variant only while the
session is empty, so the variant and the draft state flip in the same render.
The location dropped back out to a chip below the panel at exactly the moment
it was meant to stay put. The condition is the composer's width now, not its
variant.
The test written to guard this rendered `variant="hero"` against a session
with messages — a combination ActiveSession never produces — so it passed
while the shipped composer still moved the chip. It renders the default
variant now and asserts the chip sits inside the panel.
Sizing that row exposed the rest of the mismatch: the draft and the live
session were two different geometries. The draft inset its divider inside the
panel's padding; the live one welded a `-mx-4 -mb-4` band to the panel edge.
The first message therefore shifted every control 4px left and 4px down and
stretched the divider by 34px. The live row adopts the draft spacing, because
EmptySession renders the same values — two shells against one.
That alone would have grown the panel by 8px, but the live textarea was also
paying for a descender gap: a textarea is inline-block, and the hero branch
escapes it only by sitting in a flex row. `block` recovers 6px, so the panel
ends up 2px taller with four alignment defects gone.
The narrow layouts keep the band. `p-3` has no padding to spend on inset, and
they never swap variants mid-session, so there is nothing there to hold still.
Three rows of two pushed the grid past a screenful. Three across in
two rows keeps the whole preview visible at once.
Captions are tightened to fit a 337px column on one line — the
English theme caption in particular was breaking "Ink blue" across
two lines mid-phrase.
Four frames left the desktop app looking narrower than it is. Six
cover the arc a first-time reader needs: it codes, you review, you
verify, it looks good, it grows, and it keeps you company.
Adds the built-in browser preview — the frame that closes the loop by
showing the page the session just changed — plus the desktop pets.
Every caption now carries a second line saying what the frame proves,
so the grid reads without opening each image.
The site had drifted from the product. Every screenshot predated the
v0.5.0 UI redesign, the reading experience shipped no search and no
syntax highlighting, and a third of the pages were internal process
artefacts — migration task lists addressed to agentic workers, a
release runbook, a proposal marked "historical".
Reorganise around the only two people who read this: someone getting
the desktop app running for the first time, and someone reading the
source. Five sections replace nine — start / desktop / im / cli /
internals — and the pages that served neither reader are gone.
Site rewrite:
- Palette lifted from the desktop app's 「纸·墨·印」 themes, so the
site and the product read as one thing. Light mirrors 纯白, dark
mirrors 墨夜, and dark mode exists at all now.
- Fonts are self-hosted. The old @import from Google Fonts is
unreachable from mainland China, which left every heading in a
fallback serif; it also only requested weight 600 while the CSS
asked for 900, so Latin and CJK in the same heading disagreed.
- Docs were shipped as one 968KB manifest downloaded on every page
view. Split into a 32KB index plus one lazily imported chunk per
page; the entry bundle is now 101KB gzipped.
- Add search, syntax highlighting, per-route meta with canonical and
hreflang, a sitemap, and an error boundary. Replace the 44vh
mobile sidebar with a drawer.
- Image dimensions are read at build time and written into the tag,
so lazy images reserve their space instead of collapsing.
Screenshots are recaptured from a real v0.5.0 build against a clean
demo project, with tokens, QR codes and paired accounts redacted.
The previous set is deleted rather than kept alongside.
Routes follow file paths, so the restructure would have broken every
inbound link; 37 old paths redirect, in both languages. The PR policy
gate and CODEOWNERS also hardcoded docs/guide/contributing.md.
Verified: check:docs 78 pages / 323 links / 0 problems, check:policy
127 pass. Walked every route at 1440 and 390 in both themes for
overflow, contrast, keyboard reachability and focus management.
`Verify Windows installer execution` failed twice in a row on the v0.5.0
tag, both times at the same assertion:
Elevated default-mode reinstall without CLR expected process exit
code 20, received 22.
20 and 22 are different answers to "why did setup stop". 20 is legacy
recovery refusing to continue; 22 is "a matching process is running". The
stage breaks the CLR on purpose so the installer cannot run PowerShell,
which is exactly when CcHahaFindInstallProcess degrades from resolving
paths to matching bare image names -- `Claude Code Haha.exe`, the three
`claude-sidecar*` names, `OpenConsole.exe`, `winpty-agent.exe`, `rg.exe`.
Any process on the runner carrying one of those names answers for the
stage, whoever started it.
The stage before it expects 22, so a stray match there is indistinguishable
from a pass; the stage after it expects 20, so the same stray match is a
failure. That asymmetry is why this reads as "one flaky assertion" rather
than "the whole no-CLR group is unguarded".
This does not fix the installer, because the installer is not wrong:
refusing to delete user data when it cannot confirm what is running is the
intended fail-closed behaviour, and neither installer.nsh nor this script
changed between v0.4.11 (green) and v0.5.0 (red). What changed is what was
running on the runner. So the script now controls that instead of assuming
it:
- Both stages that expect 20 first clear any process matching the same
name list. `WaitForExit` only covers the PIDs this script started; the
fallback matches names, so it also sees leftovers from earlier steps of
this job -- the compiled-sidecar smoke starts 20 sidecars -- and any
child a probe spawned.
- Clearing warns instead of throwing when something survives. A survivor
is runner-owned and out of reach, and failing there would replace the
stage's own failure with a less informative one.
- A mismatched exit code now prints every matching process with PID and
path, and a baseline is printed before the first install. Between those
two, a future failure says whether the runner was dirty or the installer
regressed, which this run had no way to answer.
The name list is duplicated from installer.nsh by necessity -- NSIS
compile-time state is not readable from PowerShell -- and is commented on
both sides to be kept in sync.
Not verified locally: this needs Windows and an ephemeral runner (the
script refuses to run unless CI=true, since it mutates installer registry
state). Reviewed for Windows PowerShell 5.1, which is what the workflow
invokes: no pwsh-only syntax, and the file is kept pure ASCII as it was,
so 5.1 cannot mis-decode it.
The same suite passed under `check:desktop` and failed under
`check:coverage` — 271 files and 3332 tests either way, with one case red
in the second: "returns null rather than throwing on a missing or corrupt
cache" read back `{isDark: true, background: '#201D17', ...}`, which is
exactly what the case above it writes.
`appearanceStatePath` resolves `env.CLAUDE_CONFIG_DIR` before falling back
to `app.getPath('home')`, and these cases passed no env, so they defaulted
to `process.env`. `check:coverage` runs its suites through
`createSandboxedTestEnvironment`, which sets CLAUDE_CONFIG_DIR
(scripts/pr/test-environment.ts:74). With it set, the per-case temp
directories from `makeApp()` stop deciding anything: every read and write
collapses onto one shared file, and `afterEach` only removes the temp
directories, never that file. So the round-trip case wrote it and the
missing-cache case read it. Only that one case is ordered to notice — the
others write before they read.
Nothing about the product is wrong here: defaulting to `process.env` is
what the shipped code should do, and a portable install setting
CLAUDE_CONFIG_DIR is a supported mode. The defect is that the tests never
opted out of it.
Each case now passes the isolated env `makeApp()` hands back, which is the
convention `windows.test.ts` already follows for the same path shape (it
threads `{}` or `{CLAUDE_CONFIG_DIR: tmp}` through every call). Verified
both ways: with CLAUDE_CONFIG_DIR set — the condition that reproduced the
failure — and without it, 23/23 each time. `check:coverage` now reports
5/5 suites, and `check:desktop` stays green.
`sidecarManager.ts` and `windows.ts` resolve paths the same way; their
suites were checked and already pass an explicit env.
The header sat at 12px while everything below it started at 24px — the
new-session, scheduled and market icons, the search glyph, the settings
gear. The name hung out on a line of its own to the left, so pad it onto
theirs.
That spends 12px of header room, and the long form was already running
out around 244px. Rather than clip it mid-letter, carry a short form as
well and swap on a container query over the title region: no sidebar
width now shows a cut-off name.
Importing an animated pet required a file that was exactly 1536x2288, laid
out as 88 seamless cells, with the last two rows holding sixteen distinct
gaze angles. No image model emits that. Whatever a user got back from Jimeng
or ChatGPT was some fixed size like 1024x1536, so the path ended at "the
animation atlas must be exactly 1536x2288 pixels" every time. The third card
was worse: "AI-generate full animation" was hardcoded `disabled`, so the one
entry point named after what people actually wanted to do was dead.
The fix was already in the tree. `scripts/assemble-generated-pet-atlas.py`
landed in the same commit as the four built-in pets, which is to say the
built-ins were produced this way — it takes an action sheet at any size,
slices it on an 8x9 grid, fits each cell to 192x208, mirrors the run row to
make run-left, and reuses rows to reach eleven. That capability was never
wired to anything a user could reach.
`petAtlasNormalize.ts` reimplements it on a canvas in the renderer, so an
author draws nine rows and the app derives the rest. Verified against the
reference assembler by reversing dada-code's atlas into a nine-row sheet and
re-normalizing it: every difference lands on semi-transparent antialiased
edges (2314 pixels, max channel delta 14/255) and opaque regions are
identical. That residue is canvas premultiplied-alpha round-tripping, not a
slicing bug.
Three contract details worth stating. Row frame counts are now derived from
`PET_ANIMATION_DEFINITIONS` rather than typed out a fourth time; they come
out equal to the assembler's `(6,8,8,4,5,8,6,6,6,8,8)`. A sheet already at
1536x2288 passes through byte-for-byte instead of being resliced, because
resampling finished artwork buys nothing. And since the validator never
inspects the alpha channel, a flattened white background used to import
happily and render as a rectangle on the desktop — the renderer now rejects
sheets whose atlas is under 5% transparent (the built-ins sit near 78%) with
a message that names the actual problem.
The copy stops describing the implementation. "Animate one image" and
"Import professional animation atlas / exact 1536x2288 v2 PNG" become "use a
picture you already have" and "I already have an action sheet"; the dead AI
card becomes a three-step walkthrough carrying a copyable prompt, a labelled
8x9 reference grid that can be saved locally, and the checks that catch the
common failures. Reference images are generated by a script rather than hand-
placed, in both languages. All five locales move together.
Caught while reviewing the real dialog in Electron: after finishing the
walkthrough the form heading fell through to the atlas branch and announced
"I already have an action sheet" to someone who had just been walked through
drawing one. Covered by a test now.
Not done: docs/images/desktop_ui/15_pet_create_methods.png still shows the
old dialog and needs a fresh capture from a running app to match the styling
of the shots around it.
The provider list behind the 860px "add provider" dialog was legible
straight through the panel — URLs and model names readable in the form's
empty space.
`.glass-panel` states a translucent fill and a blur in one rule, and reads
as frosted only when both land. The blur is the fragile half: where
`backdrop-filter` does not run there is no failure for CSS to report. The
declaration is skipped, the 0.84 fill is left standing on its own, and 16%
of the page comes through unscrambled. A reduced repro pins it — with the
blur live nothing inside the panel is readable; with it disabled the result
matches the report exactly.
Dialogs leave the coupling entirely. `--color-surface-dialog` is opaque by
construction (no alpha channel to walk back one decimal at a time), mapping
to `--cc-bg` on light themes and `--cc-s1` on the ink ones — ink runs its
background at the bottom of the ramp, so a lifted surface has to climb it
rather than reuse it. `.dialog-panel` carries fill, hairline and shadow and
never touches `backdrop-filter`.
Two things follow from an opaque panel. The scrim is now the only thing
separating the dialog from the page, so `Modal` moves to the heavier
`--color-modal-scrim` — the token that already existed for exactly this and
had only `GlobalSearchModal` as a user, while `Modal` sat on the non-modal
one. And the `:focus-within` ring goes: a dialog holds focus essentially
always, so it burned permanently rather than signalling anything.
The small floating layers keep the glass, but no longer depend on the blur
for legibility: the fill goes to 0.92/0.93. The `@supports` fallback added
alongside it is worth less than it looks — it catches engines that do not
implement `backdrop-filter`, not the failure seen here, where the query
returns true and the blur still never runs. Raising the density is the half
that actually covers the reported case.
Why the blur is inert on this machine is not established. Ruled out on the
code side: GPU switches, containment on `html`/`body`/`#root`, and CSS
`zoom` (UI scaling goes through Electron's `setZoomFactor`). The fix does
not depend on that answer.
Verified across warm-classic, dark and ink-blue: computed fills come back
as `rgb(...)` with no alpha, `backdrop-filter: none`, and both ink themes
render the panel lighter than the page behind it.
Directory, branch and worktree were three separate buttons on a bar welded
under the composer. That bar forced the panel's squared bottom edge, so the
composer read as three stacked bands split by two divider lines, and the whole
row jumped outside the panel as a read-only chip the moment the first message
was sent.
They are one pill now, sized for the toolbar row it shares with "+" and the
model selector. The panel is fully rounded again and keeps a single divider.
The pill stays put for the life of the session: editable while the session is
a draft, read-only afterwards, same row either way.
Directory, branch and both worktree modes live in the pill's menu. The worktree
modes are one click from the root view; the branch list is a second view with
its own search and a way back. The nested directory picker portals its dropdown
to the body, so the menu exempts it from its own outside-click handling.
A truncated branch keeps its tail — `…use-native-on-main`, not `feature/comp…`
— because the end is what distinguishes it. `dir="rtl"` moves the ellipsis to
the front and `<bdi>` stops the RTL container from reordering the slashes.
H5 already took a different path here (`useCompactControls` keeps the controls
outside the panel), so the pill lands on its own line there at 40px for touch,
and uses the existing bottom sheet. That line is now a fixed single row: the
three buttons needed 447px against 338px available and wrapped to two, with the
row count following the branch name.
Sizing the pill exposed a layout bug: a long branch grew it until the
permission selector wrapped to two lines and the toolbar grew with it. Shrink
now falls on the pill alone.
The English label is "Location", not "Run location", which would have collided
with the Run button for anyone reading the row through a screen reader.
The Windows x64 build job went red on `Verify compiled Windows sidecar
startup`, asserting that a loopback request without
`CC_HAHA_LOCAL_ACCESS_TOKEN` returns 403 while it returned 200. Nothing
about x64 is involved — that step carries
`if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64'`, so it is
the only job in the whole matrix that runs the smoke at all. Any regression
in this area can surface nowhere else.
The 200 is correct. `7d2a8a3cd keep loopback trusted without the desktop
process token` deliberately made the token additive again: gating every
local request behind it turned the Grok OAuth success page, `/preview-fs`
links and plain `curl` into 401s, because none of that traffic can ever
carry the token. Loopback is trusted on its own; the token is demanded only
on the `/api/h5-access` control plane, where another program on the same
box must not be able to publish the user's sessions to the network. The
assertion, written before that change, was still guarding the path that had
been intentionally opened.
So the probe moves to the boundary that is actually enforced, and gains a
positive assertion — loopback without a token must be 200 — so the additive
model is pinned down rather than merely no longer contradicted. Reverting to
the pre-`7d2a8a3cd` behaviour now fails the smoke instead of passing it.
Three copies of the stale assertion existed; all three are updated. Only the
compiled-sidecar smoke runs in CI, but `local-index-benchmark.ts` and its
corpus test were already failing the same way for anyone running them
locally. The benchmark also cancels the probe response bodies now: an unread
body holds its connection open, and that would land in the event-loop delay
and RSS samples taken immediately after.
Verified with the CI parameters — `bun run build:sidecars` then
`CC_HAHA_COMPILED_SIDECAR_SMOKE_STARTS=20 bun run test:compiled-sidecar-smoke`,
8/8 — and by running the benchmark directly, which now reports
`loopbackAuth` as 200/403/403/200 with validation intact.
Three things about the left column, all reported from the same screenshot.
The brand mark beside the wordmark was clutter. Expanded, the sidebar already
says "Claude Code Haha" in the headline face, and the 32px seal next to it
repeats what the words carry. Removing it outright emptied the header on the
72px rail, though — the copy there is width-clamped to zero by
`.sidebar-copy--hidden`, so nothing was left to identify the app. The mark now
renders only when collapsed, at `sm`: two C's and the seal bar, without the
cursor arrow that reads as a stray orange wedge at that size. BrandSeal already
sheds parts as it shrinks, so this is the size ladder doing its job rather than
a new variant.
The settings rail was 260px for a column whose longest label is two words. It
is now 220px. 200px was the first choice and it was wrong: measured in a real
browser against the live stylesheet, the Japanese "コンピューター操作" needs
122px of text box and 200px leaves 115px, so it truncated. The threshold sits
at 210px; 220px keeps ten pixels of headroom over the worst locale.
The sidebar itself is now resizable. An 8px handle on its right edge drives the
width between 240 and 480px, persisted to localStorage, with a double click
back to 300 and arrow-key steps for the keyboard. Dragging left past 240 pins
there until the pointer crosses 180, which collapses to the rail; coming back
out past 200 re-opens it. The 20px gap between those two thresholds is
hysteresis — with one boundary the sidebar flickers open and closed on any
tremor of the hand. A drag that ends in a collapse deliberately does not commit
its width, so re-opening from the toggle restores the size the user chose
rather than whatever value the pointer happened to sweep through.
The live width travels as a CSS variable written imperatively onto the shell,
never through React state. Sidebar re-renders during a streaming turn would
otherwise land between drag frames and fight the pointer, which is the failure
that made the pet animation stutter. The store is written once, when the drag
settles.
That variable exposed a bug the unit tests could not see. `#sidebar-shell` sits
behind AppShell's startup gate, so it mounts a render later than the hook. With
a plain object ref, `shellRef.current` is still null on the single pass the
width effect ever runs, and because neither dependency changes afterwards the
effect never fires again — the remembered width never reaches the DOM and every
launch silently falls back to the stylesheet's 300px. Only visible by loading
the real app: the harness in a test mounts the shell and the hook together.
Fixed with a callback ref, and `useSidebarResize.test.tsx` now models the gate;
that case was confirmed to fail against the object-ref version.
Verified in a browser against the dev server: drag 300→420 persists as "420",
crossing 150 settles the shell at the 72px rail while localStorage still holds
the chosen width, and the truncation thresholds above were measured by
substituting each locale's longest label into a live tab.
Two reviews of the icon swap, run independently from opposite directions,
turned up three places the new mark never reached.
The og:image was the worst. `site/index.html:20` points at
`/images/banner.png`, and that file does not come from `docs/images/banner.png`
— the one this rebrand replaced. `site/scripts/prepare-static-output.mjs:129`
copies `docs/public/` wholesale into the site root before the two selective
image passes run, and neither of those matches an absolute https:// URL, so
`docs/public/images/banner.png` is what shipped. It was a 1200x630 screenshot
of the old site: blue panels, old circular CC badge. Every link shared to
WeChat or Slack would have previewed the pre-rebrand brand while the site
itself rendered the new one. Replaced with a card built from the new lockup;
`site/dist/images/banner.png` now hashes to it.
`desktop/src-tauri/app-icon.png` is the canonical 1024 RGBA source the platform
icon set gets regenerated from. That rule lived only in the body of 3f2ce2a6c,
and nothing references the file in code, so the rebrand skipped it — breaking
an invariant that had held since the file was introduced, where it and
`desktop/public/app-icon.png` were the same git blob. Left as it was, whoever
regenerated icons next would have restored the entire old set.
Linux had no icon above 310px. electron-builder points `linux.icon` at the
whole icons directory and keeps only files named NxN, so `icon.png` (512, no
dimensions in the name) and `128x128@2x.png` (256, collides with
`128x128.png`) were both dropped, leaving a Windows Store asset as the largest
entry. Adding 256x256.png and 512x512.png takes the resolved set from 12
entries topping out at 310 to 14 topping out at 512, confirmed by running
app-builder's icon resolver against the directory.
index.html also had no favicon, and that document is what the H5 remote client
loads in a phone browser.
CI could not have caught any of this — `scripts/quality-gate/package-smoke/`
asserts nothing about icons. `desktop/icon-assets.test.ts` now pins the source
invariant, the Linux sizes, the three packaged icons and the favicon; each
assertion was checked to fail when its subject is reverted.
`app-icon.png` is a 120KB bitmap, and the seven `.svg` files beside it under
docs/images are 193 bytes each — an `<image>` tag wrapping that bitmap. So the
mark carried its own blue/cyan/orange through all six palettes while everything
around it moved, and there was nothing to recolor.
Measured the bitmap back into geometry rather than redrawing it: connected
components to separate the elements, algebraic circle fits for the arcs, angular
histograms for the openings and stroke widths. Big C is center (415,566) r=131
stroke=60, opening 160°; the second C is center (615,576) r=117 cut into two 68°
arcs; the cursor is a four-point polygon with one notch. The slight ellipticity
and unequal stroke ends were raster artifacts, so they were rounded out. The
shape is otherwise unchanged — differencing a render against the original leaves
only antialiasing.
Color maps the original's three layers onto the palette's own three: the C's take
墨 `--cc-t1`, the bar and cursor and sparkles take 朱 `--cc-ac`.
Six themes do not need six icons. Their accent resolves to two values — #96442B
across the four light palettes, #D07B52 across the two dark ones, which is the
same ochre lightened. Only the ink varies, and ink should follow body text
anyway.
The app icon stays one fixed artwork. Dock and taskbar are drawn by the OS, which
knows nothing about the in-app theme, so paper ground with ink C's ships to every
platform. Inside the app the mark is a vector on tokens and repaints per theme.
Four ideas — two C's, the bar, the cursor, two sparkles — collapse below 24px, so
every consumer sheds parts as it shrinks: sparkles above 40px, cursor above 24px,
C's and bar always. That applies inside icon.icns and icon.ico as much as in
BrandSeal.
BrandSeal was the 「哈」 glyph in a terracotta square; it is now the CC mark drawn
inline on `--color-text-primary` and `--color-brand`. Its five call sites — the
sidebar, both empty states, the H5 connect view, the gallery — follow, and the
about pane drops its `<img>` for the same component.
README leads with the horizontal lockup inside a `<picture>` so GitHub serves the
dark cut in dark mode.
Three things the catalogue got wrong once the window was larger than a
laptop lid.
The first-page skeleton was a fixed six cards. On a wide desktop shell
that is two rows above half a screen of nothing, which reads as "loaded,
almost empty" for as long as the request takes. It now measures the space
below itself and fills it, capped at a page so it never promises more
cards than can arrive.
The next page came from a button. It now comes from an observer on a
sentinel, started 400px early, with a row of placeholder cards while the
page is in flight. The observer is rebuilt after each page: it only
reports changes, so a sentinel that never left the viewport would fire
once and leave a tall window half filled. The button stays as the
fallback for a runtime without IntersectionObserver.
A failed page used to land in `error`, which blanks the catalogue behind
a full-region panel — and under auto-loading would have walked straight
back into the same failure forever. It gets its own `loadMoreError`: an
inline notice under the grid, a retry the reader asks for, and no
observer until they do.
Also adds the way out of the catalogue: the header now carries an entry
to the installed-skills browser in Settings, next to the source status.
Claude Code writes an assistant message as one JSONL line per content
block — thinking, text, and each tool_use separately — and every one of
those lines repeats the same complete `usage` object. Both stats paths
summed them, so a reply cost as many times as it had blocks. One real
message in this repo's own transcripts spans 52 lines carrying 50.8K
tokens, and was counted as 2.65M.
Scanning ~/.claude/projects with 1927 sources: the total drops from
9.76B to 4.59B tokens, and the busiest day from 2.60B to 1.46B. 39,290
of 69,306 usage lines were repeats.
Usage records now deduplicate on (message.id, requestId), scoped per
transcript, and survive an incremental read. Lines with no message id
are still counted, matching ccusage.
Two paths compute these stats — the local index reducer and the direct
scan in stats.ts — and a parity test pins them to identical output. Both
carried the bug, so the rules that decide what counts now live in one
module, `utils/usageAccounting.ts`, rather than being written twice and
drifting.
Also fixed, all surfaced while verifying the above:
Cost was dead code. `costUSD` and `webSearchRequests` were initialized
to 0 in the reducer and never assigned, so 0 travelled to SQLite and out
again. Now estimated from the rates in modelCost.ts — but with an
unknown model returning null instead of falling back to the default
model's rates, because 12.7% of the tokens here come from third-party
providers (k3, glm, MiniMax, deepseek, grok) that would otherwise be
billed at Claude prices. Those models keep their tokens in the activity
totals and are named in `unpricedModels` so the UI can say the dollar
figure is a floor.
`MODEL_COSTS` has no entry for claude-opus-5 — the canonical-name
resolver maps it to `claude-opus`, which isn't a key either — so the
largest model by usage priced through the unknown-model fallback. The
new module handles it explicitly. The CLI's own calculateUSDCost still
takes that fallback; left alone here, it reaches well beyond activity
stats.
"Longest task" measured `last - first`, a calendar span rather than a
duration: a session resumed the next morning reported the whole night as
time on task. The panel read 436 hours for a 17-message session, and the
worst case on this machine was 1137 hours. Sessions now accumulate
working time across gaps under 30 minutes, stored in a new
`active_duration_ms` column.
Workflow subagent transcripts were never indexed. Discovery read one
level of `subagents/`, but workflow agents nest a group deeper at
`subagents/workflows/<id>/`; 79 files here were invisible to both the
index and the direct scan, which additionally attributed them to a
session named "workflows".
Ties in "longest session" now break on session id. The two paths iterate
sessions in different orders, and ties became reachable once an
out-of-order session scored 0 rather than a distinct negative span.
The parser version bump is what makes any of this reach an existing
install: `detectSourceChange` rebuilds a source only when its stored
version differs.
Pinning the metadata to the right end of the title row left a wide gap
between them, so the two read as unrelated blocks rather than one header.
Put it back on its own line under the title at 11px, and give the header
back some of the height it lost: a 17px title (15px in the workspace
layout) and py-3. Measured 66.75px on a real session, between the 123.5px
original and the 35.75px single-line version. The title still truncates to
one line with the full text in a hover tooltip, so a long title cannot push
the transcript down again.
An Auto-dark-mode user reported being flashed by a white window every
evening, then switching to a dark palette by hand. That is two defects,
and only one of them is the missing feature.
The flash fired even for someone who had already saved a dark palette.
`index.html` hardcoded `data-theme="white"` while the code that reads the
stored theme, `initializeTheme()`, only runs after the app bundle's
dynamic imports resolve. So every launch painted white first. A
synchronous inline script now resolves the theme before any stylesheet is
parsed, and Electron seeds `backgroundColor` from a cached appearance so
the window is not white before the renderer's first frame either.
Following the system is a switch in Settings -> General rather than a
seventh palette. The OS only reports dark/light while the app ships six
palettes, so each ground carries its own preference: the picker splits
into "use in light mode" (the four paper grounds) and "use in dark mode"
(the two ink ones), and a pick lands in the preference for its own ground.
Choosing ink-blue at noon is therefore remembered for that night rather
than fighting the OS. Detection goes through `prefers-color-scheme`
because the same renderer runs under Electron, the Tauri shell and the
browser entry, and the media query is the only signal all three share.
New installs follow the system; existing ones keep their fixed palette
until they opt in, so an update never silently repaints someone's app.
`nativeTheme.themeSource` is deliberately left alone, which is the part
most likely to be "fixed" later. Pinning it to the user's palette would
make context menus and the macOS frame agree with the app, but it is a
process-wide override of `prefers-color-scheme` — the very signal this
feature reads. Re-enabling the switch would then resolve against the
pinned value instead of the real OS setting, and the override also leaks
into the preview WebContentsView, forcing third-party pages to the app's
theme. A test fails on any assignment to it.
The OS-flip listener reads the preferences from storage rather than from
its own store. The pet and trace windows run the same bootstrap with
their own store instance over one shared localStorage, so after the main
window turns the switch off their in-memory copy still says "on" — acting
on it wrote the user's choice straight back out. A `storage` listener
catches the other windows up.
`settingsStore.theme` is gone. It was a copy that only refreshed on an
explicit `setTheme`, so an OS flip left the Settings picker highlighting
a palette that was no longer on screen. uiStore owns the theme; the copy
had no remaining readers.
The 「纸·墨·印」 rename reaches the new keys too: `light` -> `warm-classic`
now migrates for the per-ground preferences, not just the applied theme,
so the palette daytime returns to is not silently reset.
Guards, each verified by breaking what it protects: the inline script is
extracted from `index.html` and run verbatim against `resolveAppliedTheme`
over every stored combination — including dirty values, which are
reachable because it runs before the persistence migrations, and
cross-ground values like a dark palette stored as the light preference;
the three copies of the palette grounds (CSS `--cc-bg`, `index.html`,
main process) are pinned to each other and to `THEME_MODES`, so a seventh
palette cannot ship without a pre-paint color; the two grounds are proven
to cover every palette at compile time; and the IPC payload is held to a
literal 6-digit hex because `setBackgroundColor` also accepts
`#AARRGGBB`, where a translucent window means click-through and overlay
spoofing.
Four gaps against the official Grok CLI surface, found by comparing this
adapter with sub2api's and each confirmed by a probe against the live
endpoint.
The first is not a parity gap but a hard failure. A named tool_choice was
translated into Chat Completions syntax, `{type:'function',function:{name}}`,
which the Responses endpoint refuses:
422 data did not match any variant of untagged enum ModelToolChoice
so every request where the caller pins a tool failed outright. Responses
names the function inline. Only the Responses transform changes; the Chat
transform keeps the nested form, which is correct there.
The rest:
- A tool_choice sent with no tools, or one whose target the BatchTool
filter removed, is dropped rather than left selecting a tool the upstream
never receives — and a tool list that filters down to empty is omitted
instead of sent as `[]`.
- `prompt_cache_key` and the `x-grok-conv-id` header carry one identity per
conversation, reusing resolvePromptCacheKey (the Claude Code session id)
so a turn stops re-billing the whole prefix. Both survive the 401 refresh
retry: header assembly is folded into one helper so the first attempt and
the retry cannot drift apart.
- `x-grok-client-mode: interactive` matches what the official client sends.
Probes, in order: baseline 200; the three new fields together 200; the
inline tool_choice 200; the nested form 422.
Also lands the adapter-level regression test for the mid-stream socket
reset fixed in the previous commit — it drives a real reset through
buildGrokFetch and the SSE transform, so it needs both changes present.
A turn on the Grok provider died on `API Error: The socket connection was
closed unexpectedly` with partial text already on screen. That message is
Bun's, raised while reading a response body the peer reset: a raw TCP server
that sends chunked SSE headers plus one event and then terminates the socket
reproduces it exactly, as `Error{code:'ECONNRESET'}`.
The reset itself is the network — a stale pooled keep-alive socket, a
proxy/NAT dropping a reused connection, an edge resetting mid-flight — not
the payload. Ruled out against the live endpoint: twelve tools with a 21KB
skill tool_result, an 8-minute 11MB 47k-event generation, and a pooled
connection reused after 130s idle all complete normally.
What was broken is that nothing retried it. The error lands during stream
*consumption*, where all three recovery paths miss it:
- `withRetry` only wraps stream creation, so consumption never reaches it.
- `isRetryableStreamError` requires an APIError carrying `"type":"api_error"`
inside a 200 SSE body. A dead socket is a bare Error with neither.
- The desktop injects `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1`, so the
one remaining branch rethrows and ends the turn.
`isRetryableStreamTransportError` therefore walks the cause chain for a
transport code (ECONNRESET/ECONNABORTED/EPIPE/UND_ERR_SOCKET/
ERR_STREAM_PREMATURE_CLOSE) instead of keying on the error type, and
queryModel re-establishes the stream through the existing withStreamRetry
budget.
SSE has no resume, so the retry is a full re-send and is gated on the same
side-effect boundary the watchdog retry uses: StreamAssistantCommitBuffer
holds completed thinking/text unpublished, so they are discarded with the
attempt, but once a tool_use block completes or server-side tool activity
starts the disconnect surfaces to the user instead (#766 / inc-4258).
Keep-alive is disabled only when nothing arrived at all — no `message_start`
means the socket was already dead when the request went out, which is the
stale-pool signature, while a connection that broke later leaves the pool
trusted.
This preempts the non-streaming fallback for these disconnects, the same
trade the existing transient-error branch already makes.
Covered by the guard matrix (each transport code, bare and wrapped in
APIConnectionError; tool boundary crossed; watchdog abort; user abort;
non-transport error) and by withStreamRetry recovering a transport-flavoured
RetriableStreamError — the first one whose payload is not an SDK error
object — including the exhaustion branch that has to render it.
The redesigned chat header stacked a non-truncating 22px title above its
metadata row inside px-9 pt-6 pb-4, so a long session title wrapped onto
two lines and pushed the transcript down. Truncate the title on one line
at 15px, move the metadata onto that same row, and tighten the padding to
py-2: measured 123.5px -> 35.75px on a real session at a 1600px viewport,
with the full title kept in a hover tooltip. The wide and workspace-open
layouts now share one set of styles instead of drifting apart, and the
metadata separator is marked aria-hidden since it is pure decoration.
`TaskUpdate` treats `deleted` as a delete action, not a status — the CLI
tool unlinks the task file and returns early. The activity panel read it as
a status, and `normalizeTaskStatus` has no case for it, so it fell through
to `pending`. The row then survived `mergeTaskRowsById`, which only
overwrites rows the live list still knows about. A task the server had
already deleted stayed pinned to the panel as pending, badge included.
Reproduced end to end against a real provider (MiniMax-M3) on a throwaway
Node project: the model reached for `TaskUpdate{taskId:'1',status:'deleted'}`
on its own, `~/.claude/tasks/<session>/1.json` was gone afterwards, and
`GET /api/tasks/lists/:id` returned only the two survivors — while the panel
still rendered three rows with a badge of 1.
Three places needed it:
- Parsing `TaskUpdate` now removes the row instead of restating it. This
also stops the panel inventing a `Task #N` row when the deletion has no
matching `TaskCreate` in the same turn.
- Deletions are collected across turns. A task created in one turn is often
deleted in a later one, and per-turn parsing left the earlier row behind,
which also skewed the "Earlier tasks" roll-up to `1 of 2 / stopped`.
- `liveTasks` is filtered too. The task list only refreshes once the
`tool_result` lands, so between the deletion and that round trip it still
reports the deleted task.
`normalizeTaskStatus` keeps its `pending` fallback — `deleted` is now caught
upstream, and folding a delete action into the status enum is what caused
this in the first place.
Verified by replaying both real transcripts through the old and the new
model side by side: the single-turn session goes from 3 rows/badge 1 to
2 rows/badge 0, and the two-turn session from `Task #1 pending` plus a
`stopped` history row to just the one completed task. Four regression tests
cover same-turn deletion, deletion without a matching create, cross-turn
deletion, and a stale live list. `check:desktop` green at 3051 tests.
Project scope reads and writes disagreed about which file owns a server.
getMcpConfigsByScope('project') walks from the cwd up to the filesystem root
and inherits every .mcp.json it finds, so a server declared in an ancestor
directory shows up for a nested project -- with canRemove: true, because the
DTO derives that from the scope alone. removeMcpConfig('project') only ever
read the cwd's own .mcp.json, so it threw a plain Error for exactly those
servers, and deleteServer let it fall through to the errorHandler's 500 catch.
That is the reported pair of symptoms: the delete does nothing and the toast
says "An unexpected error occurred". Windows hits it easily, since dropping
.mcp.json in the home directory (mistaking it for global config) makes every
project below inherit servers that none of them can remove.
removeMcpConfig now resolves the file that actually declares the server, using
the same nearest-first order the reader uses, and edits that file. The edit is
applied to the raw parsed JSON rather than to a rebuilt McpJsonConfig: the old
rebuild ran through parseMcpConfigFromFilePath with expandVars: true, so
rewriting the file baked resolved ${VAR} values into it and dropped every
top-level key outside mcpServers, since the zod schema strips them. Lookup
matches on the raw object too, because validation is all-or-nothing per file --
one malformed entry would otherwise make every server in that file unremovable,
and would leave lookup and rewrite working off different views.
addMcpConfig had the same rebuild, so adding any project-scoped server rewrote
its siblings the same way. Measured against a live server with MY_MCP_TOKEN
set, "Bearer ${MY_MCP_TOKEN}" came back as "Bearer super-secret-value" -- a
plaintext credential written into a file that is normally committed. It now
inserts into the raw object as well, and its duplicate-name check reads the raw
file so a name sitting in an otherwise-invalid .mcp.json is still reported as a
conflict instead of being silently overwritten.
Two smaller corrections follow from the same asymmetry. deleteServer reports
why a removal failed (400 with the underlying message) instead of an opaque
500, and rejects unknown or non-editable scopes with a reason. configLocation
pointed at join(getCwd(), '.mcp.json') for every project-scoped server, naming
a file that does not exist for inherited ones; it now names the declaring file,
which is what tells a user where the server came from.
getProjectMcpConfigsFromCwd() has no callers left. Kept rather than deleted --
it is vendored upstream code and removing it widens future sync conflicts --
with its comment corrected, since it claimed to serve add/remove.
Verified over real HTTP against a server with an isolated CLAUDE_CONFIG_DIR,
not only through handleMcpApi: before the change the delete returned 500 with
the generic message and left the file untouched; after it returns 200, removes
the entry from the parent file, creates no .mcp.json in the cwd, and preserves
both $schema and the unexpanded ${MY_MCP_TOKEN} of a sibling server. The add
path was confirmed the same way. check:server: 226 files, 2405 tests, 0 failed.
One continuous thinking block arrived in the chat split across several
`已思考` bubbles, with nothing rendered between them. The report blamed
the model — DeepSeek — but the provider is not involved: the trace holds
a single well-formed thinking block, and reopening the session restores
it, because history is rebuilt from the transcript's own blocks.
The split comes from three pieces that are each correct alone. A
background (async) agent's tool activity is re-emitted as a normal
`tool_use_complete` / `tool_result` carrying `parentToolUseId`
(`handler.ts`, added in 1c554dc30 so background subagents stop showing
"no tool activity"). Those land at the end of `messages`. `MessageList`
then folds them into the parent agent card rather than rendering them
inline. But the merge test for a streamed thinking chunk asked whether
the *array tail* was thinking — and the tail was now a child tool call,
so every chunk after one started a new bubble. The split points are the
moments background activity arrived, which is why they look arbitrary
and why the gaps between bubbles are empty.
`findStreamMergeTargetIndex` skips those bubbled-child messages and
merges against the last real main-stream message instead. The same tail
test backed `appendAssistantTextMessage`, so a reply could be chopped
the same way; both now share the helper.
Reproducing needs thinking mode, a `run_in_background` agent, and child
activity landing mid-thought — rare enough that most sessions never see
it, and near-certain for a slow reasoning model made to wait on agents.
The screenshot shows exactly that: four subagents running, and the
mangled thought reads "The agents are still running. Let me wait a bit."
Covered by three tests: thinking stays one block across interleaved
child activity, a reply stays one block, and the main agent's own tool
call still opens a new block. Verified against the reproduction before
and after. Desktop suite 3050 tests green, tsc and build clean.
(cherry picked from commit 7d8a22f9465b8f987193dda7af5cae3858c481fc)
Independent QA marked three checks `not run` because they needed a human
at the keyboard. Each covers behavior this refactor changed, so they are
now assertions instead of intentions.
`A11Y-01` — a full keyboard tab-order audit. These components back
roughly 500 controls, so a decoration that picks up a tab stop or a
control that loses one would spread everywhere before anyone noticed.
`keyboard.test.tsx` fixes the property they must all share: interactive
components expose an exact number of tab stops, decorative ones expose
none, `disabled` and `loading` leave the sequence, a segmented control
stays one stop at any size, and every reachable control shows a focus
indicator.
That last check has two valid shapes, which the first version got wrong:
most controls style themselves, but `Switch` hides its native checkbox
under `peer sr-only` and paints the ring on the track beside it. Checking
only the focused node reported a missing indicator on a control that
draws one.
`ATT-02` — the image lightbox. Its arrows were icon-only with hardcoded
English before this branch; the existing suite only counted overlay
registrations. Now covers naming, wrap-around in both directions, arrow
keys, that the arrows disappear for a single image, and that keys stop
firing once closed.
`SEARCH-02` — stepping through find-in-page matches. `FindInPageModal`
had no `useTranslation` at all; its three controls are icon-only, so a
missing name leaves them unreachable by screen reader. Now covers naming,
the disabled state before a query matches, forward and backward wrap, and
close.
`CHAT-05` (queued message edit/delete) needed nothing — `ChatInput.test.tsx`
already covers it; QA had only skipped it by hand.
Each guard was verified by breaking the thing it protects: a `tabIndex`
on `Badge` fails the decorative check, and reverting the focus fix fails
the indicator check. Changed-lines coverage 91.32%.
Independent QA on 3264db10 flagged changed-lines coverage at 86.17%
(5539/6428), under the 90% gate. Two causes, handled differently.
`desktop/src/dev/` joins `mocks/` and `types/` in the coverage exclusions.
It holds the component gallery — 260 of the 889 uncovered lines, and by
far the largest single contributor. Vite never bundles it (the build
input is `index.html` alone), and unit-testing a page whose whole job is
rendering every primitive would assert that the primitives render, which
their own tests already do. Excluding it is a scope correction, not a
threshold adjustment.
The rest are three components this branch touched that had no test file
at all. They now have one each, covering the behavior that changed:
- `BackgroundTasksBar` — drawer open/close including Escape, the running
vs finished split, dismissed-key filtering, and that clearing reports
every finished key while keeping the drawer open if work continues.
- `TeamStatusBar` — the progress bar's `aria-valuenow`, lead exclusion
from both list and count, and that it greens on "nothing running"
rather than on 100%: one completed plus one errored is done at 50%,
which is why `tone="auto"` would have been wrong here.
- `MarketSkillDetail` — skeleton semantics, retry, install/uninstall by
`installState`, and the disabled+spinner state mid-install.
Changed-lines coverage: 91.06% (5610/6161).
The QA report's second finding, `check:impact` blocking on a missing
`allow-cli-core-change` label, is an artifact of the branch trailing
main. `check:impact` diffs against `main`, so main's own newer commits —
9 files under `src/` — are counted as this branch's. Against the merge
base the same evaluator returns `areas: desktop, blocked: false`. No code
change here; the branch needs a rebase before it can pass that lane.
`components/AGENTS.md` is the reference: what to use for a given need,
where a new component goes, and the style / i18n / a11y / test rules.
`desktop/AGENTS.md` now routes here — the line it replaces ("reuse the
existing desktop design system") named nothing to look up and so was not
an executable instruction for a person or a model.
`docs/component-library-plan.md` keeps the audit evidence and a record of
what actually shipped, including where the plan was wrong.
Two rules earned their own sections because the library broke them
itself:
- Overriding a component's utility with `className` does not reliably
win. Tailwind sorts same-utility arbitrary values by value and takes
the last, regardless of the order they were passed. `hoverTone="danger"`
was a no-op with exactly the two tones it was built for, and its test
passed because it only asserted the red class was *present*, never that
the neutral one was gone. Assert that a class prefix appears exactly
once, and prove it by reverting the fix.
- The library must not compose user-visible English. `SearchField` built
`Clear ${label}`, which made adopting it an i18n regression for every
caller that had already translated its clear button. The first repair —
falling back to `label` — was worse: the input and its clear button
then shared an accessible name and `getByLabelText` matched both.
Reuse went from 22% to 75% (524 component uses against 179 remaining
native buttons). The remainder are elements that should not be
components: `role="tab"`, `menuitem`, `option`, `treeitem`, `gridcell`,
whole-row and whole-card click targets, drag handles, and the OS
titlebar.
Renders every `components/ui` primitive under each of the three themes at
`/gallery.html`, reachable under `bun run dev` with no backend.
Unit tests assert structure and ARIA. They cannot tell whether a token
resolves to a readable color, whether an overlay lands above the thing it
is meant to cover, or whether an entrance animation actually plays. Three
defects surfaced here that had a full green suite behind them:
- `Button` and `IconButton` swallowed refs, so `Tooltip` had nothing to
measure and `Dropdown` could not return focus. Console warning only.
- The light theme's warning badge sat at 2.66:1.
- `Dropdown`'s Escape closed the dialog behind it as well.
Vite's build input is `index.html` alone, so this never enters the
production bundle.
`mockPaths.test.ts` asserts every `vi.mock` specifier resolves. A mock
pointing at a moved module is not an error — Vitest registers the factory
against a specifier nothing imports, the real module loads, and the test
keeps passing with its isolation quietly gone. That is what happened to
`RepositoryLaunchControls.test.tsx`, which kept mocking `./DirectoryPicker`
after the component moved to `composite/`, so the real picker and its API
calls rendered in a test that believed they were stubbed.
Suite updates for markup that legitimately changed shape:
- Dropdown entries are `role="option"` now, not buttons. `<button>` is
invalid inside a `role="listbox"` and left the dropdown without arrow
keys; the assertions follow the corrected semantics.
- `PetSettings`' toggles report `role="switch"`, the ARIA role for a
toggle. They were bare checkboxes while `McpSettings`' equivalent was
already a switch — the two are now consistent.
- `pages.test.tsx` drops its references to the two deleted mock pages.