import { Canvas, extend, useFrame, useThree } from '@react-three/fiber/webgpu'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera } from 'three'
import { SlugText, SlugStackText, SlugFontLoader, SlugFontStack } from '@three-flatland/slug/react'
import type { SlugFont, StyleSpan, TextMetrics } from '@three-flatland/slug/react'
} from '@three-flatland/devtools/react'
import { GemBackground, gemGradientCanvas2D } from './GemBackground'
import { GEM } from './gem'
extend({ SlugText, SlugStackText })
const FONT_URL = './Inter-Regular.ttf'
/** Font Awesome 6 Free Solid — baked subset of 12 icons in the PUA
* codepoints U+F000–U+F7FF. Demoed as a fallback font in the SlugFontStack:
* primary Inter has no PUA glyphs, so the stack routes those codepoints to
* FA. No TTF on disk — baked artifacts (`fa-solid.slug.{json,bin}`) are
* served directly; `SlugFontLoader` derives the baked URLs from the passed
* path. forceRuntime would 404 here, so the toggle is primary-only. */
const FA_FONT_URL = './fa-solid.ttf'
/** PUA codepoints for the baked FA icons. Keep in sync with the `-r` args
* in the slug-bake command that produced `fa-solid.slug.*`. */
`Built with ${ICON.code} and ${ICON.heart}\n` +
`${ICON.coffee} brewed ${ICON.rocket} launched ${ICON.bolt} fast`
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'
const LOREM_WORDS = LOREM.split(' ')
const MAX_WIDTH_FRACTION = 0.8
type CompareMode = 'off' | 'onion' | 'diff' | 'split'
const MODE_LABELS: Record<CompareMode, string> = {
onion: 'Canvas (Onion Skin)',
const FONT_SIZE_OPTIONS = {
const COMPARE_MODE_OPTIONS = {
function getLoremText(wordCount: number): string {
const words: string[] = []
for (let i = 0; i < wordCount; i++) {
words.push(LOREM_WORDS[i % LOREM_WORDS.length]!)
// --- Canvas2D text rendering (ported from examples/three/slug-text/main.ts) ---
* Line wrapping uses `font.wrapText` so line breaks match Slug's shaped output
* exactly — browser hinting at medium font sizes (48/72/96) can shrink
* `ctx.measureText` widths below the opentype-derived advances, giving a
* different line count and breaking vertical alignment.
* `fontFamily` is the `ctx.font` font-family list. For plain Inter use
* `'Inter-Slug, sans-serif'`; for icons mode use `'Inter-Slug, FA-Solid, sans-serif'`
* so the browser's native per-codepoint fallback mirrors the Slug font
* stack (Inter → FA-Solid).
* `preWrappedLines` overrides the internal `font.wrapText` — pass this in
* icons mode with `SlugFontStack.wrapText` so line breaks agree with the
* per-codepoint advances that `SlugStackText` actually uses.
function drawCompareText(
ctx: CanvasRenderingContext2D,
fontFamily: string = 'Inter-Slug, sans-serif',
preWrappedLines: string[] | null = null
const dpr = window.devicePixelRatio
const w = ctx.canvas.width / dpr
const h = ctx.canvas.height / dpr
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
/* Paint the SAME gem gradient as the Slug WebGPU canvas behind
* the compare text so the per-pixel BG matches. Diff mode then
* only highlights text differences instead of the full gradient
* mismatch. See gemGradientCanvas2D doc in GemBackground.tsx. */
gemGradientCanvas2D(ctx, GEM)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.fillStyle = mode === 'onion' ? 'rgba(255, 100, 100, 0.6)' : '#ffffff'
ctx.textBaseline = 'alphabetic'
const lines = preWrappedLines ?? font.wrapText(text, fontSize, maxWidth)
const lineHeightPx = fontSize * lineHeight
const totalBlockHeight = (lines.length - 1) * lineHeightPx
const baselineY = h / 2 - totalBlockHeight / 2
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i]!, w / 2, baselineY + i * lineHeightPx)
compareCtx: CanvasRenderingContext2D,
gpuCanvas: HTMLCanvasElement,
fontFamily: string = 'Inter-Slug, sans-serif',
preWrappedLines: string[] | null = null
const cw = compareCtx.canvas.width
const ch = compareCtx.canvas.height
const canvasPixels = compareCtx.getImageData(0, 0, cw, ch)
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')!
tempCtx.drawImage(gpuCanvas, 0, 0, gpuCanvas.width, gpuCanvas.height, 0, 0, cw, ch)
const gpuPixels = tempCtx.getImageData(0, 0, cw, ch)
const lum = (r: number, g: number, b: number) => r * 0.2126 + g * 0.7152 + b * 0.0722
const out = compareCtx.createImageData(cw, ch)
const cd = canvasPixels.data
const gd = gpuPixels.data
for (let i = 0; i < cd.length; i += 4) {
const lumCanvas = lum(cd[i]!, cd[i + 1]!, cd[i + 2]!)
const lumGpu = lum(gd[i]!, gd[i + 1]!, gd[i + 2]!)
const diff = Math.abs(lumCanvas - lumGpu)
const t = Math.min((diff - lo) / (hi - lo), 1)
od[i] = Math.round(80 + 175 * t)
od[i + 1] = Math.round(40 * (1 - t))
compareCtx.putImageData(out, 0, 0)
// --- Scene components ---
/** Syncs ortho camera to 1:1 pixel mapping on every resize. */
const camera = useThree((s) => s.camera) as OrthographicCamera
const size = useThree((s) => s.size)
camera.left = -size.width / 2
camera.right = size.width / 2
camera.top = size.height / 2
camera.bottom = -size.height / 2
camera.updateProjectionMatrix()
/** Surfaces the WebGPU canvas element to the parent for pixel reads (diff mode). */
function CanvasGrabber({ onReady }: { onReady: (canvas: HTMLCanvasElement) => void }) {
const gl = useThree((s) => s.gl)
onReady(gl.domElement as HTMLCanvasElement)
* Force the WebGPU renderer's pixel ratio to match the tracked DPR.
* R3F's `<Canvas>` captures DPR at mount and doesn't re-sync on
* monitor-swap / OS-zoom / fullscreen transitions. Post-transition the
* Slug canvas ends up at the old ratio while the compare canvas uses
* the live DPR — producing sub-pixel drift and visible desync.
* This component lives inside `<Canvas>` and pushes `windowSize.dpr`
* onto the renderer whenever it changes.
function DprSync({ dpr }: { dpr: number }) {
const gl = useThree((s) => s.gl)
gl.setPixelRatio(Math.min(dpr, 2))
/** Renders SlugText with per-frame updates. */
align: 'left' | 'center' | 'right'
styles: readonly StyleSpan[]
outlineStyle: 'fill' | 'outline' | 'both'
const ref = useRef<SlugText>(null)
const { camera, size } = useThree()
ref.current?.setViewportSize(size.width, size.height)
// Runtime uniform updates — avoid React re-rendering the JSX prop on
// every slider tick by mutating the material in place. The `outline`
// prop on <slugText> configures the mesh's child; width/color changes
if (outlineStyle === 'fill') {
mesh.outline = { width: outlineWidth, color: outlineColor }
ref.current?.setOutlineWidth(outlineWidth)
ref.current?.setOutlineColor(outlineColor)
// Fill opacity: when the user selects Outline-only, drop the fill
// alpha to 0 so only the stroke pass shows. Transparent blend on the
// fill material handles the composite.
ref.current?.setOpacity(outlineStyle === 'outline' ? 0 : 1)
ref.current?.update(camera)
maxWidth={size.width * MAX_WIDTH_FRACTION}
/** Renders SlugStackText — used for the icon-fallback demo.
* 1:1 parity with SlugTextScene for styles + outline controls so
* icons mode isn't feature-starved relative to fill mode. */
function SlugStackTextScene({
styles: readonly StyleSpan[]
outlineStyle: 'fill' | 'outline' | 'both'
const ref = useRef<SlugStackText>(null)
const { camera, size } = useThree()
ref.current?.setViewportSize(size.width, size.height)
if (outlineStyle === 'fill') {
mesh.outline = { width: outlineWidth, color: outlineColor }
ref.current?.setOutlineWidth(outlineWidth)
ref.current?.setOutlineColor(outlineColor)
// Outline-only mode: drop fill alpha to 0. Parity with SlugTextScene.
ref.current?.setOpacity(outlineStyle === 'outline' ? 0 : 1)
ref.current?.update(camera)
maxWidth={size.width * MAX_WIDTH_FRACTION}
// --- Compare UI components ---
function useWindowSize() {
const [size, setSize] = useState(() => ({
dpr: window.devicePixelRatio,
// Measure all three together — DPR changes can happen without a
// dimension change (monitor swap), and fullscreen transitions fire
// resize events that are sometimes dispatched before the layout
// viewport has actually settled. Reading live from window on every
// event keeps the canvas DPR-aware on multi-monitor setups and
// correct after fullscreen enter/exit.
dpr: window.devicePixelRatio,
// The `resolution` media query fires whenever DPR changes — covers
// moving the window between monitors with different scale factors,
// system zoom, OS UI-scale changes. Re-subscribed each time because
// the matched resolution changes.
let mediaQuery: MediaQueryList | null = null
const attachDprListener = () => {
mediaQuery?.removeEventListener('change', onDprChange)
mediaQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
mediaQuery.addEventListener('change', onDprChange)
const onDprChange = () => {
// Re-measure once more on the next frame after a fullscreen
// change — the 'resize' event that browsers fire on fullscreen
// transition can land before the document has finished re-layout,
// leaving innerWidth/innerHeight stale for one tick.
const onFullscreenChange = () => {
requestAnimationFrame(measure)
window.addEventListener('resize', measure)
document.addEventListener('fullscreenchange', onFullscreenChange)
window.removeEventListener('resize', measure)
document.removeEventListener('fullscreenchange', onFullscreenChange)
mediaQuery?.removeEventListener('change', onDprChange)
stack: SlugFontStack | null
gpuCanvas: HTMLCanvasElement | null
windowSize: { w: number; h: number; dpr: number }
const canvasRef = useRef<HTMLCanvasElement>(null)
const [computing, setComputing] = useState(false)
const canvas = canvasRef.current
// Pull DPR from the tracked state (not window.devicePixelRatio
// directly) so monitor swaps + scale changes re-run this effect.
canvas.width = windowSize.w * windowSize.dpr
canvas.height = windowSize.h * windowSize.dpr
canvas.style.width = `${windowSize.w}px`
canvas.style.height = `${windowSize.h}px`
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
const maxWidth = windowSize.w * MAX_WIDTH_FRACTION
const fontFamily = iconsMode ? 'Inter-Slug, FA-Solid, sans-serif' : 'Inter-Slug, sans-serif'
// In icons mode, wrap via the stack so line breaks agree with
// `SlugStackText` (which uses per-codepoint FA advance widths). The
// primary-only `font.wrapText` would diverge as soon as FA glyphs
// push a line over the limit.
const preWrappedLines = iconsMode && stack ? stack.wrapText(text, fontSize, maxWidth) : null
// Defer Canvas2D draws so the Slug WebGPU canvas has a chance to
// render the new content before the compare overlay updates.
// useEffect fires synchronously after commit, but R3F's useFrame
// (and thus the WebGPU submit) runs on the next RAF — without a
// delay, Canvas2D paints one frame *ahead* of Slug and you see a
// visible flash on scene toggle, word-count changes, font reload.
// requestIdleCallback runs after layout/paint/commit, so the
// WebGPU frame has already committed by the time our callback
// fires. Timeout caps at ~2 frames so bursty activity can't
// starve the compare overlay. Safari is polyfilled at the
// example entry (main.tsx) — the API is universal here.
const idleId = requestIdleCallback(
const t = setTimeout(() => setComputing(false), 1000)
cancelIdleCallback(idleId)
const idleId = requestIdleCallback(
return () => cancelIdleCallback(idleId)
}, [font, stack, text, fontSize, mode, stemDarken, thicken, windowSize, gpuCanvas, iconsMode])
clipPath: `inset(0 0 0 ${splitX}px)`,
{computing && <ComputingIndicator />}
function SplitHandle({ splitX, onDrag }: { splitX: number; onDrag: (x: number) => void }) {
const [dragging, setDragging] = useState(false)
const onMove = (e: PointerEvent) => {
onDrag(Math.max(0, Math.min(e.clientX, window.innerWidth)))
const onUp = () => setDragging(false)
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
background: 'rgba(255, 255, 255, 0.5)',
transform: 'translateY(-50%)',
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.4)',
justifyContent: 'center',
color: 'rgba(255, 255, 255, 0.7)',
function SplitLabels({ splitX, mode }: { splitX: number; mode: CompareMode }) {
/* Labels are anchored to the canvas's bottom-left (SLUG) and
* bottom-right (compare). They stay locked there as the split line
* moves — only when the line approaches within `GAP` of a label do
* we slide that label horizontally via `transform: translateX(...)`
* so the line can pass without overlap. Math references the
* respective viewport edge (left for SLUG, right for compare). */
const slugRef = useRef<HTMLDivElement>(null)
const c2dRef = useRef<HTMLDivElement>(null)
const vw = typeof window !== 'undefined' ? window.innerWidth : 0
// Read live offsetWidth so changing the compare-mode label text
// (split/onion/diff) keeps the push threshold accurate.
const slugW = slugRef.current?.offsetWidth ?? 50
const c2dW = c2dRef.current?.offsetWidth ?? 80
const slugPush = Math.max(0, PADDING + slugW + GAP - splitX)
const c2dPush = Math.max(0, splitX - (vw - PADDING - c2dW - GAP))
const base: React.CSSProperties = {
background: 'rgba(0, 2, 28, 0.7)',
// No transform transition — JS updates this every drag frame; a
// transition would lag behind the cursor and visibly oscillate.
<div ref={slugRef} style={{ ...base, color: '#fff', left: PADDING, transform: `translateX(${-slugPush}px)` }}>SLUG</div>
<div ref={c2dRef} style={{ ...base, color: '#ff6464', right: PADDING, transform: `translateX(${c2dPush}px)` }}>{MODE_LABELS[mode]}</div>
* Hover any rendered line → measure overlays (cyan tight ink, yellow
* dashed font envelope) appear and the parent's `onMetrics` fires for
* that line. Live, transient — leave the line and the overlays + measure
function MeasureOverlay({
windowSize: { w: number; h: number }
onMetrics: (m: TextMetrics | null) => void
const shapedLines = useMemo(
() => font.wrapText(text, fontSize, maxWidth),
[font, text, fontSize, maxWidth]
const lineCount = shapedLines.length
const lineMetrics = useMemo(
() => shapedLines.map((line) => font.measureText(line, fontSize)),
[font, shapedLines, fontSize]
const [hoveredLine, setHoveredLine] = useState<number | null>(null)
const overlayLine = hoveredLine
const overlayMetrics = overlayLine != null ? lineMetrics[overlayLine] : null
// Surface metrics for whichever line is hovered (or null when none).
onMetrics(overlayMetrics ?? null)
}, [overlayMetrics, onMetrics])
const lineHeightPx = fontSize * LINE_HEIGHT
const firstBaselineY = windowSize.h / 2 - ((lineCount - 1) * lineHeightPx) / 2
const centerX = windowSize.w / 2
const baselineFor = (lineIndex: number) => firstBaselineY + lineIndex * lineHeightPx
{/* Per-line hit-rects — transparent, hover → measure, click → select. */}
{lineMetrics.map((m, i) => {
const by = baselineFor(i)
onPointerEnter={() => setHoveredLine(i)}
onPointerLeave={() => setHoveredLine((cur) => (cur === i ? null : cur))}
left: centerX - m.width / 2,
top: by - m.fontBoundingBoxAscent,
height: m.fontBoundingBoxAscent + m.fontBoundingBoxDescent,
// Below the split handle (z:2) so its drag always wins.
{/* Measure overlays follow the hovered line. */}
{overlayMetrics && overlayLine != null && (
left: centerX - overlayMetrics.width / 2,
top: baselineFor(overlayLine) - overlayMetrics.fontBoundingBoxAscent,
width: overlayMetrics.width,
height: overlayMetrics.fontBoundingBoxAscent + overlayMetrics.fontBoundingBoxDescent,
border: '1px dashed rgba(255, 214, 102, 0.8)',
left: centerX - overlayMetrics.width / 2 - overlayMetrics.actualBoundingBoxLeft,
top: baselineFor(overlayLine) - overlayMetrics.actualBoundingBoxAscent,
width: overlayMetrics.actualBoundingBoxLeft + overlayMetrics.actualBoundingBoxRight,
overlayMetrics.actualBoundingBoxAscent + overlayMetrics.actualBoundingBoxDescent,
border: '1px solid rgba(102, 217, 239, 0.9)',
function ComputingIndicator() {
style={{ animation: 'slug-spin 0.7s linear infinite' }}
stroke="rgba(255,255,255,0.15)"
d="M12 2a10 10 0 0 1 10 10"
<style>{`@keyframes slug-spin { to { transform: rotate(360deg); } }`}</style>
export default function App() {
const { pane } = usePane()
// Top-of-pane toggle bar — scene selector. Inline radiogrid (essentials)
// gives an active-state button affordance that reads better than a
// dropdown for a two-way scene switch.
const [scene] = usePaneRadioGrid<'lorem' | 'icons'>(pane, {
{ title: 'Lorem', value: 'lorem' },
{ title: 'Icons', value: 'icons' },
const iconsMode = scene === 'icons'
const settings = usePaneFolder(pane, 'Settings')
const [fontSize] = usePaneInput<number>(settings, 'size', 48, { options: FONT_SIZE_OPTIONS })
const [wordCount] = usePaneInput<number>(settings, 'words', 20, { min: 5, max: 200, step: 1 })
const [stemDarken] = usePaneInput<number>(settings, 'darken', 0, { min: 0, max: 2, step: 0.01 })
const [thicken] = usePaneInput<number>(settings, 'thicken', 0, { min: 0, max: 2, step: 0.01 })
// Outline folder — Phase 4 stroke surface. `style` toggles which meshes
// render: Fill = fill only (stroke alpha=0), Outline = stroke only
// (fill alpha=0), Both = both visible and composited.
const outline = usePaneFolder(pane, 'Outline')
const [outlineStyle] = usePaneInput<'fill' | 'outline' | 'both'>(outline, 'style', 'fill', {
options: { Fill: 'fill', Outline: 'outline', Both: 'both' },
const [outlineWidth] = usePaneInput<number>(outline, 'width', 0.025, {
const [outlineColor] = usePaneInput<string>(outline, 'color', '#000000')
const mode = usePaneFolder(pane, 'Mode')
const [compareMode] = usePaneInput<CompareMode>(mode, 'compare', 'onion', {
options: COMPARE_MODE_OPTIONS,
const [forceRuntime] = usePaneInput<boolean>(mode, 'forceRuntime', false, {
// Measure folder: paragraph monitors are live (always populate for the
// currently-rendered block); line-level monitors populate when a line
// is clicked and reset when deselected.
// Styles folder — demonstrates the public StyleSpan API by applying
// decorations to one of three preset character ranges. Anything richer
// (per-character spans, multiple stacked styles, click-and-drag
// selection) is rich-text editor territory and lives in a future
// example. Here we just prove `font.emitDecorations` round-trips.
const stylesFolder = usePaneFolder(pane, 'Styles')
const [styleScope] = usePaneInput<'word' | 'sentence' | 'line'>(stylesFolder, 'scope', 'word', {
options: { 'First word': 'word', 'First sentence': 'sentence', 'First line': 'line' },
const [styleUnderline] = usePaneInput<boolean>(stylesFolder, 'underline', false)
const [styleStrike] = usePaneInput<boolean>(stylesFolder, 'strike', false)
const measure = usePaneFolder(pane, 'Measure')
const numFmt = (v: number) => v.toFixed(1)
const intFmt = (v: number) => v.toFixed(0)
const [, setParaWidth] = usePaneInput<number>(measure, 'paraWidth', 0, {
const [, setParaHeight] = usePaneInput<number>(measure, 'paraHeight', 0, {
const [, setParaLines] = usePaneInput<number>(measure, 'paraLines', 0, {
const [, setWidth] = usePaneInput<number>(measure, 'width', 0, {
const [, setActualAscent] = usePaneInput<number>(measure, 'actualAscent', 0, {
const [, setActualDescent] = usePaneInput<number>(measure, 'actualDescent', 0, {
const [, setFontAscent] = usePaneInput<number>(measure, 'fontAscent', 0, {
const [, setFontDescent] = usePaneInput<number>(measure, 'fontDescent', 0, {
const handleMetrics = useCallback(
(m: TextMetrics | null) => {
setActualAscent(m.actualBoundingBoxAscent)
setActualDescent(m.actualBoundingBoxDescent)
setFontAscent(m.fontBoundingBoxAscent)
setFontDescent(m.fontBoundingBoxDescent)
[setWidth, setActualAscent, setActualDescent, setFontAscent, setFontDescent]
const [font, setFont] = useState<SlugFont | null>(null)
const [iconFont, setIconFont] = useState<SlugFont | null>(null)
const [gpuCanvas, setGpuCanvas] = useState<HTMLCanvasElement | null>(null)
const windowSize = useWindowSize()
const [splitX, setSplitX] = useState(() => Math.round(window.innerWidth / 2))
() => (iconsMode ? ICON_DEMO : getLoremText(wordCount)),
() => (font && iconFont ? new SlugFontStack([font, iconFont]) : null),
// Compute the demo span [start, end) from the chosen scope. Falls back
// to the entire text if the heuristic finds nothing (e.g. a line scope
// with no wrapping yet).
const styleRange = useMemo<{ start: number; end: number }>(() => {
if (styleScope === 'word') {
const m = text.match(/^\S+/)
return { start: 0, end: m ? m[0].length : 0 }
if (styleScope === 'sentence') {
const m = text.match(/^[^.!?]*[.!?]?/)
return { start: 0, end: m ? m[0].length : text.length }
// 'line' — first wrapped line via the font's own wrap.
const lines = font.wrapText(text, fontSize, windowSize.w * MAX_WIDTH_FRACTION)
return { start: 0, end: lines[0]?.length ?? 0 }
return { start: 0, end: 0 }
}, [styleScope, text, font, fontSize, windowSize.w])
const styles = useMemo<StyleSpan[]>(() => {
if (!styleUnderline && !styleStrike) return []
if (styleRange.start === styleRange.end) return []
underline: styleUnderline,
}, [styleRange, styleUnderline, styleStrike])
setSplitX(Math.round(windowSize.w / 2))
// Live paragraph monitors — always reflect the currently-rendered text.
const p = font.measureParagraph(text, fontSize, {
maxWidth: windowSize.w * MAX_WIDTH_FRACTION,
setParaLines(p.lines.length)
}, [font, text, fontSize, windowSize, setParaWidth, setParaHeight, setParaLines])
// Load Inter — reloads when `forceRuntime` changes. The static cache is
// keyed on `${url}:runtime?`, so toggling uses a fresh slot; no manual
// clearCache needed. @font-face preloads keep Canvas2D compare + icons
// overlay aligned with the Slug shaping on first paint.
document.fonts.load('48px Inter-Slug'),
document.fonts.load('48px FA-Solid'),
SlugFontLoader.load(FONT_URL, { forceRuntime })
if (!cancelled) setFont(f)
if (!cancelled) console.error('[slug-text] Inter load failed:', err)
// Icon fallback font — baked-only (no .ttf on disk), independent of
// forceRuntime. Load once on mount.
SlugFontLoader.load(FA_FONT_URL)
if (!cancelled) setIconFont(f)
if (!cancelled) console.error('[slug-text] FA load failed:', err)
camera={{ position: [0, 0, 100], near: 0.1, far: 1000 }}
// Slug provides its own analytic anti-aliasing via per-fragment
// coverage — MSAA adds 4× sample cost + a canvas-area resolve pass
// for zero visual gain. Keep it off.
renderer={{ antialias: false }}
<GemBackground gem={GEM} />
<DprSync dpr={windowSize.dpr} />
<DevtoolsProvider name="slug-text" />
<CanvasGrabber onReady={setGpuCanvas} />
outlineStyle={outlineStyle}
outlineWidth={outlineWidth}
outlineColor={outlineColor}
outlineStyle={outlineStyle}
outlineWidth={outlineWidth}
outlineColor={outlineColor}
{/* Compare overlay + split affordance only when comparison is
on. `off` mode hides every piece of the overlay so the
Slug canvas renders standalone — useful for pure-signal
verification and for screenshotting. */}
{compareMode !== 'off' && (
<SplitHandle splitX={splitX} onDrag={setSplitX} />
<SplitLabels splitX={splitX} mode={compareMode} />
{/* Measure overlays are primary-font only — in icons mode they
would misreport FA glyph widths (treated as notdef), so hide. */}
maxWidth={windowSize.w * MAX_WIDTH_FRACTION}
onMetrics={handleMetrics}