fix(ui): pin lucide icon rasters globally to stop subpixel wobble - #897
Conversation
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 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Icon wobble has been fixed six times, always the same way, one surface at a time (#165, #423, #424, #782, #812, 94f8bfe): 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 <svg class="lucide-icon lucide ...">, 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 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
… pin 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 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 76c0aa3133
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| svg.lucide, | ||
| .stable-raster, | ||
| .stable-raster-glyphs svg { | ||
| transform: translateZ(0); |
There was a problem hiding this comment.
Preserve transforms for animated lucide icons
This selector now gives every Lucide SVG a non-none base transform. That includes the loading toast icon in apps/staged/src/lib/components/ui/sonner/sonner.svelte, which uses Tailwind's animate-spin; Tailwind's spin keyframes only set to { transform: rotate(360deg) }, so the animation starts from translateZ(0) instead of rotate(0deg) and can collapse to a static identity transform. Please exclude animated icons from this pin or compose the raster pin into a spin-specific keyframe so loading indicators still visibly rotate.
Useful? React with 👍 / 👎.
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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Summary
Chat/timeline icons wobbled by a subpixel on hover and repaint in WKWebView because tiny glyph rasters were re-snapped whenever their surroundings repainted. This branch fixes that at the source and removes the per-site workarounds it obsoletes.
svg.lucide) onto its own compositor layer via the existingtransform: translateZ(0)rule inapp.css, so repaints can't re-snap glyph rasters anywhere in the app.stable-rasterto the non-lucide brand marks inAgentIcon.svelte(goose PNG mask and the claude/codex/amp/copilot/cursor inline SVGs), which the global lucide pin doesn't reach.stable-raster-glyphshints frombadge.svelte,button.svelte, andTimelineRow.svelte; the class itself remains for the few non-lucide raster surfaces that still need it.🤖 Generated with Claude Code