From 94f8bfef92970ce850e148a68e954683578a1259 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 30 Jul 2026 12:10:02 +1000 Subject: [PATCH 1/4] fix(chat): stop collapsed tool-call status icon wobbling on hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering a collapsed tool call underlines its verb, repainting the row. The status-icon SVG is centered in an 11px/1.5-line-height flex row, so it sits at a fractional pixel offset; without its own compositor layer WebKit re-rasterizes it during the hover repaint and it re-snaps between pixels, producing a visible wobble. Pin the icon with the shared stable-raster-glyphs compositor hint so it rasterizes once on its own layer and stays put — the same fix already applied to buttons, badges, and timeline icons in #812. Applied at the toolStatusDot snippet so both single and grouped tool cards are covered. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src/lib/features/sessions/SessionChatPane.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte index e494067f..11582fe4 100644 --- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte @@ -1741,7 +1741,7 @@ {#snippet toolStatusDot(statusTone: RichToolItem['statusTone'])} Date: Thu, 30 Jul 2026 12:56:49 +1000 Subject: [PATCH 2/4] fix(ui): pin every lucide icon's raster globally to stop subpixel wobble Icon wobble has been fixed six times, always the same way, one surface at a time (#165, #423, #424, #782, #812, 94f8bfef): give the icon its own compositor layer so WKWebView repaints can't re-rasterize it at a new fractional-pixel snap. The fix kept recurring because the stable-raster hint is opt-in per element. Every icon in the app renders via @lucide/svelte as , so extend the existing compositor hint in app.css with a global svg.lucide selector. This retroactively covers all past per-surface fixes and pins every future icon by default. The opt-in .stable-raster / .stable-raster-glyphs classes stay for the few non-lucide raster surfaces (badges, AgentIcon). Checked for conflicts per the investigation note: the rotate(90deg) caret rules target wrapper spans, not the SVG, so nothing overrides the pin. svelte-check and tsc pass. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src/app.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/staged/src/app.css b/apps/staged/src/app.css index d57f79c9..18cd8729 100644 --- a/apps/staged/src/app.css +++ b/apps/staged/src/app.css @@ -216,7 +216,11 @@ body { background: var(--scrollbar-thumb-hover); } -/* Keep compositor hints limited to tiny glyph and badge surfaces. */ +/* Pin tiny glyph rasters on their own compositor layer so repaints + can't re-snap them (WKWebView subpixel wobble). Every lucide icon + is covered by default; .stable-raster(-glyphs) remain for the few + non-lucide raster surfaces. */ +svg.lucide, .stable-raster, .stable-raster-glyphs svg { transform: translateZ(0); From 76c0aa3133616811e261ddb6337c0e403b3dda3a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 30 Jul 2026 13:03:53 +1000 Subject: [PATCH 3/4] refactor(ui): drop per-site raster hints covered by the global lucide pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the global svg.lucide compositor rule: the per-site stable-raster-glyphs sprinkles on the button and badge variants, the collapsed tool-call status dot, and the timeline meta-badge only exist to pin lucide icons, which the global rule now covers — remove them. The plain stable-raster hints stay where they pin the element's own raster (badges, timeline icons, repo badges). Hashtag badges keep stable-raster-glyphs: their icons are raw SVG strings duplicating lucide paths (renderHashtagTokens emits plain HTML where components can't render), so they carry no lucide class and the global rule misses them. Pin AgentIcon's five raw brand marks and the goose mask span with stable-raster — the note's one remaining unpinned non-lucide surface; the Bot fallback is lucide and already covered. This also keeps AgentIcon stable inside buttons and badges now that those no longer pin descendant SVGs. svelte-check, tsc, and all 451 vitest tests pass. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../src/lib/components/ui/badge/badge.svelte | 2 +- .../lib/components/ui/button/button.svelte | 2 +- .../src/lib/features/agents/AgentIcon.svelte | 19 +++++++++++++------ .../features/sessions/SessionChatPane.svelte | 2 +- .../lib/features/timeline/TimelineRow.svelte | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/staged/src/lib/components/ui/badge/badge.svelte b/apps/staged/src/lib/components/ui/badge/badge.svelte index 405eda89..9634f85e 100644 --- a/apps/staged/src/lib/components/ui/badge/badge.svelte +++ b/apps/staged/src/lib/components/ui/badge/badge.svelte @@ -2,7 +2,7 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const badgeVariants = tv({ - base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-[color,background-color,border-color,box-shadow,opacity] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge stable-raster stable-raster-glyphs inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:ring-[3px] [&>svg]:pointer-events-none', + base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-[color,background-color,border-color,box-shadow,opacity] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge stable-raster inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:ring-[3px] [&>svg]:pointer-events-none', variants: { variant: { default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', diff --git a/apps/staged/src/lib/components/ui/button/button.svelte b/apps/staged/src/lib/components/ui/button/button.svelte index 51098b4a..27440616 100644 --- a/apps/staged/src/lib/components/ui/button/button.svelte +++ b/apps/staged/src/lib/components/ui/button/button.svelte @@ -4,7 +4,7 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const buttonVariants = tv({ - base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button stable-raster-glyphs inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity] outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity] outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/80', diff --git a/apps/staged/src/lib/features/agents/AgentIcon.svelte b/apps/staged/src/lib/features/agents/AgentIcon.svelte index eb3a683e..0ae18b3f 100644 --- a/apps/staged/src/lib/features/agents/AgentIcon.svelte +++ b/apps/staged/src/lib/features/agents/AgentIcon.svelte @@ -16,6 +16,10 @@ monochrome and inherit currentColor. Goose is a PNG tinted via a CSS mask (ported from goose-internal's GooseIcon). + The raw brand marks aren't lucide icons, so the global svg.lucide + compositor pin doesn't reach them — they opt into stable-raster to + avoid WKWebView subpixel wobble. + Props: id - provider id (the join key shared by the picker and Doctor) size - icon dimensions in px (default 16) @@ -40,11 +44,14 @@ {#if id === 'goose'} - {:else if id === 'claude'} {:else if id === 'codex'} {:else if id === 'amp'} {:else if id === 'copilot'} {:else if id === 'cursor'} + {#if badge.icon === 'warning'} {:else} From 0d0c8543d2923de707072183a673fd6c47e975b6 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 30 Jul 2026 14:13:20 +1000 Subject: [PATCH 4/4] fix(ui): exclude animate-spin icons from the global lucide raster pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review on the global svg.lucide translateZ(0) rule flagged that it breaks Tailwind's animate-spin: the spin keyframes only define to { rotate(360deg) }, so the implicit from frame inherits the pinned translateZ(0). Mismatched transform lists interpolate via matrix decomposition, where rotate(360deg) is the identity matrix — the animation collapses to identity -> identity and the icon never visibly rotates. The one affected surface is the sonner loading-toast icon (sonner.svelte puts animate-spin directly on the lucide svg); the shared Spinner component is raw SVG with SMIL animateTransform and all caret/pane transforms target wrappers, so they are unaffected. Exclude spinners with :not(.animate-spin): a continuously rotating icon cannot exhibit subpixel wobble and is promoted to its own compositor layer while animating anyway, so the exclusion loses nothing. svelte-check and tsc pass. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- apps/staged/src/app.css | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/staged/src/app.css b/apps/staged/src/app.css index 18cd8729..f446775d 100644 --- a/apps/staged/src/app.css +++ b/apps/staged/src/app.css @@ -219,8 +219,15 @@ body { /* Pin tiny glyph rasters on their own compositor layer so repaints can't re-snap them (WKWebView subpixel wobble). Every lucide icon is covered by default; .stable-raster(-glyphs) remain for the few - non-lucide raster surfaces. */ -svg.lucide, + non-lucide raster surfaces. + + Spinning icons are excluded: Tailwind's spin keyframes only define + `to { rotate(360deg) }`, so the implicit `from` frame would inherit + this translateZ(0) — mismatched transform lists then interpolate via + matrices, where rotate(360deg) is the identity, freezing the spinner. + A continuously rotating icon can't wobble and is layer-promoted while + animating anyway. */ +svg.lucide:not(.animate-spin), .stable-raster, .stable-raster-glyphs svg { transform: translateZ(0);