From Component Extractor
Make a faithful, deployable replica of a WebGL/WebGL2 canvas component — particle fields, point-cloud globes, shader backgrounds, GPU-driven animations — by reading its real shaders + setup source, instrumenting its live GL context to capture exact uniforms/camera/scene-graph, and reusing its downloadable assets, instead of eyeballing pixels. Use when the thing you need to clone, extract, or replicate is a <canvas> rendered with WebGL (Three.js or raw WebGL2). Do NOT use for inline SVG, declarative CSS/SMIL, Lottie/Rive, or video — those are sibling extraction skills with a completely different strategy.
How this skill is triggered — by the user, by Claude, or both
Slash command
/component-extractor:webglThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> **Status: WIP.** Written from one real run (Stripe `GradientNoiseGlobe`, see
Status: WIP. Written from one real run (Stripe
GradientNoiseGlobe, see../stripe-money-movement/). The pipeline + gotchas are battle-tested, and live GL instrumentation (step 3) is now proven: it replaced the globe's eye-tuned scale/camera with captured values. Arc/timing fidelity is still approximate (uncaptured).
Make a faithful, deployable replica of a WebGL/WebGL2 canvas component (particle fields, globes, shader backgrounds) by reading its actual shaders + setup source, instrumenting its live GL context to capture real uniforms/camera, and reusing its downloadable assets — not by eyeballing pixels.
One of a family of per-rendering extraction skills. This file covers WebGL canvases. If the target is inline SVG, declarative CSS/SMIL, Lottie/Rive, or a video, use the sibling skill — the strategy is completely different.
Driver: browser-harness (heredoc form, on $PATH).
scripts/_glcap.js — live GL + worker capture hook. Inject via
Page.addScriptToEvaluateOnNewDocument; it wraps the WebGL prototype to capture
named uniforms, camera matrices, real draw counts, and the worker's input config.
This is pillars 1–3. Read it when you reach step 3.scripts/_scenecap.js — 4th-pillar capture: the live Three.js scene graph + material /
render state (primitive type, material flags, occluders, animated draw ranges). Inject
the same way. Read it when you reach step 3b.examples/<site>-<component>.md — worked runs: exact module IDs, captured
constants, recovery stories. Examples of the method, not the method itself — read the
closest one for a concrete template, and add a new file when you finish an extraction
(don't thread instance specifics back into this file). Current: examples/stripe-globe.md.Extract, don't infer. WebGL is the hardest branch only if you treat it as a black box. In practice the design is shipped as four recoverable things: downloadable assets (textures, masks, point data) + shader source (GLSL strings in the JS bundle)
Don't stop at shaders + uniforms + config — the 4th pillar is where "it looks off but the numbers all match" bugs live. A whole class of look is decided outside the GLSL, in the
new Mesh(...)/ material construction: the primitive type (PointsvsLinevsMeshvsSprite), material flags (transparent,depthTest,depthWrite,colorWrite,side,blending), helper objects (depth-only occluder spheres), and geometry-level animation (drawRange/visibleRange, quaternion orientation). On this globe, three separate "looks wrong" reports — markers that floated like flat stickers, arcs/pins showing through the back, and arcs that faded instead of retracting — were all 4th-pillar misses with byte-identical shaders/uniforms. When a captured config matches but it still looks wrong, read the mesh/material setup in the bundle, not the GLSL again. Grep the bundle fornew (Mesh|Points|Line\w*|Sprite)\(,depthTest|depthWrite|colorWrite|blending|side:,setDrawRange|drawRange|visibleRange, andsetFromUnitVectors|quaternion|lookAtnear the component class.
Corollary: verify perceptually, not by pixel-diff — particle systems are stochastic and time-driven, so there is no 0.0 frame to match.
Observation is the whole game here (the canvas is an opaque pixel buffer — unlike SVG, the DOM tells you nothing). And you can observe a WebGL component in motion — you just have to instrument the thread the GL context actually lives on. The classic mistake is instrumenting the page when the context is in a worker — or the reverse: assuming "worker = offscreen render" and bailing to source-reading when the thing renders on the main thread the whole time.
Field correction (Stripe
GradientNoiseGlobe). Earlier notes said this renders in an OffscreenCanvas worker → "read source, don't instrument." Wrong. The globe renders on the main thread; the only worker (dotGeneration.worker.js) is a pure compute worker that postsFloat32Arrays of point data back. A worker existing is not proof rendering happens in one. Main-threadgetContext/uniform*instrumentation works perfectly and is the primary tool — it handed us every real uniform + camera matrix and let us delete a wholeS=100eye-tuned scaling hack. Seescripts/_glcap.js.
Two cases, both observable:
getContext('2d') on the
target canvas does not throw, and a getContext hook injected via
addScriptToEvaluateOnNewDocument fires for that canvas's class. → Instrument the page
(next section). This is the default; don't skip it.getContext('2d') throws InvalidStateError, and
your page-side hook never fires for that canvas. The context is in a worker — so
instrument the worker, don't give up on observation:
Fetch.enable matching *.worker.js, then
Fetch.fulfillRequest with a body that prepends the same GL-prototype wrappers and
postMessages captures out. You already know the worker URL from the network list.Target.setAutoAttach({autoAttach:true, waitForDebuggerOnStart:true, flatten:true}) → the worker spawns paused →
Runtime.evaluate your wrappers → Runtime.runIfWaitingForDebugger → read state back.Source-reading is the fallback for when the GLSL/generator is unreadable (heavy obfuscation), not the default. And remember: instrumentation cracks observability (you get exact uniforms/buffers/camera), but a stochastic, time-driven system still has no canonical frame — verify perceptually regardless.
browser-harness <<'PY'
r = js(r"""(() => {
const c = document.querySelector('canvas'); // narrow to your target's canvas
return { canvas:!!c, cls:c&&c.className,
transferred: (()=>{ try{ c.getContext('2d'); return false }catch(e){ return e.name } })() }; // throws if offscreen
})()""")
print(r)
PY
<canvas> present → this skill. (No canvas → wrong skill.)transferred throws InvalidStateError → OffscreenCanvas worker render → instrument
the worker (rewrite the worker file / attach to the worker target — see headline gotcha).getContext hook installed (if the hook fires for that canvas's class,
it's main-thread for sure).Screenshot a few states (the animation is usually intermittent — sample over several seconds). Then list network resources and download the data assets — this is where the expensive design lives:
browser-harness <<'PY'
import json
print("\n".join(json.loads(js(
"JSON.stringify(performance.getEntriesByType('resource').map(e=>e.name)"
".filter(u=>/\\.(png|webp|json|bin|ktx2|basis|drc|glb)(\\?|$)/i.test(u)))"))))
PY
Inspect each: a 2:1 PNG is an equirectangular map; an alpha-only PNG is a mask
(Stripe's map_dots.png alpha = a land mask the shader scatters particles onto); a small
square is usually a palette/gradient. Decode with Pillow; the data is in the
channel that looks empty in RGB (check alpha).
Download all document.scripts, grep for the CSS-module class stem, the component name,
and any literal magic numbers from the assets/config:
grep -l "money-movement\|GradientNoiseGlobe\|dotGeneration" *.js
*.worker.js) is tiny and readable — it holds the point/geometry
generation algorithm verbatim (we recovered a Fibonacci-sphere + land-mask + Poisson
jitter dot generator in ~40 lines).NNNNN:function(e){e.exports="<GLSL>"}.
Extract them all and filter for void main:import re, codecs
s=open('chunk.js').read()
for m in re.finditer(r'(\d+):function\(\w\)\{\w\.exports="((?:[^"\\]|\\.)*)"\}', s):
body=codecs.decode(m.group(2),'unicode_escape')
if 'void main' in body: open(f'shader_{m.group(1)}.glsl','w').write(body)
a(49708), a(64875)…)
near the material creation — that block tells you which vertex+fragment pair each pass
uses. The class constructor holds the config: a long this.x=…,this.y=… run with
every uniform value (radius, gradient stops/colors, corona timing, rotation, etc.).er class —
arcSpawnIntervalMin/Max, arcPeakMinHeight, lineEnterAnimationDuration, Q/ee
color pairs; the pill logic getRandomUIData = random wallet, floor(999*rand)+1,
Phantom→CASH; and the wallet-icon SVGs in the ei array.)getComputedStyle the real element (here .money-movement-graphic__arc-ui:
4px radius, layered shadow, 12px/400 #061b31 amount + #50617a currency, 0.76 rest
scale) instead of guessing CSS. For behaviour, the overlay is usually driven by CSS
vars (--ui-x/--ui-y/--ui-scale/--ui-opacity) — sample them frame-by-frame with a
requestAnimationFrame loop into a window array, then read the time series back. That
reconstructs the real animation curve directly: here it exposed the pill state machine
(idle→intro 800ms easeOutCubic → travel 5000·clamp(distKm/1e4·.12+1,.6,1.2)ms
easeInOutCubic, riding 5%→95% → outro 600ms easeInCubic), which source-reading then
confirmed (K.uiIntro/uiTravel/uiOutro). Poll for the element — overlay pills are
transient and pooled.
Throttle gotcha: a backgrounded tab throttles
rAF/timers to ~1Hz and may pause the component onvisibilitychange. Before sampling,Target.activateTarget+Page.bringToFront(and/orEmulation.setFocusEmulationEnabled) or you get ~1Hz junk. Also clear stale per-element tag attributes between runs, or the sampler keys collide. Neither alone is enough; the globe needed all three. Grep distinctive tokens (USDC,simpleArc,Line2, wallet names) to land the chunk, read the constructor'sthis.*run +update*Controllermethods, andgetComputedStylethe live overlay nodes.
Gotcha: SVG assets reused per-instance (gradient
id/url(#…)) collide — uniquify ids per copy (Stripe appends a-UID). Rainbow/Crypto.com icons need this.
This is what turns eye-tuned guesses into captured numbers; do it, don't skip to source.
Inject a hook via Page.addScriptToEvaluateOnNewDocument (runs before page scripts) that,
keyed by canvas.className, wraps:
getContext → tag each context with its canvas class;shaderSource/attachShader/linkProgram → label each program by its GLSL
(gl_PointSize→dots, vFresnel→shell, vPlanePosition→atmo);getUniformLocation → remember loc.__name/program label, so values can be named
(must be pre-injected — Three caches locations at compile, so post-hoc you can't name them);uniform*/uniformMatrix* → store the latest value per label.name;drawArrays/drawElements → dedupe counts (this gives the real point count);Worker constructor + postMessage → capture the generator's input config.Reload, scroll the component into view (lazy init), let it run a few seconds, read
window.__cap. A working hook is scripts/_glcap.js.
Inject gotcha:
addScriptToEvaluateOnNewDocumentonly applies to the tab whose session you set it on. Open the tab first, set the script on that session, thenPage.reload— don't set it and thennew_tab(the new target won't have it).
Pillars 1-3 above are numbers/GLSL; they will all match and it can still look wrong,
because the look also lives in the scene graph: primitive type, material flags,
occluders, and extent animation. Capture it with scripts/_scenecap.js, injected
the same way (it predefines window.__THREE_DEVTOOLS__ so Three's constructors register
their renderer + scene with it). After mount:
js("JSON.stringify(window.__scenecap('money-movement'))") # filter renderers by canvas class
Read scenes[i].byType + scenes[i].notable first — they call out, at a glance:
sprites, occluders (colorWrite:false), depthTestOff layers, and animatedExtent
(finite drawRange or a range/segment-style uniform → the exit is likely a retract,
not a fade). On this globe one dump returned {Mesh:13, Line2:5, Points:1},
occluders:[Mesh] (the backgroundSphere at radius 1.986), depthTestOff:[Points,Mesh]
(dots exempt from occlusion), and animatedExtent: Line2 via u_visibleRange — i.e. every
4th-pillar fact that otherwise took a multi-turn back-and-forth to discover. traverse()
is exhaustive by construction: it lists every drawn object whether or not you knew to
grep for it. (Raw WebGL / no Three → nothing registers; fall back to per-draw GL-state in
scripts/_glcap.js.)
What it bought us on the globe — all of it previously eyeballed:
dotCount 60000, radius 2, poissonJitter 0.75, …);drawArrays(POINTS, …) count = 17,925 real dots;u_radius 2, u_dotSize 0.12 → killed the S=100 scaling hack entirely;projectionMatrix → fov 25°, near 0.1/far 1000; the modelViewMatrix
→ camera at (0,0,11.7), globe centered at (0,-1,0);flightDur 8.857, not the 1/travelSpeed=2.857 we'd derived).Read matrices, not just custom uniforms. Three.js sets
modelViewMatrix/projectionMatrixviauniformMatrix4fvwith those names — capture them and you recover the exact camera/framing, the part most likely to be eye-tuned.
Stripe's shaders are written for Three.js conventions — they use auto-injected
uniforms/attributes (modelMatrix, modelViewMatrix, projectionMatrix, position,
normal). So:
THREE.ShaderMaterial (GLSL1, i.e.
attribute/varying/gl_FragColor, is the default) and feed the extracted config as
uniforms. Shipping Three.js is fine for "deploy anywhere" — it's the lib the original
uses, and reimplementing a 3-pass renderer raw doesn't scale.uniform mat4 modelMatrix, modelViewMatrix, projectionMatrix; attribute vec3 position, normal;)
and supply them yourself.getImageData isn't tainted on file://.For a deterministic, time-driven shader, use the shared
../verify/skill —../verify/scripts/temporal_oracle.py(N frames → time-averaged + flicker fields) tells you whether a mismatch is a real defect or just animation phase, which a single screenshot cannot. The perceptual notes below are specifically for stochastic systems (particles/arcs) where there is no canonical frame and time-averaging is weak.
requestAnimationFrame to ~1
frame. Drive captures through a focused tab (new_tab, not Page.navigate) or your
screenshots will show a frozen first frame and you'll think the loop is broken.gl_PointSize is in
device px, so a dot's on-screen size is gl_PointSize / devicePixelRatio; if the
original and replica tabs sit on different-DPI monitors (or different browser zoom), the
same code renders chunky-vs-fine and you'll "diagnose" a difference that's pure capture
setup. Check devicePixelRatio on both tabs first; pin it (Emulation.setDeviceMetrics Override deviceScaleFactor) or normalize crops to a fixed px-per-CSS-px before judging.
Also: clip-based Page.captureScreenshot returns blank for a WebGL canvas with
preserveDrawingBuffer:false — use full-viewport capture + crop instead.dotOpacity 0, maxActive 1, freeze rotation, slow
the animation) and sample a dense frame burst — a single faint line is invisible against
17,925 dots, and a static screenshot can't show a draw-on/retract. A controlled probe
(static test pins front/limb/back) beats hoping a stochastic frame catches the case.uiTravelEndT 0.95) was real but only half of it; the position-update function
also offsets the pill radially off the line (+ r·lerp(.01,.25,d)). Read the actual
position/update function, not just the config object — and when a user reports a
qualitative behaviour, measure it (sample positions, compute the gap) to confirm and
quantify rather than taking (or dismissing) it on faith.mix-blend-mode, vignette pseudo-elements, an opacity layer).
On Vercel's hero the shader was byte-correct yet the composite was 2.3× too dark and
oversaturated — the bug was a CSS isolation boundary and a missing .gradient::before
vignette, above the canvas. Tell: raw shader pixels match the original but the composite
doesn't (isolate the canvas at opacity:1, hide siblings, compare — see ../verify/). When
that happens, stop re-reading GLSL and extract the CSS stack with the ../css-composite/
sibling. Capture the shader canvas's own opacity, mix-blend-mode, and the isolation
boundary it composites within — those are extracted values, not defaults.radius 2, cameraDistance 11.7, dotSize 0.12) because the point-size formula
(dotSize * canvasH/refH * k/-mvz) does the px conversion. The old advice was to scale
the whole world up by eye until dots looked right — skip that. Capture u_dotSize,
u_radius, and the camera matrices (step 3) and use the normalized values verbatim; the
dots come out right by construction. We deleted an S=100 hack this way.u_canvasHeight is CSS height, not backing-store height. Stripe feeds the CSS px
height (e.g. 728); if you feed renderer.domElement.height (DPR-scaled, 1456) every
dot is 2× too big on a Retina canvas. Captured value disambiguates this instantly.dotGeneration.worker.js only generates
point data and posts Float32Arrays back — the globe renders on the main thread.
Don't let a worker in the network list trick you into "it's offscreen, read source."map_dots.png
mask + the generator worker exist — grab both.THREE.Sprite (or any camera-facing quad) is mathematically incapable of looking like
it sits on a curved surface — it always rotates to face the camera, so the pin stays a
perfect circle from every angle and reads as a 2D sticker floating in front of the
globe. No texture/glow/size tuning fixes this; it's the wrong primitive. The tell, when a
user says a pin "looks flat / floating / not part of the surface," is exactly that perfect
roundness. Stripe's arc-endpoint markers are a flat PlaneGeometry(.4,.4) Mesh whose
normal is rotated onto the surface normal —
mesh.quaternion.setFromUnitVectors(vec3(0,0,1), normalize(point)) — so it foreshortens to
a thin ellipse near the limb, plus depthTest:true so the globe occludes the ones
past the horizon (or, if your shell doesn't write depth, fade by facing:
smooth01(normal·toCam, -0.35, -0.1)). The texture itself is a concentric "radar blip"
(createMarkerTexture): a 25%-alpha colored glow halo (full radius) + a full-color
ring (annulus) + a white core, mipmapped + linear-filtered, tinted per arc-endpoint
color. This is a pure extract-don't-infer miss: a stale code comment claimed "Stripe
uses sprites here" and it was trusted instead of read — the bundle plainly ships
new Mesh(markerGeometry, …) + setFromUnitVectors(normal). Go to source for the marker
primitive the same way you do for the shaders.depthWrite:false, so it
writes no depth → arcs/pins/labels on the far side draw straight through it and look
like they're floating in front. The fix is a separate invisible occluder: a
SphereGeometry(~0.99·r) Mesh with colorWrite:false, depthWrite:true and renderOrder:-1
(Stripe's backgroundSphere) that lays down depth on the near hemisphere so anything
farther fails the depth test. Then it's a per-layer choice via depthTest: arcs +
markers depthTest:true (hard-clip at the horizon), but the dots depthTest:false so
the back-of-globe dots stay faintly visible (their depth-fade shader dims them) — that
asymmetry ("hide arcs/pins behind the globe but not the dots") is exactly what a user
will ask for, and it's two material flags, not a shader change. Verify with three static
test pins (front / limb / back): front shows, back is hidden, dots behind it persist.visibleRange/setDrawRange(start, end-start) with start
ramping 0→1; captured lineRetreat 2200ms/800ms, easeInOutCubic). Same family as the
draw-on, which is itself drawRange(0, end) with end ramping. If a user says "ours just
fades out but theirs disappears differently," look for a draw-range/visible-range animation
in the update loop, not an opacity curve.mesh.material (and a _scenecap snapshot) sampled between frames catches whatever
was assigned last — often the velocity material (tell: uniforms previousModelView/Projection Matrix, frag writes gVelocity via layout(location=1), no matcap/lighting). To get the real
beauty material+uniforms, hook renderer.renderBufferDirect(cam, scene, geo, material, object, group) and read the material arg per object.uuid. (Beauty mats may also write gVelocity
in an MRT setup, so don't classify on that — classify on BlinnPhong/matcap/sphericalTexture.)onBeforeCompile: inject before #include <opaque_fragment>, not at
<dithering_fragment>. Edits to outgoingLight must happen before gl_FragColor is written
(in <opaque_fragment>, r154+; <output_fragment> older). The classic dead-end: your injected
code compiles and is present in the shader (confirm with a shaderSource hook), onBeforeCompile
runs N times, every #include target exists — yet the render is byte-identical to stock,
because the dithering hook is downstream of the pixel write. If a captured material is lit
(BlinnPhong/Standard) with a texture blended on top at low alpha, rebuild on the matching lit
material (MeshPhong/StandardMaterial) + the captured lights and inject the overlay — don't
collapse it to a MeshMatcapMaterial/unlit clone (the shared lighting is usually the layer that
makes many pieces read as one object).hoverEnabled:false
default read like "no interactivity" — but the canvas wired onMouseMove/Enter/Leave
unconditionally and the flag only gated a different shader's darken pass; the dots reacted
regardless. Don't trust a boolean's name: grep what it actually branches, and reproduce the
behavior live before declaring a feature off.DotHalftone components
(a demo page + the shipped one), different mouseRadius/ripple/decay constants — the first
shaderSource/text hit was the wrong one. Confirm you've extracted the module the live
component instantiates: match its render-site props (grid size, ripple values) to the
module's destructured defaults, not a same-named sibling or test harness.Concrete runs — exact module IDs, captured constants, recovery stories — live in
examples/, one file per component. They're examples of the method, not method; if
stripe.com vanished they'd be useless to the next target, which is why they're not inlined here.
examples/stripe-globe.md — Stripe GradientNoiseGlobe (the reference run; replica in
../stripe-money-movement/). All four pillars recovered: live-captured config/camera,
the scene graph (occluder, tangent-mesh markers, depthTest:false dots, Line2 retract).examples/vercel-triangle.md — Vercel homepage prism hero (WIP; replica in
../vercel-triangle/). Remote CDP runbook: ../vercel-triangle/BROWSER.md.When you finish a new extraction, add examples/<site>-<component>.md here instead of
threading its specifics back into this file.
npx claudepluginhub novarii/component-extractor --plugin component-extractorGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.