HTML in Canvas: Render Real DOM Inside <canvas>

Want more of these in your Google results?
Someone hands you a canvas and a product request: render a real-time 3D desk lamp, the kind every furniture and sneaker site ships now, that you orbit by dragging and click to select a part, with a label floating beside it and an inspector to recolor and configure it. The lamp earns the canvas, because no <div> or <svg> can render a shaded, orbitable product in real time. So you reach for the canvas, and the trap is the next move, drawing the labels and the inspector into it too, every pixel painted by hand.
It looks finished in a screenshot. Then it meets a real user. Tab does nothing, because there’s nothing to focus. You can’t select the label text or find-in-page it, because it’s a picture of text. The inspector can’t take a keyboard, because there’s no form. The accessibility panel shows one flat image. And you’re on the hook to rebuild hover, focus, active, and disabled by hand, for every control, on every state change.
Watch that trap run below. Drag to orbit the lamp, click a part with the mouse to select it, and recolor the shade from the painted swatches, all of which feels like it works. Now press Tab, and the focus counter stays at zero.
import { LitElement, html, css, nothing } from 'lit';
import { supportsHtmlInCanvas } from '@demos/supports-html-in-canvas';
import { customElement, property, state, query } from 'lit/decorators.js';
import * as THREE from 'three';
import { buildLamp, disposeLamp, setBaseFinish, setBulbState, setShadeColor, STUDIO_PALETTES } from './lamp';
import type { LampHandle } from './lamp';
import type {
BaseFinish,
ConfiguratorConfigChangeDetail,
ConfiguratorLayout,
ConfiguratorMode,
ConfiguratorSelectDetail,
ConfiguratorSelectSource,
ConfiguratorTheme,
LampConfig,
LampPartId,
ProductConfiguratorEventMap,
SceneObject,
ShadeSwatch,
} from './types';
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here.
* Writing the returned matrix into the element's `style.transform` is what keeps focus, hit testing, and accessibility geometry on the drawn pixels.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface OffscreenCanvasRenderingContext2D {
/**
* The offscreen mirror of {@link CanvasRenderingContext2D.drawElementImage}, declared for completeness so a worker path type-checks too.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface HTMLCanvasElement {
/**
* Opts the canvas in to the experimental model, so its direct children lay out and become drawable and hit-testable.
*
* Reflects the `layoutsubtree` HTML attribute, and is absent from `lib.dom.d.ts` because the feature is still flag-gated.
*/
layoutSubtree: boolean;
/**
* Forces a `paint` event on the next frame, the way `requestAnimationFrame` schedules a draw.
*/
requestPaint(): void;
}
}
/**
* The swatches the shade can be recolored to, a small neutral-to-bold range so the recolor reads in both themes.
*
* It is a module constant rather than a property because the swatch set is part of the lamp's design, not data a harness owns, and the first entry is the default the lamp opens on.
*/
const SHADE_SWATCHES: ReadonlyArray<ShadeSwatch> = [
{ id: 'porcelain', label: 'Porcelain', hex: '#e9e7e0' },
{ id: 'cobalt', label: 'Cobalt', hex: '#2257e6' },
{ id: 'moss', label: 'Moss', hex: '#3f7d5a' },
{ id: 'clay', label: 'Clay', hex: '#c2683f' },
];
/**
* The aspect ratio of the canvas, its height as a fraction of its width, so the stage reserves the right height before WebGL sizes itself.
*/
const CANVAS_ASPECT = 0.62;
/**
* The labelled parts the lamp draws, in the order the overlay stacks them, so the four ids the geometry and inspector know about have a single declared source.
*/
const PART_IDS: ReadonlyArray<LampPartId> = [ 'shade', 'arm', 'bulb', 'base' ];
/**
* A shared black color reused when a part's selection highlight is cleared, so the highlight reset allocates nothing per frame.
*/
const BLACK = new THREE.Color( 0x000000 );
/**
* A real-time 3D product configurator: a procedural desk lamp in a WebGL canvas, with a raycast selection, an orbit drag, and an inspector, that moves its UI between three ownership models.
*
* The lamp is graphics the DOM cannot draw, owned by the canvas, and the labels and inspector are interface, owned by the DOM, so `mode` decides which layer carries them.
* `canvas-only` paints the labels and a faux inspector onto a flat overlay canvas with no focusable DOM, the trap; `dom-overlay` is the version that ships, projecting each part's 3D anchor to a real HTML label kept glued as the lamp orbits, with the real inspector beside it; `html-in-canvas` feature-detects the experimental `drawElementImage` path, draws the real label nodes into the flat overlay canvas, and writes the returned alignment transform back, falling back to `dom-overlay` when the API is absent.
* Selection is a raycast from the camera, the 3D replacement for the old 2D hit test, and the inspector recolors the shade, switches the base finish, and toggles the bulb, which relights the scene.
*
* @element product-configurator
* @slot header - Sits above the stage in the left column, for a harness caption over the canvas.
* @slot panel - Stacks under the real inspector in the right column of the `split` layout, for a harness control panel beside the canvas.
* @slot footer - Spans both columns under the stage in the `split` layout, for a tall report or log that would otherwise strand the canvas column if stacked in the narrow side column.
* @fires object-select - Fired when the selected part changes, carrying a `ConfiguratorSelectDetail`.
* @fires config-change - Fired after an inspector edit, carrying the whole `LampConfig`.
* @cssprop [--configurator-accent=#2257e6] - The selection and focus color used by the labels and the inspector.
*/
@customElement( 'product-configurator' )
export class ProductConfigurator extends LitElement {
/**
* The selectable parts, the four named meshes of the lamp.
*
* It is `attribute: false` because an array binds as a property (`.objects=${parts}`) and never round-trips through a string attribute.
* It has no default, since inventing the parts inside the component would hide the contract that the parent names the scene; the component builds a mesh for each known id and skips any other.
*/
@property( { attribute: false } )
objects: SceneObject[] = [];
/**
* Which ownership model the configurator renders.
*
* `html-in-canvas` is a request, not a guarantee: when the experimental API is absent the component renders `dom-overlay` instead, so the selected mode and the active mode can differ.
*/
@property( { type: String } )
mode: ConfiguratorMode = 'dom-overlay';
/**
* How the canvas and the inspector are arranged.
*
* Defaults to `stacked` so a standalone embed keeps the inspector under the canvas; a harness sets `split` to get the glass-demo shape, the canvas in a left column and the inspector plus a `panel` slot in one right column, collapsing back to a single stacked column on a narrow width.
*/
@property( { type: String } )
layout: ConfiguratorLayout = 'stacked';
/**
* Whether to deliberately skip writing the alignment transform back to the drawn label.
*
* Teaching switch for the experimental path: with the sync omitted, the focus ring and click target drift off the drawn pixels, which is the failure the article makes visible.
* It does nothing outside `html-in-canvas` mode on a supporting build.
*/
@property( { type: Boolean, attribute: 'omit-transform-sync' } )
omitTransformSync = false;
/**
* The id of the currently selected part, or `null` when nothing is selected.
*
* It is a controllable property: leave it unset and the component manages selection itself from a raycast or a keyboard pick, or bind it (`.selectedId=${id}`) to drive several configurators from one shared selection, the way the review demo syncs three modes.
* Either way consumers also hear changes through the `object-select` event, and the component writes the property on every pick so the uncontrolled case stays in sync.
*/
@property( { attribute: 'selected-id' } )
selectedId: string | null = null;
/**
* The viewport canvas Three.js renders the lamp into, resolved by Lit after the first render.
*/
@query( '.viewport' )
private $viewport!: HTMLCanvasElement;
/**
* The flat 2D overlay canvas stacked over the WebGL canvas, used to paint the faux UI in `canvas-only` and to host the drawn labels in `html-in-canvas`.
*/
@query( '.overlay-canvas' )
private $overlayCanvas?: HTMLCanvasElement;
/**
* The selected page theme, resolved on connect and kept in sync at runtime, so the studio is lit to match the page in light and dark.
*
* It is `@state` so a runtime theme flip re-renders, and the scene recolors through {@link applyTheme} rather than a rebuild.
*/
@state()
private theme: ConfiguratorTheme = 'light';
/**
* The lamp's configurable state, the shade swatch, the base finish, and the bulb, edited by the inspector.
*
* It is `@state` so an edit re-renders the inspector and re-runs the overlay draw, and it opens on the first swatch with a matte base and a lit bulb so the lamp reads as switched on.
*/
@state()
private config: LampConfig = {
shadeSwatchId: SHADE_SWATCHES[ 0 ].id,
baseFinish: 'matte',
bulbOn: true,
};
/**
* Whether the live 2D context exposes the experimental `drawElementImage` method.
*
* Resolved on connect from a scratch canvas, so it gates the experimental path per render rather than trusting the requested `mode`.
* It is `@state` so flipping it triggers the re-render that mounts the drawn label nodes, since `activeMode` reads it during `render`.
*/
@state()
private supportsHtmlInCanvas = false;
/**
* The Three.js scene, camera, and renderer, created once the viewport exists.
*/
private scene: THREE.Scene | null = null;
private camera: THREE.PerspectiveCamera | null = null;
private renderer: THREE.WebGLRenderer | null = null;
/**
* The built lamp and the handles to its parts, materials, and light, or `null` before the scene is built.
*/
private lamp: LampHandle | null = null;
/**
* The studio floor mesh, kept so its geometry and material can be disposed on teardown.
*/
private floorMesh: THREE.Mesh | null = null;
/**
* The studio floor material, kept so a theme change can recolor it in place.
*/
private floorMaterial: THREE.MeshStandardMaterial | null = null;
/**
* The themed studio lights, kept so a theme change can dim or lift them to match the palette.
*/
private themedLights: {
ambient: THREE.AmbientLight | null;
key: THREE.DirectionalLight | null;
rim: THREE.DirectionalLight | null;
} = { ambient: null, key: null, rim: null };
/**
* The reusable raycaster for the part-selection pick, allocated once.
*/
private readonly raycaster = new THREE.Raycaster();
/**
* A reusable vector for projecting a part anchor, so the overlay sync allocates nothing per frame.
*/
private readonly projected = new THREE.Vector3();
/**
* The overlay canvas 2D context, cached after the first paint so the per-frame draw does not re-acquire it.
*/
private overlayCtx: CanvasRenderingContext2D | null = null;
/**
* The CSS-pixel size the overlay backing store was last sized for, so it is only resized when the box actually changes rather than every frame.
*/
private overlaySize = { width: 0, height: 0, dpr: 0 };
/**
* The resolved selection accent as a Three.js color, cached so the per-frame highlight does not call `getComputedStyle`, and refreshed on a theme change.
*/
private readonly accent = new THREE.Color( 0x2257e6 );
/**
* The themed colors the flat overlay paints with, cached and refreshed on a theme change so the canvas-only and drawn-label paints do not read the DOM every frame.
*
* The canvas cannot inherit a CSS color, so these mirror the tokens the rest of the page uses, resolved once per theme rather than once per frame.
*/
private overlayColors = {
ink: '#17171a',
surface: '#ffffff',
accent: '#2257e6',
accentInk: '#ffffff',
fontSans: 'system-ui, sans-serif',
fontMono: 'monospace',
};
/**
* The current screen position of each part's overlay label, in CSS pixels, recomputed on every rendered frame while the lamp is on screen.
*
* It is the bridge from the 3D scene to the 2D layers: the DOM-overlay labels read it for their `left` and `top`, and the canvas-only and drawn-label paints read it to place text, so all three layers stay glued to the orbiting model from one projection.
*/
@state()
private anchorPoints: Record<string, { x: number; y: number; visible: boolean }> = {};
/**
* The orbit yaw and pitch in radians, driven by the drag and applied to the lamp group each frame.
*
* The lamp rotates rather than the camera, so the studio lighting stays fixed and the part the reader turns toward stays lit; the pitch is bounded so the lamp never tips past a readable angle.
*/
private orbit = { yaw: 0.5, pitch: 0.05 };
/**
* Whether a drag-to-orbit gesture is in progress, and the last pointer position it was sampled at.
*/
private isDragging = false;
/**
* The id of the pointer that started the current gesture, so a second finger or pen mid-drag cannot steer, end, or cancel a gesture it does not own.
*
* `isPrimary` is not enough as a filter, since touch and pen each get their own primary pointer; the id is the one key that names this exact contact.
*/
private activePointerId: number | null = null;
private lastPointer = { x: 0, y: 0 };
/**
* Whether the pointer moved past a small threshold during the current gesture, so a press-and-release in place still counts as a select rather than being eaten as a drag.
*/
private dragMoved = false;
/**
* Whether the viewport is currently on screen, so the render loop only runs while the demo is visible.
*/
private isVisible = true;
/**
* Whether {@link setUp} has wired the scene, listeners, and observers, so its two entry points, the first update and a reconnect, can never double-wire them.
*/
private isSetUp = false;
/**
* The media query that fires when `devicePixelRatio` changes, so the renderer and the overlay can be re-corrected on a zoom or a move to a different-density display.
*
* It is recreated on each change because the query is pinned to a single resolution, which is the lifecycle the canvas makes the author own.
*/
private dprQuery: MediaQueryList | null = null;
/**
* The media query the studio watches so it follows the OS color scheme when the page has not pinned a theme, retained so its listener tears down on disconnect.
*/
private themeMedia: MediaQueryList | null = null;
/**
* The observer watching `data-theme` on the document element, so an in-page theme toggle re-lights the studio at runtime, retained so it disconnects cleanly.
*/
private themeObserver: MutationObserver | null = null;
/**
* The resize and intersection observers, tracked so they tear down cleanly on disconnect.
*/
private resizeObserver: ResizeObserver | null = null;
private intersectionObserver: IntersectionObserver | null = null;
/**
* The animation-frame handle, cleared on disconnect or when the demo scrolls offscreen so a hidden lamp stops its loop.
*/
private frame = 0;
/**
* Whether the next animation frame has work to do.
*
* Nothing in the scene self-animates, so the loop keeps its frame cadence but skips the render and the overlay sync until an orbit, a selection, a config edit, a theme flip, or a resize marks the scene dirty through {@link requestRender}; label-only rendering changes in the experimental mode, a focus ring appearing on a drawn label, arrive through the canvas `paint` event and never need the flag.
*/
private needsRender = false;
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
:host {
display: block;
/* The host is a size container, so the split layout collapses on the embed's own width rather than the viewport's. */
container-type: inline-size;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
/* Resolve the selection accent into a private name; reading --configurator-accent first, then the themed --accent, then the brand, avoids the self-reference cycle that redefining --accent here would cause when a host sets --configurator-accent to the page accent. */
--cfg-accent: var( --configurator-accent, var( --accent, #2257e6 ) );
}
/* The stacked default keeps the canvas and inspector in one column; the split layout puts the canvas on the left and the inspector plus the harness panel in one right column, the glass-demo shape. */
.layout {
display: grid;
gap: var( --space-4, 16px );
}
.layout.split {
grid-template-columns: minmax( 0, 1fr ) 17rem;
grid-template-areas:
'main side'
'footer footer';
align-items: start;
}
.layout.split .main {
grid-area: main;
}
.layout.split .side {
grid-area: side;
}
/* The tall trailing content spans both columns under the stage, so a long report no longer strands the canvas column. */
.layout.split .footer {
grid-area: footer;
}
/* The left column: anything slotted into the header sits above the stage, and the stage carries the canvas. */
.main {
display: flex;
flex-direction: column;
gap: var( --space-3, 12px );
min-width: 0;
}
/* The header slot collapses to nothing when the harness slots no caption, so a bare embed shows just the stage. */
slot[name='header']::slotted( * ) {
margin: 0;
}
/* The right column of the split layout: the real inspector stacked above whatever the harness slots in, so every control reads as one column beside the canvas. */
.side {
display: flex;
flex-direction: column;
gap: var( --space-4, 16px );
min-width: 0;
}
.layout.split .inspector {
margin-top: 0;
}
/* Collapse to a single stacked column when the embed itself is too narrow for a comfortable two-column split. */
@container ( width <= 38rem ) {
.layout.split {
grid-template-columns: minmax( 0, 1fr );
grid-template-areas:
'main'
'side'
'footer';
}
}
.stage {
position: relative;
width: 100%;
aspect-ratio: 100 / 62;
border-radius: var( --radius-md, 11px );
border: 1px solid var( --line, #e7e6e2 );
overflow: hidden;
background: var( --surface, #fff );
}
canvas.viewport {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.stage.dragging canvas.viewport {
cursor: grabbing;
}
/* The flat overlay canvas sits exactly over the WebGL canvas in CSS-pixel space, the painted faux UI in canvas-only and the drawn-label host in html-in-canvas. */
canvas.overlay-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* The DOM-overlay layer sits over the canvas in CSS-pixel space, the bookkeeping the experimental API folds into the platform. */
.overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.overlay .label {
position: absolute;
transform: translate( -50%, -50% );
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
.overlay .label:hover {
border-color: var( --cfg-accent );
}
.overlay .label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
.overlay .label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
/*
* The drawn labels are real children of the overlay canvas in the experimental path: laid out and hit-testable, never display:none, anchored at the canvas origin with style.transform reserved for the platform alignment matrix that places and keeps them on the drawn pixels.
* Their look is kept identical to the DOM-overlay label above, the small light pill with ink text and a hairline border, so the only difference a reader can tell between the two modes is the mechanism, not the size or color of the pill.
*/
canvas.overlay-canvas .drawn-label {
position: absolute;
top: 0;
left: 0;
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
canvas.overlay-canvas .drawn-label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
canvas.overlay-canvas .drawn-label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.inspector {
margin-top: var( --space-3, 12px );
padding: var( --space-4, 16px );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-md, 11px );
background: var( --surface, #fff );
}
.inspector .head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var( --space-3, 12px );
margin-bottom: var( --space-3, 12px );
}
.inspector h3 {
margin: 0;
font-size: 14px;
color: var( --ink, #17171a );
}
.inspector .part-tag {
font-family: var( --font-mono, monospace );
font-size: 11px;
color: var( --ink-faint, #71717b );
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var( --space-3, 12px );
}
.field:last-child {
margin-bottom: 0;
}
.field > .field-label {
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var( --ink-faint, #71717b );
}
.swatches {
display: flex;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
border: 2px solid var( --line, #e7e6e2 );
cursor: pointer;
}
.swatch[aria-pressed='true'] {
border-color: var( --cfg-accent );
box-shadow: 0 0 0 2px var( --surface, #fff ), 0 0 0 4px var( --cfg-accent );
}
.segmented {
display: inline-flex;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-sm, 7px );
overflow: hidden;
width: max-content;
}
.segmented button {
padding: 7px 14px;
font: inherit;
font-size: 13px;
border: 0;
background: var( --bg, #fcfcfb );
color: var( --ink, #17171a );
cursor: pointer;
}
.segmented button + button {
border-left: 1px solid var( --line, #e7e6e2 );
}
.segmented button[aria-pressed='true'] {
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 13px;
color: var( --ink, #17171a );
cursor: pointer;
}
.switch .track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: var( --line, #e7e6e2 );
transition: background 0.2s ease;
flex: none;
}
.switch .track.on {
background: var( --cfg-accent );
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s ease;
}
.switch .track.on .knob {
transform: translateX( 16px );
}
`;
/**
* Resolves the page theme and the experimental-API support before the first render, so the studio opens in the page's palette and the first render already knows the active mode.
*
* On a reconnect it also re-runs {@link setUp}: the shadow DOM survives a re-parenting, so `hasUpdated` stays true, `firstUpdated` never fires again, and the wiring {@link tearDown} removed has to come back here, with the `@query` fields still resolving into the kept shadow root.
*/
connectedCallback(): void {
super.connectedCallback();
this.theme = this.detectTheme();
this.resolveSupport();
if ( this.hasUpdated ) {
this.setUp();
}
}
/**
* Runs the shared {@link setUp} once the viewport canvas exists after the first render.
*/
protected firstUpdated(): void {
this.setUp();
}
/**
* Builds the scene, wires the controls, observers, and theme listeners, and kicks the loop.
*
* It is shared by `firstUpdated` and a reconnect, because `firstUpdated` only ever fires once: without `connectedCallback` re-running this, a configurator moved to a new parent would come back with everything {@link tearDown} released still gone.
* The `isSetUp` guard keeps the two entry points from wiring the scene twice.
* It also refuses to wire a detached element: an append and remove in the same task still runs the pending first update after disconnection, and window listeners wired there would never hear the disconnect that already happened.
*/
private setUp(): void {
if ( this.isSetUp || ! this.isConnected ) {
return;
}
this.isSetUp = true;
this.refreshAccent();
this.initScene();
this.applySelectionHighlight();
this.bindEvents();
this.bindThemeListeners();
this.observeDevicePixelRatio();
this.startLoop();
}
/**
* Re-runs the overlay paint whenever a property that changes the flat layers updates, so a mode switch, a selection, or a config edit repaints the canvas-only UI or the drawn labels.
*
* A selection, mode, or objects change also marks the scene dirty through {@link requestRender}, so the gated loop draws the change on its next frame.
* The 3D scene redraws itself in the loop, so this only touches the flat overlay, and only when the loop is paused does it need to force one; the guarded call keeps a visible, animating demo from double-painting.
* The `anchorPoints` guard is what keeps a paused offscreen demo from looping: {@link syncOverlay} writes the `anchorPoints` `@state`, which schedules another update, so re-running `syncOverlay` on that follow-up would project, write `anchorPoints` again, and never terminate; skipping the call when the only change is `anchorPoints` lets a real change like a theme, selection, mode, or config edit sync the overlay exactly once and then settle.
*/
protected updated( changed: Map<PropertyKey, unknown> ): void {
if ( changed.has( 'selectedId' ) ) {
this.applySelectionHighlight();
this.requestRender();
}
if ( changed.has( 'mode' ) || changed.has( 'objects' ) ) {
this.requestRender();
}
if ( this.frame === 0 && ! changed.has( 'anchorPoints' ) ) {
this.syncOverlay();
}
}
/**
* Tears the configurator down through the shared {@link tearDown} when it leaves the document.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.tearDown();
}
/**
* Stops the loop and tears down every observer, listener, and the WebGL context so a removed configurator leaks nothing.
*
* It is the mirror of {@link setUp} rather than the body of `disconnectedCallback`, so a reconnected configurator can rebuild exactly what this released; clearing `isSetUp` is what re-arms that rebuild.
*/
private tearDown(): void {
this.isSetUp = false;
this.stopLoop();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
this.dprQuery = null;
this.themeMedia?.removeEventListener( 'change', this.onThemeMediaChange );
this.themeMedia = null;
this.themeObserver?.disconnect();
this.themeObserver = null;
this.$viewport?.removeEventListener( 'pointerdown', this.onPointerDown );
window.removeEventListener( 'pointermove', this.onPointerMove );
window.removeEventListener( 'pointerup', this.onPointerUp );
window.removeEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.removeEventListener( 'paint', this.onOverlayPaint );
if ( this.lamp ) {
disposeLamp( this.lamp );
this.lamp = null;
}
this.floorMesh?.geometry.dispose();
this.floorMesh = null;
this.floorMaterial?.dispose();
this.floorMaterial = null;
this.renderer?.dispose();
this.renderer = null;
// The window listeners that would release a live gesture are gone now, so the flags reset here; a stale drag would otherwise let plain hover motion orbit the lamp after a reconnect, since a mouse keeps a stable pointerId.
this.isDragging = false;
this.dragMoved = false;
this.activePointerId = null;
}
/**
* The mode actually rendered, which is the requested mode unless `html-in-canvas` was asked for without support.
*
* This is the single place the requested-versus-active split is resolved, so the template and the overlay paint agree on which layer owns the labels.
*/
private get activeMode(): ConfiguratorMode {
if ( this.mode === 'html-in-canvas' && ! this.supportsHtmlInCanvas ) {
return 'dom-overlay';
}
return this.mode;
}
/**
* The studio palette for the current theme, the one object the scene materials and lights read.
*/
private get palette() {
return STUDIO_PALETTES[ this.theme ];
}
/**
* The four selectable parts that map onto a real lamp mesh, in the lamp's stacking order.
*
* The component only knows the four lamp ids, so it filters the bound scene to them and keeps them in a stable order, which keeps the overlay labels and the raycast list aligned with the geometry the helper built.
*/
private get parts(): SceneObject[] {
const byId = new Map( this.objects.map( ( object ) => [ object.id, object ] ) );
const ordered: SceneObject[] = [];
for ( const id of PART_IDS ) {
const object = byId.get( id );
if ( object ) {
ordered.push( object );
}
}
return ordered;
}
/**
* The selected part, or `undefined` when nothing is selected or the selection is not a known lamp part.
*/
private get selectedPart(): SceneObject | undefined {
return this.parts.find( ( part ) => part.id === this.selectedId );
}
/**
* Records whether the drawn-label path can run, through the shared {@link supportsHtmlInCanvas} predicate that feature-detects the full path, `drawElementImage` on a live 2D context and `requestPaint` on the canvas, rather than sniffing a UA string.
*
* It runs from `connectedCallback`, since the probe needs only `document`, so the first render already knows the answer instead of flipping the mode an update cycle later.
*/
private resolveSupport(): void {
this.supportsHtmlInCanvas = supportsHtmlInCanvas();
}
/**
* Resolves the page theme the same way the demo page decides it: an explicit `data-theme` on the document element wins, and the OS color scheme is the fallback when nothing is pinned.
*
* @returns The theme the studio should light for right now.
*/
private detectTheme(): ConfiguratorTheme {
const pinned = document.documentElement.dataset.theme;
if ( pinned === 'dark' || pinned === 'light' ) {
return pinned;
}
return window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light';
}
/**
* The current size of the viewport canvas in CSS pixels, read from the element so it is right in both standalone and embedded contexts.
*/
private viewportSize(): { width: number; height: number } {
return {
width: this.$viewport.clientWidth || 600,
height: this.$viewport.clientHeight || Math.round( 600 * CANVAS_ASPECT ),
};
}
/**
* Creates the scene, camera, renderer, the studio floor and lighting, and the lamp, then frames the camera on the lamp.
*/
private initScene(): void {
const palette = this.palette;
const scene = new THREE.Scene();
scene.background = new THREE.Color( palette.background );
this.scene = scene;
const size = this.viewportSize();
const camera = new THREE.PerspectiveCamera( 38, size.width / size.height, 0.1, 100 );
camera.position.set( 0, 0.55, 5.6 );
camera.lookAt( 0, 0.26, 0 );
this.camera = camera;
const renderer = new THREE.WebGLRenderer( { canvas: this.$viewport, antialias: true, alpha: true } );
renderer.setSize( size.width, size.height, false );
renderer.setPixelRatio( Math.min( window.devicePixelRatio || 1, 2 ) );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
this.renderer = renderer;
this.buildStudio( scene );
this.buildLampInto( scene );
}
/**
* Builds the studio floor and the three-point lighting from the current palette, keeping each so a theme change can recolor or re-level it.
*
* @param scene - The scene to add the studio to.
*/
private buildStudio( scene: THREE.Scene ): void {
const palette = this.palette;
const floorMaterial = new THREE.MeshStandardMaterial( { color: palette.floor, roughness: 0.95, metalness: 0.05 } );
const floor = new THREE.Mesh( new THREE.CircleGeometry( 6, 64 ), floorMaterial );
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.95;
scene.add( floor );
this.floorMesh = floor;
this.floorMaterial = floorMaterial;
const ambient = new THREE.AmbientLight( 0xffffff, palette.ambient );
scene.add( ambient );
this.themedLights.ambient = ambient;
const key = new THREE.DirectionalLight( 0xffffff, palette.key );
key.position.set( 2.6, 4, 3.2 );
scene.add( key );
this.themedLights.key = key;
const rim = new THREE.DirectionalLight( 0xbcd0ff, palette.rim );
rim.position.set( -3, 2.2, -2.6 );
scene.add( rim );
this.themedLights.rim = rim;
}
/**
* Builds the lamp from the helper in the lamp's current config and adds it to the scene, applying the current orbit so it opens at a readable three-quarter angle.
*
* @param scene - The scene to add the lamp to.
*/
private buildLampInto( scene: THREE.Scene ): void {
const lamp = buildLamp( this.palette, this.shadeHex(), this.config.baseFinish, this.config.bulbOn );
lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
scene.add( lamp.group );
this.lamp = lamp;
}
/**
* The hex color of the currently selected shade swatch, falling back to the first swatch when the stored id is unknown.
*/
private shadeHex(): string {
const swatch = SHADE_SWATCHES.find( ( entry ) => entry.id === this.config.shadeSwatchId ) ?? SHADE_SWATCHES[ 0 ];
return swatch.hex;
}
/**
* Wires the orbit pointer listeners, the overlay's `paint` listener, the resize observer that keeps the renderer matched to the embedded stage, and the intersection observer that pauses the loop offscreen.
*
* The orbit starts on a `pointerdown` on the viewport and tracks on the window so a drag that leaves the canvas keeps rotating, and a release without a meaningful move is treated as a select on the same press.
* The `paint` listener registers once, because the overlay canvas is rendered in every mode and so survives a mode switch; on a build without the experimental API the event never fires, which leaves the handler inert.
*/
private bindEvents(): void {
this.$viewport.addEventListener( 'pointerdown', this.onPointerDown );
window.addEventListener( 'pointermove', this.onPointerMove );
window.addEventListener( 'pointerup', this.onPointerUp );
window.addEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.addEventListener( 'paint', this.onOverlayPaint );
this.resizeObserver = new ResizeObserver( () => this.onResize() );
this.resizeObserver.observe( this.$viewport );
this.intersectionObserver = new IntersectionObserver(
( entries ) => this.onIntersection( entries ),
{ threshold: 0.01 },
);
this.intersectionObserver.observe( this.$viewport );
}
/**
* Wires the two ways the page theme can change at runtime: the OS color scheme and an in-page `data-theme` toggle, both funnelled through {@link refreshTheme}.
*/
private bindThemeListeners(): void {
this.themeMedia = window.matchMedia( '(prefers-color-scheme: dark)' );
this.themeMedia.addEventListener( 'change', this.onThemeMediaChange );
this.themeObserver = new MutationObserver( () => this.refreshTheme() );
this.themeObserver.observe( document.documentElement, { attributes: true, attributeFilter: [ 'data-theme' ] } );
}
/**
* Re-resolves the OS color scheme into the studio when the system theme flips.
*/
private onThemeMediaChange = (): void => {
this.refreshTheme();
};
/**
* Re-resolves the theme and, when it actually changed, recolors the studio and the shared metal so the lamp follows the page.
*/
private refreshTheme(): void {
const next = this.detectTheme();
if ( next === this.theme ) {
return;
}
this.theme = next;
this.applyTheme();
}
/**
* Pushes the current palette onto the live scene: the background, the floor, the lights, and the shared metal tone of the arm and base, so a runtime theme change lands without rebuilding the scene.
*/
private applyTheme(): void {
const palette = this.palette;
if ( this.scene ) {
this.scene.background = new THREE.Color( palette.background );
}
this.floorMaterial?.color.setHex( palette.floor );
if ( this.themedLights.ambient ) {
this.themedLights.ambient.intensity = palette.ambient;
}
if ( this.themedLights.key ) {
this.themedLights.key.intensity = palette.key;
}
if ( this.themedLights.rim ) {
this.themedLights.rim.intensity = palette.rim;
}
if ( this.lamp ) {
this.lamp.baseMaterial.color.setHex( palette.metal );
this.lamp.armMaterial.color.setHex( palette.metal );
}
this.refreshAccent();
this.applySelectionHighlight();
this.requestRender();
}
/**
* Sizes the WebGL backing store and the overlay backing store for the current device pixel ratio, and rebuilds the one-shot listener for the next change.
*
* `matchMedia( '(resolution: Ndppx)' )` only watches the current ratio, so a move to a different-density display needs the query rebuilt each time; the renderer caps the ratio at two so a very dense display does not balloon the backing store.
*/
private observeDevicePixelRatio(): void {
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
const dpr = window.devicePixelRatio || 1;
this.dprQuery = window.matchMedia( `(resolution: ${ dpr }dppx)` );
this.dprQuery.addEventListener( 'change', this.onDevicePixelRatioChange, { once: true } );
this.renderer?.setPixelRatio( Math.min( dpr, 2 ) );
}
/**
* Re-corrects the backing stores after the device pixel ratio changes, then re-arms the watcher and repaints.
*/
private onDevicePixelRatioChange = (): void => {
this.observeDevicePixelRatio();
this.onResize();
};
/**
* Resizes the renderer and updates the camera aspect when the viewport's CSS box changes, then repaints the overlay.
*/
private onResize(): void {
if ( ! this.camera || ! this.renderer ) {
return;
}
const size = this.viewportSize();
this.camera.aspect = size.width / size.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize( size.width, size.height, false );
this.requestRender();
this.syncOverlay();
}
/**
* Pauses or resumes the render loop as the demo scrolls in and out of view, so an offscreen lamp costs no frames.
*
* @param entries - The intersection records for the observed viewport.
*/
private onIntersection( entries: IntersectionObserverEntry[] ): void {
for ( const entry of entries ) {
this.isVisible = entry.isIntersecting;
if ( this.isVisible ) {
this.startLoop();
} else {
this.stopLoop();
}
}
}
/**
* Begins a drag-to-orbit gesture, recording the start pointer so motion is measured as a delta and a press in place can still resolve to a select.
*
* Only the primary pointer's primary button starts a gesture: a right or middle click falls through to the browser, since a context menu can swallow the matching release and leave a phantom drag armed, and a gesture already in progress owns the viewport, so a new contact of any type is ignored until the owning pointer releases, because `isPrimary` is per pointer type and a first touch mid-mouse-drag would otherwise pass the guard and steal the gesture.
*
* @param event - The pointerdown on the viewport.
*/
private onPointerDown = ( event: PointerEvent ): void => {
if ( this.activePointerId !== null || ! event.isPrimary || event.button !== 0 ) {
return;
}
this.isDragging = true;
this.dragMoved = false;
this.activePointerId = event.pointerId;
this.lastPointer = { x: event.clientX, y: event.clientY };
};
/**
* Applies a drag delta to the orbit yaw and pitch while a gesture is active, bounding the pitch so the lamp stays framed.
*
* Once the pointer has moved past a few pixels the gesture is marked as a drag, so the matching release orbits rather than selecting.
*
* @param event - The pointermove on the window.
*/
private onPointerMove = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
const dx = event.clientX - this.lastPointer.x;
const dy = event.clientY - this.lastPointer.y;
this.lastPointer = { x: event.clientX, y: event.clientY };
if ( Math.abs( dx ) + Math.abs( dy ) > 3 ) {
this.dragMoved = true;
}
this.orbit.yaw += dx * 0.01;
this.orbit.pitch += dy * 0.01;
this.orbit.pitch = Math.max( -0.5, Math.min( 0.6, this.orbit.pitch ) );
this.requestRender();
};
/**
* Ends the gesture, and when the pointer never moved past the drag threshold, treats the release as a raycast select on the part under it.
*
* @param event - The pointerup on the window.
*/
private onPointerUp = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
if ( ! this.dragMoved ) {
this.pickAt( event );
}
};
/**
* Abandons the gesture when the browser cancels the owning pointer, a touch scroll takeover or a system sheet, without treating the interruption as a select.
*
* Only the gesture's own pointer can cancel it, so an unrelated contact being cancelled leaves a live drag alone.
*/
private onPointerCancel = ( event: PointerEvent ): void => {
if ( event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
};
/**
* Raycasts from the camera through a pointer position and selects the lamp part it hits, or clears the selection on a miss.
*
* This is the article's 3D hit-testing lesson: there is still no built-in canvas hit test, so the pointer is converted into normalized device coordinates, `raycaster.setFromCamera` builds the ray, and `intersectObjects` against the whole lamp group resolves the nearest hit to a part id, the raycast that replaces the old `isPointInPath`.
* Every visible mesh carries the id of the part it belongs to, so a click on a cosmetic piece like the shade cap or the neck still selects; a hit whose chain carries no id, the additive glow halo, falls through to the next hit rather than eating the click, and the selection only clears when nothing in the list resolves.
* In `canvas-only` the painted UI sits on top of the scene, so its pills, swatches, and panel are hit-tested first, more controls whose geometry has to be re-derived by hand because no DOM exists to do it.
*
* @param event - The pointer event whose position to pick at.
*/
private pickAt( event: PointerEvent ): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
if ( this.activeMode === 'canvas-only' && this.pickFauxUiAt( event ) ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const ndc = new THREE.Vector2(
( ( event.clientX - rect.left ) / rect.width ) * 2 - 1,
-( ( event.clientY - rect.top ) / rect.height ) * 2 + 1,
);
this.raycaster.setFromCamera( ndc, this.camera );
const hits = this.raycaster.intersectObjects( this.lamp.group.children, true );
for ( const hit of hits ) {
const id = this.partIdForObject( hit.object );
if ( id ) {
this.select( id, 'canvas' );
return;
}
}
this.select( null, 'canvas' );
}
/**
* Hit-tests the painted faux UI by hand, the part label pills first, then the inspector's swatch row, then the panel body, and applies whatever the matching real control would do.
*
* This mirrors the exact geometry {@link paintPill} and {@link paintFauxInspector} paint (the pill boxes around the visible anchors, the swatch centers and radius, the {@link fauxPanelRect} box), because painted pixels carry no hit regions of their own.
* That duplication is the point the trap teaches: the mouse path can be made to feel complete, but every painted control costs a hand-derived hit region like this, the demo now pays that cost for all of them, and focus, the accessibility tree, and text selection still get nothing.
* A hit on the panel body that lands on no control still returns `true`, so a click on the painted inspector never raycasts the scene through it.
* The swatch hit radius is a couple of pixels wider than the painted circle so the click is comfortable, the kind of forgiveness a real `<button>` gives for free.
*
* @param event - The pointer event whose position to test.
* @returns Whether the point landed on the painted UI, with the matching action applied.
*/
private pickFauxUiAt( event: PointerEvent ): boolean {
const rect = this.$viewport.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
// Test in reverse paint order, so the topmost painted thing wins: the swatches sit on the panel, the panel covers any pill it overlaps, and the pills sit over the scene.
const panel = this.fauxPanelRect( rect.width, rect.height );
const swatchY = panel.y + 54 + 8;
let swatchX = panel.x + 12 + 8;
for ( const swatch of SHADE_SWATCHES ) {
if ( Math.hypot( px - swatchX, py - swatchY ) <= 10 ) {
this.onSelectSwatch( swatch.id );
return true;
}
swatchX += 24;
}
if ( px >= panel.x && px <= panel.x + panel.width && py >= panel.y && py <= panel.y + panel.height ) {
return true;
}
const ctx = this.overlayCtx;
if ( ctx ) {
ctx.font = `600 12px ${ this.overlayColors.fontSans }`;
// Within the pills the test also runs in reverse paint order, since a later-painted pill covers an earlier one where they overlap: one more stacking decision the DOM normally owns and painted UI has to re-derive.
for ( const part of [ ...this.parts ].reverse() ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
const padX = 9;
const boxWidth = ctx.measureText( part.label ).width + padX * 2;
const boxHeight = 22;
const x = point.x - boxWidth / 2;
const y = point.y - boxHeight / 2;
if ( px >= x && px <= x + boxWidth && py >= y && py <= y + boxHeight ) {
this.select( part.id, 'canvas' );
return true;
}
}
}
return false;
}
/**
* Resolves the part a hit object belongs to by walking its ancestry to the first stamped `userData.partId`.
*
* The geometry helper stamps every visible mesh, cosmetic pieces included, so a hit anywhere on the lamp maps to the part a user believes they clicked; the glow sprite is left unstamped on purpose, so this returns `null` for it and the caller moves on to the next hit.
*
* @param object - The object the raycaster returned.
* @returns The part id stamped on the object or an ancestor, or `null` when the chain carries none.
*/
private partIdForObject( object: THREE.Object3D ): LampPartId | null {
let current: THREE.Object3D | null = object;
while ( current ) {
const stamped = current.userData.partId;
const id = PART_IDS.find( ( partId ) => partId === stamped );
if ( id ) {
return id;
}
current = current.parent;
}
return null;
}
/**
* Starts the render loop if it is not already running and the demo is on screen, and marks the scene dirty so the first frame back repaints.
*/
private startLoop(): void {
if ( this.frame === 0 && this.isVisible ) {
this.frame = requestAnimationFrame( this.renderLoop );
}
this.requestRender();
}
/**
* Stops the render loop and clears the frame handle so it can be restarted cleanly.
*/
private stopLoop(): void {
if ( this.frame !== 0 ) {
cancelAnimationFrame( this.frame );
this.frame = 0;
}
}
/**
* Marks the scene dirty so the next animation frame renders the WebGL view and re-syncs the overlay.
*/
private requestRender(): void {
this.needsRender = true;
}
/**
* The render loop: applies the orbit to the lamp, draws the WebGL frame, and re-syncs the overlay so the labels stay glued as the model turns.
*
* The loop stays hot at the frame cadence while the demo is on screen, but the work is gated on {@link needsRender}: an idle frame re-arms itself and returns, so a lamp nobody is touching renders nothing and writes no fresh `anchorPoints`, and the first frame after a change does the full pass.
*/
private renderLoop = (): void => {
this.frame = requestAnimationFrame( this.renderLoop );
if ( ! this.needsRender ) {
return;
}
if ( ! this.renderer || ! this.scene || ! this.camera || ! this.lamp ) {
return;
}
this.needsRender = false;
this.lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
this.renderer.render( this.scene, this.camera );
this.syncOverlay();
};
/**
* Lifts the emissive of the selected part and rests every other part, so the selection reads in 3D as a glow on the chosen mesh in either theme.
*
* It runs on a selection or theme change rather than every frame, since the emissive persists in the material between renders, and the cached accent keeps it off the `getComputedStyle` path.
* The bulb is left to its own lit glow, so selecting it does not fight the bulb toggle; its overlay label and inspector heading still mark the selection.
*/
private applySelectionHighlight(): void {
if ( ! this.lamp ) {
return;
}
for ( const id of PART_IDS ) {
if ( id === 'bulb' ) {
continue;
}
const material = this.lamp.parts[ id ].material;
if ( Array.isArray( material ) || ! ( material instanceof THREE.MeshStandardMaterial ) ) {
continue;
}
const selected = id === this.selectedId;
material.emissive.copy( selected ? this.accent : BLACK );
material.emissiveIntensity = selected ? 0.32 : 0;
}
}
/**
* Projects each part's 3D anchor to a CSS-pixel screen position and stores it, then repaints the flat overlay for the active mode.
*
* This is the alignment bookkeeping the article says the overlay owns: the anchor is taken into world space through the lamp's matrix, projected with the camera, and converted from normalized device coordinates to CSS pixels against the canvas rect, which keeps the projection device-pixel-ratio correct because the rect is in CSS pixels while the WebGL backing store carries the ratio separately.
*/
private syncOverlay(): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
this.lamp.group.updateMatrixWorld();
const next: Record<string, { x: number; y: number; visible: boolean }> = {};
for ( const id of PART_IDS ) {
this.projected.copy( this.lamp.anchors[ id ] );
this.lamp.group.localToWorld( this.projected );
this.projected.project( this.camera );
next[ id ] = {
x: ( this.projected.x * 0.5 + 0.5 ) * width,
y: ( -this.projected.y * 0.5 + 0.5 ) * height,
visible: this.projected.z < 1,
};
}
this.anchorPoints = next;
this.paintFlatOverlay();
}
/**
* Repaints the flat overlay canvas for the active mode: the faux UI in `canvas-only`, a requested `paint` event for the drawn labels in `html-in-canvas`, and a clear in `dom-overlay` where real HTML owns the labels.
*
* The experimental branch neither draws nor clears here; it calls `requestPaint()` and {@link onOverlayPaint} clears and draws inside the event, so a platform-initiated paint, a focus ring appearing on a drawn label, repaints the whole canvas atomically instead of compositing over pixels this pass left behind. The shared {@link supportsHtmlInCanvas} predicate gates the mode on both methods, so the branch only runs where the request is real.
*/
private paintFlatOverlay(): void {
const $canvas = this.$overlayCanvas;
if ( ! $canvas ) {
return;
}
if ( ! this.overlayCtx ) {
this.overlayCtx = $canvas.getContext( '2d' );
}
const ctx = this.overlayCtx;
if ( ! ctx ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = rect.width || 1;
const cssHeight = rect.height || 1;
// Resize the backing store when the box or the device pixel ratio changes, since assigning width or height clears and reallocates the canvas, which would flicker if done every frame; keying on dpr too reallocates when only the ratio shifts (a display move or browser zoom that leaves the CSS box bit-identical), matching the WebGL side that already re-sizes for the new ratio.
if ( this.overlaySize.width !== cssWidth || this.overlaySize.height !== cssHeight || this.overlaySize.dpr !== dpr ) {
$canvas.width = Math.round( cssWidth * dpr );
$canvas.height = Math.round( cssHeight * dpr );
this.overlaySize = { width: cssWidth, height: cssHeight, dpr };
}
if ( this.activeMode === 'html-in-canvas' ) {
$canvas.requestPaint();
return;
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
ctx.clearRect( 0, 0, cssWidth, cssHeight );
if ( this.activeMode === 'canvas-only' ) {
this.paintFauxUi( ctx, cssWidth, cssHeight );
}
}
/**
* Paints the canvas-only trap: every part label and a faux inspector panel, all as raw pixels with no DOM behind them.
*
* It looks like a working configurator, but the text is pixels that cannot be selected, found in page, or reached with a Tab key or a screen reader, which is the trap a real 3D scene invites because it is exactly where someone reaches for drawing the UI onto the canvas too.
*
* @param ctx - The overlay 2D context, scaled so one unit is one CSS pixel.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxUi( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
this.paintPill( ctx, part.label, point.x, point.y, part.id === this.selectedId );
}
this.paintFauxInspector( ctx, width, height );
}
/**
* Paints one rounded label pill centered on a point, the faux equivalent of an overlay label, from the cached themed colors.
*
* @param ctx - The overlay 2D context.
* @param text - The label text.
* @param cx - The pill center x in CSS pixels.
* @param cy - The pill center y in CSS pixels.
* @param selected - Whether to fill the pill with the accent.
*/
private paintPill( ctx: CanvasRenderingContext2D, text: string, cx: number, cy: number, selected: boolean ): void {
const colors = this.overlayColors;
ctx.font = `600 12px ${ colors.fontSans }`;
const padX = 9;
const boxWidth = ctx.measureText( text ).width + padX * 2;
const boxHeight = 22;
const x = cx - boxWidth / 2;
const y = cy - boxHeight / 2;
const pill = new Path2D();
pill.roundRect( x, y, boxWidth, boxHeight, 11 );
ctx.fillStyle = selected ? colors.accent : colors.surface;
ctx.fill( pill );
ctx.lineWidth = 1;
ctx.strokeStyle = selected ? colors.accent : this.withAlpha( colors.ink, 0.18 );
ctx.stroke( pill );
ctx.fillStyle = selected ? colors.accentInk : colors.ink;
ctx.textBaseline = 'middle';
ctx.fillText( text, x + padX, y + boxHeight / 2 );
}
/**
* The faux inspector panel's box in CSS pixels, one derivation shared by the painter and the hand-built hit test so the drawn panel and its hit region cannot drift apart.
*
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
* @returns The panel's position and size.
*/
private fauxPanelRect( width: number, height: number ): { x: number; y: number; width: number; height: number } {
const panelWidth = Math.min( 190, width - 24 );
const panelHeight = 96;
return {
x: width - panelWidth - 12,
y: height - panelHeight - 12,
width: panelWidth,
height: panelHeight,
};
}
/**
* Paints the faux inspector panel into the corner of the canvas, a picture of the real inspector with no DOM behind it, from the cached themed colors.
*
* The panel responds to the mouse only because {@link pickFauxUiAt} re-derives this exact geometry by hand, from the same {@link fauxPanelRect} box this painter draws; there is still no element, so nothing here can take focus, appear in the accessibility tree, or offer selectable text.
*
* @param ctx - The overlay 2D context.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxInspector( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
const colors = this.overlayColors;
const box = this.fauxPanelRect( width, height );
const x = box.x;
const y = box.y;
const panel = new Path2D();
panel.roundRect( x, y, box.width, box.height, 10 );
ctx.fillStyle = this.withAlpha( colors.surface, 0.95 );
ctx.fill( panel );
ctx.lineWidth = 1;
ctx.strokeStyle = this.withAlpha( colors.ink, 0.16 );
ctx.stroke( panel );
const selected = this.selectedPart;
ctx.fillStyle = colors.ink;
ctx.textBaseline = 'alphabetic';
ctx.font = `600 13px ${ colors.fontSans }`;
ctx.fillText( selected ? selected.label : 'Inspector', x + 12, y + 24 );
ctx.font = `11px ${ colors.fontMono }`;
ctx.fillStyle = this.withAlpha( colors.ink, 0.6 );
ctx.fillText( 'Shade color', x + 12, y + 46 );
const swatchY = y + 54;
let swatchX = x + 12;
for ( const swatch of SHADE_SWATCHES ) {
ctx.beginPath();
ctx.arc( swatchX + 8, swatchY + 8, 8, 0, Math.PI * 2 );
ctx.fillStyle = swatch.hex;
ctx.fill();
if ( swatch.id === this.config.shadeSwatchId ) {
ctx.lineWidth = 2;
ctx.strokeStyle = colors.accent;
ctx.stroke();
}
swatchX += 24;
}
}
/**
* Draws the real labels when the overlay canvas fires its `paint` event, so the experimental draw runs inside the event the way the canonical flow prescribes.
*
* The render loop and the sync passes never draw or clear the labels themselves; {@link paintFlatOverlay} requests a paint, and this handler clears the canvas and runs {@link drawLabelsIntoCanvas} when the event lands, so a platform-initiated paint, a focus ring appearing on a label, starts from its own clear instead of leaving residue over the last frame.
* The mode guard mirrors the experimental branch of the paint pass, since the listener stays wired in every mode, and the context guard covers an event that lands before the first paint pass has cached the 2D context.
*/
private onOverlayPaint = (): void => {
const ctx = this.overlayCtx;
const $canvas = this.$overlayCanvas;
if ( this.activeMode !== 'html-in-canvas' || ! ctx || ! $canvas ) {
return;
}
// Clear in device pixels under the identity transform, the same grid the label draw below runs on.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
ctx.clearRect( 0, 0, $canvas.width, $canvas.height );
this.drawLabelsIntoCanvas( ctx );
};
/**
* Draws every visible real label node into the overlay canvas at its own 1:1 size and projected center, then writes each returned alignment matrix back, the experimental in-canvas-label path.
*
* Each label's `style.transform` is a platform-owned slot here. The node is already a reachable element the browser exposes in the accessibility tree and the tab order, because the overlay canvas keeps it a real DOM child under `layoutsubtree`; writing the matrix back is only what keeps that element's focus ring, hit target, and accessibility bounds aligned under its drawn pixels. When `omitTransformSync` is set the write is skipped so the reader can watch the focus ring and click target drift off the pixels.
*
* A label whose part is facing away is hidden rather than skipped in place: `visibility: hidden` also removes it from the tab order and the accessibility tree while it is offscreen, the same contract the DOM-overlay labels keep, where a skipped node would otherwise stay tabbable at its stale position.
*
* The work runs in three passes, the transform resets and visibility first, then every box measurement, then every draw and write-back, so the style writes cost at most two layout flushes per paint instead of one per label.
*
* The draw runs on the bare device-pixel grid, not under the `dpr` scale the faux UI paints with: the label node already rasterizes at the display's pixel density, so drawing it under an extra `dpr` transform would double its size, which is what made the drawn pills balloon. The projected center and the label box are scaled into device pixels by hand instead, so the drawn pill matches the DOM-overlay pill exactly, then the transform is restored for the next paint.
*
* The explainer's canonical flow routes this draw through the canvas `paint` event, and that is where it runs: the render loop and the sync passes only call `requestPaint()`, and {@link onOverlayPaint} invokes this method when the event fires. Calling `drawElementImage` outside the event is still defined behavior, but it draws the previous frame's snapshot of the element and throws before a first snapshot exists, which is why the handler owns the draw.
*
* @param ctx - The overlay 2D context, on a supporting build where `drawElementImage` exists.
*/
private drawLabelsIntoCanvas( ctx: CanvasRenderingContext2D ): void {
const $host = this.$overlayCanvas;
if ( ! $host ) {
return;
}
const dpr = window.devicePixelRatio || 1;
// Drop to the identity transform so one drawing unit is one backing-store pixel, the space the element bitmap is already rasterized in; the faux-UI dpr scale is restored at the end.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
// First pass: reset any prior alignment before measuring, so each box read is the label's own size rather than a previously skewed one, and hide the labels whose part is facing away.
const drawable: Array<{ point: { x: number; y: number }; $node: HTMLElement }> = [];
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
const $node = $host.querySelector( `[data-part='${ part.id }']` );
if ( ! ( $node instanceof HTMLElement ) ) {
continue;
}
if ( ! point || ! point.visible ) {
$node.style.visibility = 'hidden';
continue;
}
$node.style.visibility = '';
if ( ! this.omitTransformSync ) {
$node.style.transform = '';
}
drawable.push( { point, $node } );
}
// Second pass: measure every box in one batch, so the writes above flush layout once rather than per label.
const boxes = drawable.map( ( entry ) => entry.$node.getBoundingClientRect() );
// Third pass: draw each label at its projected center and write the alignment matrix back.
for ( const [ index, entry ] of drawable.entries() ) {
const box = boxes[ index ];
const dx = ( entry.point.x - box.width / 2 ) * dpr;
const dy = ( entry.point.y - box.height / 2 ) * dpr;
const alignment = ctx.drawElementImage( entry.$node, dx, dy );
if ( ! this.omitTransformSync ) {
entry.$node.style.transform = alignment.toString();
}
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
}
/**
* Reads a CSS custom property off the host, falling back to a literal when the token sheet has not loaded.
*
* A canvas paints raw pixels and cannot inherit a CSS color, so every themed color the faux UI draws is read here at paint time, which is what keeps the canvas-only labels legible in light and dark.
*
* @param name - The custom property to read.
* @param fallback - The literal color to use when the property is empty.
* @returns The resolved color string.
*/
private token( name: string, fallback: string ): string {
const value = getComputedStyle( this ).getPropertyValue( name ).trim();
return value || fallback;
}
/**
* Resolves the selection accent the same way the stylesheet does, reading the private `--cfg-accent` so the 3D highlight and the painted UI match the DOM controls in either theme.
*/
private accentColor(): string {
return this.token( '--cfg-accent', '#2257e6' );
}
/**
* Re-reads the themed colors into the caches the render loop relies on: the accent as a Three.js color for the 3D highlight, and the token set the flat overlay paints with.
*
* It is called on connect and after a theme change, so every per-frame draw works from cached values rather than calling `getComputedStyle`.
* The flip side of that cache is staleness: a host that swaps `--configurator-accent` at runtime without a theme flip paints the old accent until the next theme change refreshes it.
*/
private refreshAccent(): void {
const accent = this.accentColor();
this.accent.set( accent );
this.overlayColors = {
ink: this.token( '--ink', '#17171a' ),
surface: this.token( '--surface', '#ffffff' ),
accent,
accentInk: this.token( '--accent-ink', '#ffffff' ),
fontSans: this.token( '--font-sans', 'system-ui, sans-serif' ),
fontMono: this.token( '--font-mono', 'monospace' ),
};
}
/**
* Returns a color string at a given alpha, parsing a `#rgb` or `#rrggbb` token into `rgba()` and mixing any other syntax toward transparent with `color-mix()`.
*
* The canvas needs an explicit alpha for the faux panel and pill borders, but the theme tokens are usually opaque hex, so the fast path parses those by hand; anything else goes through `color-mix()`, which any browser modern enough to hand us a non-hex token can paint.
*
* @param color - The base color, usually a hex token value.
* @param alpha - The alpha to apply, from 0 to 1.
* @returns An `rgba()` string for hex input, or a `color-mix()` expression for anything else.
*/
private withAlpha( color: string, alpha: number ): string {
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec( color.trim() );
if ( ! match ) {
return `color-mix( in srgb, ${ color } ${ Math.round( alpha * 100 ) }%, transparent )`;
}
const body = match[ 1 ];
const full = body.length === 3 ? body.split( '' ).map( ( c ) => c + c ).join( '' ) : body;
const r = parseInt( full.slice( 0, 2 ), 16 );
const g = parseInt( full.slice( 2, 4 ), 16 );
const b = parseInt( full.slice( 4, 6 ), 16 );
return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`;
}
/**
* Selects a part from a real overlay label, the keyboard-reachable path the raycast cannot provide.
*
* @param id - The id of the part the label represents.
*/
private onLabelClick( id: string ): void {
this.select( id, 'overlay' );
}
/**
* Updates the selection and dispatches the typed `object-select` event, tagged with the layer it came from.
*
* The event fires on every pick, even a repeat of the current selection, so a demo log can record each raycast; the internal `selectedId` only changes when the target actually changes.
* The event is `composed` so it crosses the shadow boundary and `bubbles` so a listener on the host hears it.
*
* @param id - The id to select, or `null` to clear.
* @param source - Which layer triggered the selection, the raycast or a real overlay control.
*/
private select( id: string | null, source: ConfiguratorSelectSource ): void {
if ( id !== this.selectedId ) {
this.selectedId = id;
}
this.dispatchEvent(
new CustomEvent<ConfiguratorSelectDetail>( 'object-select', {
detail: { objectId: id, source },
bubbles: true,
composed: true,
} ),
);
}
/**
* Recolors the shade to a swatch, applying it to the live material and reporting the new config.
*
* @param swatchId - The id of the chosen swatch.
*/
private onSelectSwatch( swatchId: string ): void {
this.config = { ...this.config, shadeSwatchId: swatchId };
if ( this.lamp ) {
setShadeColor( this.lamp, this.shadeHex() );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Switches the base finish, retuning the live base material and reporting the new config.
*
* @param finish - The finish to apply, matte or metal.
*/
private onSelectFinish( finish: BaseFinish ): void {
this.config = { ...this.config, baseFinish: finish };
if ( this.lamp ) {
setBaseFinish( this.lamp, finish );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Toggles the bulb, relighting the scene through the helper and reporting the new config.
*/
private onToggleBulb(): void {
const bulbOn = ! this.config.bulbOn;
this.config = { ...this.config, bulbOn };
if ( this.lamp ) {
setBulbState( this.lamp, bulbOn );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Dispatches the typed `config-change` event with the whole current config after an inspector edit.
*
* It is `composed` and `bubbles` for the same reason the selection event is, so a host listener hears every edit without a cast.
*/
private emitConfigChange(): void {
this.dispatchEvent(
new CustomEvent<ConfiguratorConfigChangeDetail>( 'config-change', {
detail: { config: this.config },
bubbles: true,
composed: true,
} ),
);
}
/**
* Types `addEventListener` for the configurator's own events so a listener receives the typed `CustomEvent` with no cast, and falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof ProductConfiguratorEventMap>(
type: K,
listener: ( this: ProductConfigurator, ev: ProductConfiguratorEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the WebGL viewport, the flat overlay canvas, the real DOM labels or the drawn-label nodes for the active mode, and the inspector and harness panel in the side column.
*
* The WebGL canvas always renders the lamp; what changes per mode is whether real HTML labels sit over it (`dom-overlay`), real label nodes are drawn into the overlay canvas (`html-in-canvas`), or the labels and a faux inspector are painted onto the overlay canvas with no DOM (`canvas-only`).
* The stage and the side column are always laid out by the same `.layout` grid: `stacked` keeps it one column so the side falls under the canvas, and `split` makes it two so the side becomes the right column, the glass-demo shape.
*/
render() {
const active = this.activeMode;
const parts = this.parts;
return html`
<div class=${ this.layout === 'split' ? 'layout split' : 'layout' }>
<div class="main">
<slot name="header"></slot>
<div class=${ this.isDragging ? 'stage dragging' : 'stage' }>
<canvas class="viewport" role="img" aria-label="A 3D desk lamp you can orbit by dragging. Click a part to select it."></canvas>
<canvas class="overlay-canvas" aria-hidden=${ active === 'html-in-canvas' ? 'false' : 'true' } ?layoutsubtree=${ active === 'html-in-canvas' }>
${ active === 'html-in-canvas'
? parts.map( ( part ) => html`
<button
type="button"
class="drawn-label"
data-part=${ part.id }
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
` )
: nothing }
</canvas>
${ active === 'dom-overlay'
? html`
<div class="overlay">
${ parts.map( ( part ) => {
const point = this.anchorPoints[ part.id ];
const shown = point && point.visible;
return html`
<button
type="button"
class="label"
style="left: ${ point ? point.x : 0 }px; top: ${ point ? point.y : 0 }px; visibility: ${ shown ? 'visible' : 'hidden' };"
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
`;
} ) }
</div>
`
: nothing }
</div>
</div>
${ this.renderSide( active ) }
</div>
`;
}
/**
* Renders the side column of the layout, the real inspector when the mode has one stacked above the harness `panel` slot, and in `split` a full-width `footer` slot under both columns.
*
* In `split` it is always rendered so it forms the right column even in `canvas-only`, where the inspector is painted into the canvas and the slot still carries the harness panel. In `stacked` it is rendered only when there is something to show, the inspector for a mode that has one, so a bare `canvas-only` embed does not leave an empty row under the canvas.
* The `footer` row spans both columns and only renders in `split`, so a harness can drop a tall report or log there instead of stacking it in the narrow side column, where it would run far below the stage and strand the canvas column; an empty `footer` slot produces no box, so a harness that slots nothing renders exactly as before.
*
* @param active - The active mode, which decides whether a real DOM inspector exists.
* @returns The side-column template, or `nothing` when stacked with no inspector to show.
*/
private renderSide( active: ConfiguratorMode ) {
const inspector = active === 'canvas-only' ? nothing : this.renderInspector();
if ( this.layout !== 'split' && active === 'canvas-only' ) {
return nothing;
}
return html`
<div class="side">
${ inspector }
<slot name="panel"></slot>
</div>
${ this.layout === 'split'
? html`<div class="footer"><slot name="footer"></slot></div>`
: nothing }
`;
}
/**
* Renders the real DOM inspector for the selected part: the shade swatches, the base finish, and the bulb toggle, each a real, keyboard-reachable control.
*
* It shows every control whatever the selected part, since the lamp is one product, and names the selected part in the heading so the selection still reads; with nothing selected the heading falls back to the whole lamp.
*/
private renderInspector() {
const selected = this.selectedPart;
return html`
<div class="inspector">
<div class="head">
<h3>${ selected ? selected.label : 'Lamp' }</h3>
${ selected ? html`<span class="part-tag">#${ selected.id }</span>` : nothing }
</div>
<div class="field">
<span class="field-label">Shade color</span>
<div class="swatches">
${ SHADE_SWATCHES.map( ( swatch ) => html`
<button
type="button"
class="swatch"
style="background: ${ swatch.hex };"
aria-label=${ swatch.label }
aria-pressed=${ swatch.id === this.config.shadeSwatchId }
@click=${ () => this.onSelectSwatch( swatch.id ) }
></button>
` ) }
</div>
</div>
<div class="field">
<span class="field-label">Base finish</span>
<div class="segmented">
<button type="button" aria-pressed=${ this.config.baseFinish === 'matte' } @click=${ () => this.onSelectFinish( 'matte' ) }>Matte</button>
<button type="button" aria-pressed=${ this.config.baseFinish === 'metal' } @click=${ () => this.onSelectFinish( 'metal' ) }>Metal</button>
</div>
</div>
<div class="field">
<span class="field-label">Bulb</span>
<button type="button" class="switch" aria-pressed=${ this.config.bulbOn } @click=${ () => this.onToggleBulb() }>
<span class=${ this.config.bulbOn ? 'track on' : 'track' }><span class="knob"></span></span>
<span>${ this.config.bulbOn ? 'On' : 'Off' }</span>
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'product-configurator': ProductConfigurator;
}
}That dead end is the entry point to a better question than “how do I draw this.” The interesting frontier API here is HTML-in-Canvas, a Chromium experiment that draws a real DOM element into a canvas and keeps it interactive. It’s exciting, and it’s exactly the kind of thing that’s easy to misjudge, because it looks like it solves the trap by magic. It mostly doesn’t, and walking through why is how you build the judgment that outlasts any single API: how to read a new platform primitive without confusing what’s possible with what you can ship.
First, the part that makes you want it
Before the careful build, look at what this API reaches for. These three demos are the frontier, the spectacle that gets HTML-in-Canvas into a roadmap conversation in the first place. Each one runs on this page through a real fallback, so you can poke at it right now, and each ends in a Ship, Enhance, or Research verdict, because the rest of the piece is about how a senior gets from “this is amazing” to “here is exactly when I’d ship it.”
The first is the one most likely to reach production. It opens on liquid glass rippling over live text, and you can switch between three tiers. A frosted backdrop-filter card is the Baseline floor, an SVG refraction adds the real warped-glass look, and a flag-only WebGL shader captures the live card through drawElementImage to run effects no CSS can reach. The indicator always names the tier you asked for and the one actually live, and you can select the text through the glass, which a painted image would never allow. It lands at Ship for the frosted floor, Enhance for the SVG refraction, and Research for the shader over live HTML.
import { LitElement, html, css, svg, nothing } from 'lit';
import { supportsHtmlInCanvas } from '@demos/supports-html-in-canvas';
import { customElement, property, state, query } from 'lit/decorators.js';
import type { GlassCardEventMap, GlassTier, GlassTierChangeDetail, GlassVerdict } from './types';
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here.
* The shader tier feeds the live card through this draw before sampling it into a WebGL texture, which is why the tier probes for it and falls back when it is missing.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
}
/**
* A liquid-glass card rendered three ways over live, still-interactive HTML, so one effect carries three production verdicts.
*
* `frosted` is the Baseline `backdrop-filter: blur()` panel that ships for most readers today, `refraction` layers an animated SVG `feDisplacementMap` warp with a three-pass chromatic split and a pointer-tracked specular rim on the card's own content so the text stays selectable and runs in Chromium and Firefox, and `shader` is the flag-only frontier that runs a real WebGL fragment shader over the live card: the card content is laid out inside a `layoutsubtree` staging canvas, captured through `drawElementImage`, uploaded as a GPU texture, and refracted by a composited shader that stacks three independent passes, an underwater water warp, a VHS pass, and a CRT pass, each its own toggle with its own intensity.
* The card feature-detects each tier and drops to a supported one when a filter chain or the experimental API is missing, so the content underneath the glass is always real, selectable DOM and the requested tier and the active tier can differ.
* The point the card makes is not the effect, it is naming which tier you are in: frosted Ship, refraction Enhance, shader Research.
* The scene is dressed as a view up through water at the glassy panel, a light water-blue field under drifting caustics, slanting rays, and rising bubbles, and the `distortion` property drives how hard the water bends the view on the SVG refraction tier so the reader can dial the warp from a calm ripple to a dramatic refraction while keeping the text readable.
* The frosted tier has no displacement to distort, so it reads the `frost` property instead, which scales its `backdrop-filter` blur radius, and the shader tier carries three independent effect toggles, `water`, `vhs`, and `crt`, each with its own amount, `waterAmt`, `vhsAmt`, and `crtAmt`, so every combination is valid and each pass contributes on its own dial, all off through all on, with `waterAmt` setting the shader tier's water bend so it needs no separate distortion control.
*
* @element glass-card
* @slot - The live content rendered behind the glass, kept real DOM so it stays selectable and focusable on every tier, including the shader tier where it is the laid-out child the staging canvas captures.
* @fires tier-change - Fired when the active tier changes, carrying a `GlassTierChangeDetail` with the requested and active tiers.
* @cssprop [--glass-accent=#1f9fd1] - The rim and verdict color, an aqua to suit the underwater scene, themeable so the card matches the surrounding page.
*/
@customElement( 'glass-card' )
export class GlassCard extends LitElement {
/**
* Which tier the reader has asked the card to render.
*
* It is a request, not a guarantee: `refraction` falls back to `frosted` when the displacement filter chain is unsupported, and `shader` falls back to `refraction` then `frosted` whenever the experimental `drawElementImage` API or WebGL is absent, which today is almost everywhere.
*/
@property( { type: String } )
tier: GlassTier = 'frosted';
/**
* Forces the calmed rendering on, on top of the system `prefers-reduced-motion` setting the card already honors.
*
* The card reads the real media query itself through `matchMedia` and tracks changes, so a reader whose OS asks for reduced motion gets the calm surface no matter what this attribute says.
* A sandboxed iframe cannot force the real media query, which is why the demo needs this attribute at all; it can only add the reduction, and when either source is on the water stops drifting, the turbulence stops animating, and the warp shrinks to a still, gentle bend.
*/
@property( { type: Boolean, reflect: true, attribute: 'reduced-motion' } )
reducedMotion = false;
/**
* How hard the water bends the view on the SVG refraction tier, from `0` (a calm, barely-there ripple) to `1` (a dramatic, heavy refraction), defaulting low so the card text stays clearly readable while the surface still moves like water.
*
* It drives the `feDisplacementMap` scale on the refraction tier, and reduced motion clamps it so the warp can never push the text past legibility.
* It is inert on the frosted tier, which is a pure `backdrop-filter` blur with no geometric displacement, so a frosted card reads the `frost` control instead, and it is inert on the shader tier, whose water bend is driven by `waterAmt` so the shader needs no separate distortion control.
*/
@property( { type: Number } )
distortion = 0.18;
/**
* How heavily the frosted tier blurs the scene behind the glass, from `0` (a near-clear pane) to `1` (a deep, opaque frost), defaulting to the mid value that matches the card's classic look.
*
* It exists because the frosted tier has no displacement for the distortion control to drive, so this is the one knob that visibly does something there: it scales the `backdrop-filter` blur radius through the `--card-blur` custom property. The distorting tiers ignore it and keep their own fixed base blur, so each tier has exactly one live control and none sit dead.
* It is named `frost` rather than `blur` because `blur` is the inherited `HTMLElement.blur()` focus method, which a numeric property would shadow.
*/
@property( { type: Number } )
frost = 0.5;
/**
* Whether the water refraction pass is on, the first of the shader tier's three independent effect toggles.
*
* It only does anything on the shader tier, where it bends the captured card through an animated underwater height field with per-channel dispersion and lays a rippling caustic net and a pointer-tracked glint over it, scaled by `waterAmt` so it contributes on its own dial. It is no longer a forced base, so it can be off while VHS or CRT run alone, and with all three off the shader pipeline shows the live card with no visible effect.
*/
@property( { type: Boolean } )
water = true;
/**
* Whether the VHS pass is on, one of the shader tier's three independent effect toggles.
*
* It only does anything on the shader tier, where it stacks tape wobble, vertical jitter, chroma bleed, a rolling noise band, grain, and a touch of desaturation over whatever the lower passes produced, scaled by `vhsAmt` so it blends in rather than swamping the text, and it runs whether or not water is on.
*/
@property( { type: Boolean } )
vhs = false;
/**
* Whether the CRT pass is on, one of the shader tier's three independent effect toggles.
*
* It only does anything on the shader tier, where it stacks barrel distortion, scanlines, a phosphor flicker, a phosphor tint, and a vignette over the composited image, scaled by `crtAmt`, composing on top of the water and VHS passes when they are on and running on its own when they are off.
*/
@property( { type: Boolean } )
crt = false;
/**
* How strongly the water refraction pass contributes on the shader tier, from `0` (no bend, no caustics, no glint, identical to `water` being off) to `1` (a heavy underwater warp), defaulting mid so a freshly toggled water pass looks tasteful.
*
* It is the shader tier's single water control: it scales the whole water contribution through the `uWaterAmt` uniform and also sets the bend character through the `uStrength` uniform, gated by the `water` toggle, so the effect fades smoothly to nothing at the low end and reaches a dramatic warp at the top with no separate distortion slider. Reduced motion caps it so the warp can never push the text past legibility.
*/
@property( { type: Number } )
waterAmt = 0.5;
/**
* How strongly the VHS pass contributes, from `0` (no contribution, identical to `vhs` being off) to `1` (full tape grade), defaulting mid so a freshly toggled VHS pass reads clearly without swamping the text.
*
* It scales the VHS pass through the `uVhsAmt` uniform, gated by the `vhs` toggle, so the effect fades smoothly to nothing at the low end, while reduced motion caps it and damps its moving parts so the look settles without losing the grade.
*/
@property( { type: Number } )
vhsAmt = 0.5;
/**
* How strongly the CRT pass contributes, from `0` (no contribution, identical to `crt` being off) to `1` (full tube grade), defaulting mid so a freshly toggled CRT pass reads clearly while the text stays legible.
*
* It scales the CRT pass through the `uCrtAmt` uniform, gated by the `crt` toggle, so the effect fades smoothly to nothing at the low end, while reduced motion caps it and holds its flicker still so the look settles without losing the grade.
*/
@property( { type: Number } )
crtAmt = 0.5;
/**
* Whether the live 2D context exposes the experimental `drawElementImage` method.
*
* Resolved once after the first render, so the shader tier degrades on a per-build basis rather than trusting the requested `tier`.
*/
@state()
private supportsShader = false;
/**
* Whether the browser supports the SVG displacement filter chain the refraction tier needs.
*
* Probed with `CSS.supports` so a browser that cannot render the warp drops to the frosted base instead of showing a broken card.
*/
@state()
private supportsRefraction = false;
/**
* Whether the OS-level `prefers-reduced-motion: reduce` setting is on, read from `matchMedia` on connect and tracked live through its change event.
*
* It is `@state` so a mid-session settings change re-renders and re-resolves every motion gate, and it combines with the demo attribute in {@link motionReduced}, where the system setting always wins.
*/
@state()
private systemReducedMotion = false;
/**
* The WebGL canvas the shader tier paints into, stacked over the staging canvas with `pointer-events: none` so the shader dominates visually while clicks, focus, and selection fall through to the live card content beneath it.
*
* It is optional like its `$stageCanvas` and `$capture` siblings because all three only exist in the template while the shader tier is active; on any other tier the query resolves `null`, so a definite `!` here would lie to every call site.
* It only carries a context while the shader tier is the active tier, so the GPU work is allocated lazily and torn down on a tier change.
*/
@query( 'canvas.gl' )
private $glCanvas?: HTMLCanvasElement;
/**
* The `layoutsubtree` staging canvas whose laid-out child is the live card content, captured each frame through `drawElementImage` and uploaded as the shader's source texture.
*
* It is only present in the DOM on the shader tier, since a `<canvas>` hides its child content on every canvas-supporting browser, so the other tiers render the content as a normal card child instead.
*/
@query( 'canvas.stage' )
private $stageCanvas?: HTMLCanvasElement;
/**
* The element captured into the staging canvas, the wrapper around the live content whose transform is synced back from `drawElementImage` so the real DOM stays aligned under its drawn pixels and interactive through the stacked shader.
*/
@query( '.capture' )
private $capture?: HTMLElement;
/**
* The element animation handle for the refraction turbulence, kept so it can be paused for reduced motion and cancelled on teardown.
*/
private rafId = 0;
/**
* The one-shot frame handle for the reduced-motion still render, kept separate from {@link rafId} so cancelling it never kills a running loop, and cancelled on teardown.
*/
private stillFrameId = 0;
/**
* Whether the card is currently intersecting the page, so the animation loop only spins while the card is on screen.
*/
private isVisible = true;
/**
* The observer that pauses the loop when the card scrolls offscreen and resumes it in view, retained so it disconnects cleanly.
*/
private intersectionObserver: IntersectionObserver | null = null;
/**
* Whether {@link setUp} has wired the media query, the visibility observer, and the animation state, so a reconnect can never double-wire them.
*/
private isSetUp = false;
/**
* The animated turbulence node inside the refraction filter, looked up once so each frame can rewrite its `baseFrequency` without re-rendering the template.
*/
private $turbulence: SVGFETurbulenceElement | null = null;
/**
* The `prefers-reduced-motion` media query list, kept so its change listener can be removed on disconnect.
*/
private reducedMotionQuery: MediaQueryList | null = null;
/**
* The live WebGL context for the shader tier, created when that tier goes active and dropped when it leaves.
*/
private gl: WebGLRenderingContext | null = null;
/**
* The 2D context of the staging canvas, cached so each frame can call `drawElementImage` without re-acquiring it.
*/
private stageCtx: CanvasRenderingContext2D | null = null;
/**
* The GPU texture the captured card is uploaded into each frame, the shader's `uTexture` source.
*/
private glTexture: WebGLTexture | null = null;
/**
* The resolved uniform locations for the composited shader pass, cached alongside the program so each frame skips the lookups.
*/
private glUniforms: {
texture: WebGLUniformLocation | null;
time: WebGLUniformLocation | null;
resolution: WebGLUniformLocation | null;
pointer: WebGLUniformLocation | null;
accent: WebGLUniformLocation | null;
calm: WebGLUniformLocation | null;
strength: WebGLUniformLocation | null;
waterAmt: WebGLUniformLocation | null;
vhsAmt: WebGLUniformLocation | null;
crtAmt: WebGLUniformLocation | null;
} | null = null;
/**
* The pointer position over the card in 0..1 coordinates, driving the specular highlight and the shader refraction center.
*
* It eases toward the latest pointer sample each frame so the highlight glides rather than snapping.
*/
private pointer = { x: 0.5, y: 0.4, tx: 0.5, ty: 0.4 };
/**
* The timestamp the current animation loop started, so the elapsed time fed to the turbulence and the shader is stable across pauses.
*/
private startTime = 0;
/**
* The fixed production verdict for each tier, printed in the card so the reader always sees which tier carries which decision.
*/
private readonly verdicts: Record<GlassTier, GlassVerdict> = {
frosted: 'Ship',
refraction: 'Enhance',
shader: 'Research',
};
/**
* Styles for the card, scoped to its shadow root.
*/
static styles = css`
:host {
display: block;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
--accent: var( --glass-accent, #1f9fd1 );
}
/*
* The scene is the view up through water at a glassy panel.
* The base field is the requested light water blue, and the caustic light, the slanting rays, and the drifting bubbles all layer on top of it so the refraction has bright, moving edges to bend.
*/
.scene {
position: relative;
overflow: hidden;
border-radius: var( --radius-lg, 14px );
padding: clamp( 32px, 8vw, 68px ) clamp( 16px, 5vw, 40px );
min-height: 300px;
display: grid;
place-items: center;
isolation: isolate;
background-color: #83b8f8;
background-image:
radial-gradient( circle at 50% -10%, rgba( 233, 248, 255, 0.85 ) 0, transparent 46% ),
radial-gradient( circle at 18% 14%, rgba( 186, 240, 255, 0.7 ) 0, transparent 40% ),
radial-gradient( circle at 82% 88%, rgba( 16, 78, 132, 0.55 ) 0, transparent 52% ),
linear-gradient( 180deg, rgba( 175, 224, 255, 0.55 ) 0, transparent 38% ),
linear-gradient( 180deg, transparent 60%, rgba( 18, 92, 150, 0.42 ) 100% );
}
/*
* The caustics are the rippling net of light the water surface throws down onto everything below it.
* Two overlapping radial weaves drift slowly across each other so the bright cells wander like real caustics rather than sitting as a fixed pattern, and they give the refraction high-contrast edges to bend.
*/
.scene::before {
content: '';
position: absolute;
inset: -20%;
z-index: 0;
background-image:
radial-gradient( circle at 30% 30%, rgba( 240, 252, 255, 0.55 ) 0, transparent 9% ),
radial-gradient( circle at 70% 44%, rgba( 224, 248, 255, 0.45 ) 0, transparent 8% ),
radial-gradient( circle at 48% 72%, rgba( 240, 252, 255, 0.5 ) 0, transparent 10% ),
radial-gradient( circle at 86% 80%, rgba( 214, 244, 255, 0.4 ) 0, transparent 9% ),
radial-gradient( circle at 14% 62%, rgba( 235, 250, 255, 0.42 ) 0, transparent 8% );
background-size: 44% 44%, 38% 38%, 50% 50%, 40% 40%, 46% 46%;
mix-blend-mode: screen;
opacity: 0.9;
animation: caustics 18s ease-in-out infinite alternate;
}
/* The light rays are the god-rays slanting down from the surface, a second moving layer that sweeps gently across the scene. */
.scene::after {
content: '';
position: absolute;
inset: -30% -10%;
z-index: 0;
background-image: repeating-linear-gradient(
100deg,
rgba( 255, 255, 255, 0.16 ) 0,
rgba( 255, 255, 255, 0.16 ) 5px,
transparent 5px,
transparent 46px
);
mix-blend-mode: screen;
opacity: 0.7;
transform-origin: top center;
animation: rays 16s ease-in-out infinite alternate;
}
:host( [reduced-motion] ) .scene::before,
:host( [reduced-motion] ) .scene::after,
:host( [reduced-motion] ) .bubbles span {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.scene::before,
.scene::after,
.bubbles span {
animation: none;
}
}
/* The bubbles rise from the deep, a slow stream that drifts as it climbs so the water reads as a living volume, not a flat wash. */
.bubbles {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.bubbles span {
position: absolute;
bottom: -8%;
border-radius: 50%;
background: radial-gradient(
circle at 32% 30%,
rgba( 255, 255, 255, 0.95 ) 0,
rgba( 226, 246, 255, 0.5 ) 38%,
rgba( 226, 246, 255, 0.08 ) 70%,
transparent 100%
);
box-shadow: inset 0 0 4px rgba( 255, 255, 255, 0.7 );
opacity: 0;
animation: rise linear infinite;
}
.bubbles span:nth-child( 1 ) { left: 12%; width: 10px; height: 10px; animation-duration: 9s; animation-delay: 0s; }
.bubbles span:nth-child( 2 ) { left: 28%; width: 6px; height: 6px; animation-duration: 7s; animation-delay: 1.4s; }
.bubbles span:nth-child( 3 ) { left: 44%; width: 14px; height: 14px; animation-duration: 11s; animation-delay: 3s; }
.bubbles span:nth-child( 4 ) { left: 61%; width: 8px; height: 8px; animation-duration: 8s; animation-delay: 0.7s; }
.bubbles span:nth-child( 5 ) { left: 76%; width: 5px; height: 5px; animation-duration: 6.5s; animation-delay: 2.2s; }
.bubbles span:nth-child( 6 ) { left: 88%; width: 11px; height: 11px; animation-duration: 10s; animation-delay: 4.1s; }
.bubbles span:nth-child( 7 ) { left: 36%; width: 4px; height: 4px; animation-duration: 5.5s; animation-delay: 3.6s; }
.bubbles span:nth-child( 8 ) { left: 69%; width: 13px; height: 13px; animation-duration: 12s; animation-delay: 1.1s; }
@keyframes caustics {
from {
transform: translate3d( 0, 0, 0 ) scale( 1 );
}
to {
transform: translate3d( 4%, 3%, 0 ) scale( 1.08 );
}
}
@keyframes rays {
from {
transform: translateX( -3% ) skewX( -2deg );
opacity: 0.55;
}
to {
transform: translateX( 3% ) skewX( 2deg );
opacity: 0.8;
}
}
@keyframes rise {
0% {
transform: translate( 0, 0 ) scale( 0.7 );
opacity: 0;
}
12% {
opacity: 0.9;
}
50% {
transform: translate( 10px, -52vh ) scale( 1 );
}
88% {
opacity: 0.7;
}
100% {
transform: translate( -8px, -104vh ) scale( 1.1 );
opacity: 0;
}
}
/* The card is the glassy panel seen through the water, a cool aqua pane lit from the surface above and shadowed into the depth below. */
.card {
position: relative;
z-index: 1;
width: min( 360px, 100% );
border-radius: var( --radius-lg, 14px );
border: 1px solid rgba( 224, 248, 255, 0.55 );
box-shadow:
0 1px 0 rgba( 240, 252, 255, 0.6 ) inset,
0 0 0 1px rgba( 70, 160, 210, 0.18 ),
0 24px 64px rgba( 10, 52, 92, 0.5 );
background: linear-gradient(
160deg,
rgba( 220, 246, 255, 0.26 ) 0,
rgba( 120, 196, 235, 0.16 ) 60%,
rgba( 24, 100, 156, 0.2 ) 100%
);
-webkit-backdrop-filter: blur( var( --card-blur, 16px ) ) saturate( 1.6 );
backdrop-filter: blur( var( --card-blur, 16px ) ) saturate( 1.6 );
}
/* The content layer is the surface the refraction filter warps, so the displacement lands on the card's own pixels and the text stays real DOM. */
.content {
position: relative;
z-index: 1;
padding: var( --space-6, 24px );
color: #f4fbff;
text-shadow: 0 1px 14px rgba( 8, 44, 80, 0.55 );
}
.card[data-tier='refraction'] .content {
filter: url( #glass-refraction );
}
.content h3 {
margin: 0 0 var( --space-2, 8px );
font-size: var( --fs-18, 18px );
letter-spacing: -0.01em;
}
.content p {
margin: 0 0 var( --space-5, 20px );
font-size: var( --fs-14, 14px );
line-height: 1.55;
color: rgba( 238, 250, 255, 0.9 );
}
/* The default CTA only shows when no slot content is provided; the harness slots its own premium button. */
.content .cta {
font: inherit;
font-size: var( --fs-13, 13px );
font-weight: 600;
color: #f4fbff;
background: rgba( 198, 240, 255, 0.22 );
border: 1px solid rgba( 224, 248, 255, 0.6 );
border-radius: var( --radius-pill, 999px );
padding: 9px 18px;
cursor: pointer;
}
.content .cta:hover {
background: rgba( 198, 240, 255, 0.34 );
}
/*
* The specular rim is a moving highlight that reads as light raking across a glass edge.
* Its bright corner follows the pointer through the --mx / --my custom properties the pointer handler writes, so the glass feels lit by the cursor rather than statically shaded.
*/
.rim {
position: absolute;
inset: 0;
z-index: 2;
border-radius: inherit;
pointer-events: none;
background:
radial-gradient(
120px 120px at calc( var( --mx, 0.5 ) * 100% ) calc( var( --my, 0.4 ) * 100% ),
rgba( 245, 253, 255, 0.7 ) 0,
rgba( 198, 240, 255, 0.16 ) 32%,
transparent 60%
),
linear-gradient(
135deg,
rgba( 235, 251, 255, 0.55 ) 0,
transparent 32%,
transparent 68%,
rgba( 150, 214, 246, 0.3 ) 100%
);
mix-blend-mode: screen;
transition: background-position 0.1s linear;
}
/*
* The staging canvas opts in with layoutsubtree so its child content lays out, paints, and stays hit-testable.
* It sits at the back of the card, drawn into a WebGL texture each frame; the GL canvas above it shows the shader output, so this canvas itself is the source, not the visible surface.
*/
.stage {
position: relative;
z-index: 0;
display: block;
width: 100%;
}
/* The capture wrapper is the real, interactive content the staging canvas draws and whose transform is synced back, so it stays aligned and selectable under the shader. */
.capture {
display: block;
}
/*
* The WebGL surface for the shader tier sits directly over the staging canvas and under the rim, masked to the card so the shader reads as the glass itself.
* It carries the composited shader output (the independent water, VHS, and CRT passes in whatever combination is toggled) at full opacity and forwards pointer events to the live content beneath, so the card text and the Add-to-cart button stay real, selectable, focusable DOM.
*/
.gl {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
border-radius: inherit;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
}
.card[data-tier='shader'] .gl {
opacity: 1;
}
.badge {
position: absolute;
z-index: 3;
top: var( --space-3, 12px );
right: var( --space-3, 12px );
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var( --radius-pill, 999px );
background: rgba( 8, 46, 78, 0.62 );
color: #f4fbff;
font-family: var( --font-mono, monospace );
font-size: 11px;
letter-spacing: 0.02em;
backdrop-filter: blur( 6px );
-webkit-backdrop-filter: blur( 6px );
}
.badge .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var( --accent );
}
.badge[data-verdict='Ship'] .dot {
background: #2bb673;
}
.badge[data-verdict='Enhance'] .dot {
background: #e0a32b;
}
.badge[data-verdict='Research'] .dot {
background: #d1495b;
}
/* The displacement filter only needs to exist in the layout, never be seen, so it is parked off-canvas. */
.filter-defs {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.probe {
display: none;
}
.status {
margin-top: var( --space-3, 12px );
font-family: var( --font-mono, monospace );
font-size: 11.5px;
line-height: 1.6;
color: var( --ink-soft, #56565c );
}
.status .tier {
color: var( --accent );
}
`;
/**
* Runs the shared {@link setUp} when the card enters the document, on the first connect and on every reconnect.
*/
connectedCallback(): void {
super.connectedCallback();
this.setUp();
}
/**
* Wires everything {@link tearDown} releases: the OS reduced-motion subscription, the offscreen pause observer, and, on a reconnect, the animation sync.
*
* It runs from `connectedCallback` alone rather than the two entry points the canvas components need, because nothing here waits on the shadow DOM: the first connect leaves the animation sync to the `updated` that follows the first render, while a reconnect re-syncs here since no update fires on a re-append, which is what left a re-parented card frozen with its loop gone.
* The `isSetUp` guard and the connected check keep it from wiring twice or wiring a detached element.
*/
private setUp(): void {
if ( this.isSetUp || ! this.isConnected ) {
return;
}
this.isSetUp = true;
this.reducedMotionQuery = window.matchMedia( '(prefers-reduced-motion: reduce)' );
this.systemReducedMotion = this.reducedMotionQuery.matches;
this.reducedMotionQuery.addEventListener( 'change', this.onReducedMotionChange );
this.intersectionObserver = new IntersectionObserver(
( entries ) => this.onIntersection( entries ),
{ threshold: 0.01 },
);
this.intersectionObserver.observe( this );
if ( this.hasUpdated ) {
this.syncAnimation();
}
}
/**
* Pauses the loop as the card scrolls offscreen and re-syncs the animation when it returns, so an offscreen card costs no frames.
*
* The resume goes through {@link syncAnimation} rather than starting the loop directly, because whether a loop should run at all depends on the active tier and the motion setting, and that decision already lives there.
*
* @param entries - The intersection records for the observed host.
*/
private onIntersection( entries: IntersectionObserverEntry[] ): void {
for ( const entry of entries ) {
this.isVisible = entry.isIntersecting;
if ( this.isVisible ) {
this.syncAnimation();
} else {
this.stopLoop();
}
}
}
/**
* Mirrors a mid-session change of the OS reduced-motion setting into state, so the turbulence, the shader uniforms, and the intensity caps re-resolve without a reload.
*
* @param event - The media query's change event.
*/
private onReducedMotionChange = ( event: MediaQueryListEvent ): void => {
this.systemReducedMotion = event.matches;
};
/**
* Probes the two upgrade tiers once the card has rendered, so each falls back on a per-build basis.
*/
protected firstUpdated(): void {
this.supportsRefraction = this.detectRefraction();
this.supportsShader = this.detectShader();
}
/**
* Announces the active tier and drives the live animation whenever the requested tier or a capability result changes.
*
* @param changed - The reactive properties that changed this update.
*/
protected updated( changed: Map<string, unknown> ): void {
if ( changed.has( 'tier' ) || changed.has( 'supportsRefraction' ) || changed.has( 'supportsShader' ) ) {
this.dispatchEvent(
new CustomEvent<GlassTierChangeDetail>( 'tier-change', {
detail: { requested: this.tier, active: this.activeTier },
bubbles: true,
composed: true,
} ),
);
}
this.syncAnimation();
}
/**
* Tears the card down through the shared {@link tearDown} when it leaves the document.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.tearDown();
}
/**
* Cancels the loop and any pending still frame, releases the WebGL context, and unsubscribes the reduced-motion query and the visibility observer.
*
* It is the mirror of {@link setUp} rather than the body of `disconnectedCallback`, so a removed card leaks nothing and a reconnected one can rebuild exactly what this released; clearing `isSetUp` is what re-arms that rebuild.
*/
private tearDown(): void {
this.isSetUp = false;
this.stopLoop();
this.cancelStillFrame();
this.teardownGl();
this.stageCtx = null;
this.$turbulence = null;
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
this.reducedMotionQuery?.removeEventListener( 'change', this.onReducedMotionChange );
this.reducedMotionQuery = null;
}
/**
* The tier actually rendered, which steps down from the requested tier to one the browser can run.
*
* `shader` drops to `refraction` (or `frosted` when even that is unsupported) without the experimental draw API and WebGL, and `refraction` drops to `frosted` without the displacement filter chain, so this getter is the single place the requested-versus-active split is resolved.
*/
private get activeTier(): GlassTier {
if ( this.tier === 'shader' && ! this.supportsShader ) {
return this.supportsRefraction ? 'refraction' : 'frosted';
}
if ( this.tier === 'refraction' && ! this.supportsRefraction ) {
return 'frosted';
}
return this.tier;
}
/**
* Whether motion should actually be reduced, folding the OS `prefers-reduced-motion` setting and the demo attribute into the one gate every animated path reads.
*
* The system setting always wins; the `reduced-motion` attribute can add the reduction on top but can never turn motion back on for a reader whose OS asks for reduce.
*/
private get motionReduced(): boolean {
return this.reducedMotion || this.systemReducedMotion;
}
/**
* Detects whether the browser accepts the `filter: url(...)` value the refraction tier is built on.
*
* `CSS.supports` is a parse-level probe, so a pass establishes that the engine recognizes a URL filter value, not that it will actually apply the referenced SVG displacement chain to the card.
* Proving application would mean rendering and reading pixels back, so this stays the pragmatic gate; a failing probe still drops those engines to the frosted base, and a wrong pass degrades to the card without the bend rather than a broken one.
*
* @returns Whether the refraction tier can be offered.
*/
private detectRefraction(): boolean {
return typeof CSS !== 'undefined' && CSS.supports( 'filter', 'url(#glass-refraction)' );
}
/**
* Detects whether this build can run the shader tier, which needs both the experimental draw method and a usable WebGL context.
*
* The shared {@link supportsHtmlInCanvas} predicate feature-detects the full experimental path, `drawElementImage` plus `requestPaint`, rather than sniffing the user agent, then this confirms WebGL is reachable so the tier never claims a capability it cannot paint.
*
* @returns Whether the shader tier can render.
*/
private detectShader(): boolean {
return supportsHtmlInCanvas() && this.detectWebgl();
}
/**
* Confirms a WebGL context can be created at all, so the shader tier is only offered where the GPU path is reachable.
*
* @returns Whether WebGL is available.
*/
private detectWebgl(): boolean {
try {
const $probe = document.createElement( 'canvas' );
return !! ( $probe.getContext( 'webgl' ) || $probe.getContext( 'experimental-webgl' ) );
} catch {
return false;
}
}
/**
* Starts or stops the per-frame work to match the active tier, the reduced-motion setting, and the card's visibility.
*
* The refraction tier animates its turbulence, the shader tier drives the WebGL pass, and the frosted tier needs no loop.
* Reduced motion stops the loop outright instead of letting it spin out identical still frames: the refraction filter holds its turbulence as a still, gentle bend on its own, and the shader tier gets one settled frame from {@link renderStillFrame} on each state change rather than a capture per frame. The OS-wins contract is untouched, since the decision reads {@link motionReduced}, where the system setting always beats the demo attribute.
*/
private syncAnimation(): void {
const active = this.activeTier;
if ( active === 'shader' ) {
this.setupStage();
this.setupGl();
} else {
this.teardownGl();
this.stageCtx = null;
}
if ( active === 'refraction' ) {
this.$turbulence = this.renderRoot.querySelector( 'feTurbulence' );
} else {
this.$turbulence = null;
}
const needsLoop = ( active === 'refraction' || active === 'shader' ) && ! this.motionReduced;
if ( needsLoop ) {
this.startLoop();
} else {
this.stopLoop();
this.renderStillFrame();
}
}
/**
* Schedules one settled shader frame while the loop is stopped, so a reduced-motion reader still sees every state change land on the canvas.
*
* Only the shader tier needs it: its image lives on the GL canvas and goes stale the moment a toggle or slider changes, while the refraction tier's still bend is plain DOM plus a filter that renders itself, and the frosted tier has no canvas at all.
* It waits one animation frame rather than drawing synchronously because `drawElementImage` reads the previous frame's paint record, and content that just re-rendered has none yet; the single-shot handle folds a burst of updates into one capture, and the time uniform stays at zero so the surface sits still.
* A capture attempted before that first paint record exists reports failure, so the callback reschedules itself until one lands, the still-frame equivalent of the running loop's resample-next-frame behavior, and it stops the moment a frame draws.
*/
private renderStillFrame(): void {
if ( this.activeTier !== 'shader' || ! this.isConnected || this.stillFrameId !== 0 ) {
return;
}
this.stillFrameId = requestAnimationFrame( () => {
this.stillFrameId = 0;
const captured = this.captureCard();
this.renderGl( 0 );
if ( ! captured ) {
this.renderStillFrame();
}
} );
}
/**
* Cancels a scheduled still-frame render, so teardown never leaves a one-shot callback to fire against a released context.
*/
private cancelStillFrame(): void {
if ( this.stillFrameId !== 0 ) {
cancelAnimationFrame( this.stillFrameId );
this.stillFrameId = 0;
}
}
/**
* Begins the single shared animation loop, recording the start time so elapsed time is stable.
*
* It refuses to start for a detached or offscreen card: an append and remove in the same task still runs the pending first update after disconnection, and a loop started there could never be stopped, since the disconnect that stops it already happened.
*/
private startLoop(): void {
if ( this.rafId !== 0 || ! this.isConnected || ! this.isVisible ) {
return;
}
this.startTime = performance.now();
this.rafId = requestAnimationFrame( this.frame );
}
/**
* Stops the shared animation loop.
*/
private stopLoop(): void {
if ( this.rafId !== 0 ) {
cancelAnimationFrame( this.rafId );
this.rafId = 0;
}
}
/**
* Advances one frame: eases the pointer, animates the refraction turbulence unless motion is reduced, and renders the shader pass when that tier is active.
*
* @param now - The current high-resolution timestamp from `requestAnimationFrame`.
*/
private frame = ( now: number ): void => {
const elapsed = ( now - this.startTime ) / 1000;
// Ease the pointer toward its latest sample so the highlight glides instead of snapping.
this.pointer.x += ( this.pointer.tx - this.pointer.x ) * 0.12;
this.pointer.y += ( this.pointer.ty - this.pointer.y ) * 0.12;
if ( this.$turbulence && ! this.motionReduced ) {
// Slowly wander the turbulence frequency so the warp breathes like a fluid surface rather than sitting static.
const bx = 0.011 + Math.sin( elapsed * 0.5 ) * 0.004;
const by = 0.015 + Math.cos( elapsed * 0.37 ) * 0.004;
this.$turbulence.setAttribute( 'baseFrequency', `${ bx.toFixed( 4 ) } ${ by.toFixed( 4 ) }` );
}
if ( this.activeTier === 'shader' ) {
this.captureCard();
this.renderGl( this.motionReduced ? 0 : elapsed );
}
this.rafId = requestAnimationFrame( this.frame );
};
/**
* Tracks the pointer over the card, writing eased targets for the shader and the CSS custom properties the specular rim reads.
*
* @param event - The pointer move over the scene.
*/
private onPointerMove = ( event: PointerEvent ): void => {
const $scene = this.renderRoot.querySelector( '.scene' );
if ( ! ( $scene instanceof HTMLElement ) ) {
return;
}
const rect = $scene.getBoundingClientRect();
this.pointer.tx = Math.min( 1, Math.max( 0, ( event.clientX - rect.left ) / rect.width ) );
this.pointer.ty = Math.min( 1, Math.max( 0, ( event.clientY - rect.top ) / rect.height ) );
const $card = this.renderRoot.querySelector( '.card' );
if ( $card instanceof HTMLElement ) {
const cardRect = $card.getBoundingClientRect();
const mx = Math.min( 1, Math.max( 0, ( event.clientX - cardRect.left ) / cardRect.width ) );
const my = Math.min( 1, Math.max( 0, ( event.clientY - cardRect.top ) / cardRect.height ) );
$card.style.setProperty( '--mx', mx.toFixed( 3 ) );
$card.style.setProperty( '--my', my.toFixed( 3 ) );
}
};
/**
* Reads a resolved color token off the host so the canvas, which cannot inherit CSS, paints in the page theme.
*
* @param name - The custom property to resolve.
* @param fallback - The literal color used when the token is unset.
* @returns The resolved color as an `[r, g, b]` triple in 0..1.
*/
private readColor( name: string, fallback: string ): [ number, number, number ] {
const value = getComputedStyle( this ).getPropertyValue( name ).trim() || fallback;
return this.parseColor( value );
}
/**
* Parses a hex or `rgb()` color string into a 0..1 RGB triple for a shader uniform.
*
* @param value - The color string, either `#rgb`, `#rrggbb`, or `rgb(r, g, b)`.
* @returns The color as an `[r, g, b]` triple in 0..1.
*/
private parseColor( value: string ): [ number, number, number ] {
if ( value.startsWith( '#' ) ) {
const hex = value.slice( 1 );
const full = hex.length === 3 ? hex.split( '' ).map( ( c ) => c + c ).join( '' ) : hex;
const int = parseInt( full, 16 );
return [ ( ( int >> 16 ) & 255 ) / 255, ( ( int >> 8 ) & 255 ) / 255, ( int & 255 ) / 255 ];
}
const match = value.match( /rgba?\(([^)]+)\)/ );
if ( match ) {
const parts = match[ 1 ].split( ',' ).map( ( p ) => parseFloat( p ) );
return [ ( parts[ 0 ] || 0 ) / 255, ( parts[ 1 ] || 0 ) / 255, ( parts[ 2 ] || 0 ) / 255 ];
}
return [ 0.13, 0.34, 0.9 ];
}
/**
* Compiles a shader of the given type, throwing with the driver log if it fails so a setup error is never silent.
*
* @param gl - The live WebGL context.
* @param type - The shader stage, vertex or fragment.
* @param source - The GLSL source.
* @returns The compiled shader.
*/
private compileShader( gl: WebGLRenderingContext, type: number, source: string ): WebGLShader {
const shader = gl.createShader( type );
if ( ! shader ) {
throw new Error( 'glass-card: could not allocate a shader' );
}
gl.shaderSource( shader, source );
gl.compileShader( shader );
if ( ! gl.getShaderParameter( shader, gl.COMPILE_STATUS ) ) {
const log = gl.getShaderInfoLog( shader );
gl.deleteShader( shader );
throw new Error( `glass-card: shader compile failed: ${ log ?? 'unknown' }` );
}
return shader;
}
/**
* Acquires and caches the staging canvas 2D context once the shader tier is active, so each frame can draw the live card into it through the experimental `drawElementImage` method.
*
* The context is the source side of the pipeline: the staging canvas opts in with `layoutsubtree`, its laid-out child is the real card content, and capturing that child each frame is what feeds the GPU texture the shader refracts.
* It is acquired with `alpha: true` so the captured card keeps its transparent regions, which is what lets the field and the glass panel show through the shader output instead of compositing onto an opaque black box.
*/
private setupStage(): void {
if ( this.stageCtx || ! this.$stageCanvas ) {
return;
}
this.stageCtx = this.$stageCanvas.getContext( '2d', { alpha: true } );
}
/**
* Captures the live card content into the staging canvas through `drawElementImage` and syncs the returned alignment matrix back to the capture wrapper.
*
* The staging bitmap is kept 1:1 with the card's CSS pixels because `layoutsubtree` lays the captured child out to the bitmap size, so multiplying by the device pixel ratio here would lay the content out oversized and break alignment; the device-pixel-ratio crispness is owned by the WebGL output canvas instead, which upscales this texture.
* Writing the returned matrix back to the wrapper's `transform` keeps that real DOM aligned under its drawn pixels so it stays selectable, focusable, and clickable through the stacked shader.
* The draw can throw "No cached paint record" right after a tier change before the child's paint records exist, so it is swallowed and reported: the running loop resamples on its next frame anyway, and the reduced-motion still frame reads the result to reschedule itself.
*
* @returns Whether a capture actually landed in the staging bitmap.
*/
private captureCard(): boolean {
const ctx = this.stageCtx;
const $canvas = this.$stageCanvas;
const $capture = this.$capture;
if ( ! ctx || ! $canvas || ! $capture ) {
return false;
}
const width = Math.max( 1, Math.round( $capture.clientWidth ) );
const height = Math.max( 1, Math.round( $capture.clientHeight ) );
if ( $canvas.width !== width || $canvas.height !== height ) {
$canvas.width = width;
$canvas.height = height;
}
ctx.clearRect( 0, 0, width, height );
try {
const alignment = ctx.drawElementImage( $capture, 0, 0 );
$capture.style.transform = alignment.toString();
} catch {
// The next attempt will resample once the child's paint records are built.
return false;
}
return true;
}
/**
* Creates the WebGL context, compiles the composited retro-glass program, and uploads the full-screen triangle, all once per shader-tier activation.
*
* A reconnect is the one activation that can hand back a lost context, since teardown loses it on purpose while the canvas element survives the re-append, so that case restores the context and retries instead of misreading the dead context as a driver rejection.
*
* The fragment shader is the part CSS cannot reach: it composes three independent passes over the captured card texture, each gated and scaled by its own amount uniform so it fades smoothly to nothing at zero. The water pass (`uWaterAmt`) refracts the card through an animated height field with per-channel dispersion and lays caustics and a pointer glint over it, the VHS pass (`uVhsAmt`) adds tape wobble, vertical jitter, chroma bleed, a rolling noise band, grain, and slight desaturation, and the CRT pass (`uCrtAmt`) adds barrel distortion, scanlines, phosphor flicker, a phosphor tint, and a vignette. Every combination is valid, and with all three amounts at zero the pipeline shows the plain captured card with no visible effect.
*/
private setupGl(): void {
if ( this.gl || ! this.$glCanvas ) {
return;
}
const gl = this.$glCanvas.getContext( 'webgl', { alpha: true, premultipliedAlpha: false, antialias: true } );
if ( ! gl ) {
return;
}
// A reconnected card gets back the same context teardown deliberately lost, because the canvas survives the re-append; every GL call would no-op on it, and the program setup below would misread that as a driver rejection and drop the tier. Ask for a restore and re-run the sync when it lands; the restore is asynchronous, and the guard keeps a card torn down in the meantime from re-acquiring GPU state.
if ( gl.isContextLost() ) {
this.$glCanvas.addEventListener(
'webglcontextrestored',
() => {
if ( this.isSetUp ) {
this.syncAnimation();
}
},
{ once: true },
);
gl.getExtension( 'WEBGL_lose_context' )?.restoreContext();
return;
}
this.gl = gl;
// Flip V so the canvas texture, whose origin is top-left, samples the right way up under the shader.
const vertexSource = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
vUv.y = 1.0 - vUv.y;
gl_Position = vec4( position, 0.0, 1.0 );
}
`;
const fragmentSource = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uTexture;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uPointer;
uniform vec3 uAccent;
uniform float uCalm;
uniform float uStrength;
uniform float uWaterAmt;
uniform float uVhsAmt;
uniform float uCrtAmt;
// Hash and value noise: the raw material for the water surface and the grain, kept cheap so the loop holds 60fps.
float hash( vec2 p ) {
return fract( sin( dot( p, vec2( 127.1, 311.7 ) ) ) * 43758.5453123 );
}
float noise( vec2 p ) {
vec2 i = floor( p );
vec2 f = fract( p );
vec2 u = f * f * ( 3.0 - 2.0 * f );
return mix(
mix( hash( i + vec2( 0.0, 0.0 ) ), hash( i + vec2( 1.0, 0.0 ) ), u.x ),
mix( hash( i + vec2( 0.0, 1.0 ) ), hash( i + vec2( 1.0, 1.0 ) ), u.x ),
u.y
);
}
float fbm( vec2 p ) {
float total = 0.0;
float amplitude = 0.5;
for ( int i = 0; i < 5; i++ ) {
total += noise( p ) * amplitude;
p *= 2.0;
amplitude *= 0.5;
}
return total;
}
void main() {
float t = uTime;
vec2 uv = vUv;
// --- Water refraction: an independent pass, scaled by its own amount ---
// A drifting height field gives the bend direction; multiplying the whole offset by uWaterAmt means amount zero is no displacement at all, identical to the water toggle being off.
vec2 flow = vec2( t * 0.06 * ( 1.0 - uCalm ), t * 0.04 * ( 1.0 - uCalm ) );
float e = 0.0025;
float hx = fbm( ( uv + vec2( e, 0.0 ) ) * 5.0 + flow ) - fbm( ( uv - vec2( e, 0.0 ) ) * 5.0 + flow );
float hy = fbm( ( uv + vec2( 0.0, e ) ) * 5.0 + flow ) - fbm( ( uv - vec2( 0.0, e ) ) * 5.0 + flow );
vec2 normal = vec2( hx, hy ) * 60.0;
// The water displacement, gated by its amount so it vanishes cleanly at zero; the per-channel index of refraction gives true chromatic dispersion on the bent text, the give-away that this is a GPU pass and not a single CSS displacement.
vec2 warp = normal * uStrength * uWaterAmt;
vec2 ruv = uv + warp;
vec3 col;
col.r = texture2D( uTexture, uv + warp * 1.18 ).r;
col.g = texture2D( uTexture, ruv ).g;
col.b = texture2D( uTexture, uv + warp * 0.82 ).b;
// Carry the captured card's own alpha, warped with the green channel so the displaced content keeps its silhouette; this is what keeps the transparent regions transparent so the glass panel and the underwater field show through instead of a filled box.
float alpha = texture2D( uTexture, ruv ).a;
// A rippling caustic net and a pointer-tracked glint layered onto the refracted card, both scaled by the water amount so they fade out with the pass and leave the plain card at zero.
float drift = t * 0.08 * ( 1.0 - uCalm );
float ca = fbm( uv * 6.0 + vec2( drift, drift * 0.6 ) );
float cb = fbm( uv * 9.0 - vec2( drift * 0.7, drift ) + 11.0 );
float caustic = pow( max( 0.0, ca * cb * 2.2 ), 2.0 );
col += vec3( 0.4, 0.6, 0.7 ) * caustic * 0.35 * uWaterAmt;
float glint = pow( max( 0.0, 1.0 - distance( uv, uPointer ) * 1.4 ), 6.0 );
col += uAccent * glint * 0.25 * uWaterAmt;
// Let the water's own highlights, the caustic net and the pointer glint, lift the alpha so they read over the translucent glass and not only where the card content is already opaque, while the bare card at water-amount zero keeps exactly its captured alpha.
alpha = clamp( alpha + ( caustic * 0.35 + glint * 0.25 ) * uWaterAmt, 0.0, 1.0 );
// --- VHS pass: an independent pass scaled by its own amount, running with or without water ---
float vhsAmt = uVhsAmt;
if ( vhsAmt > 0.001 ) {
vec2 vuv = ruv;
// Tracking wobble: two sine bands wander the horizontal sample so the image weaves like worn tape, damped by calm.
float motion = 1.0 - uCalm;
float wobble = sin( vuv.y * 40.0 + t * 3.0 ) * 0.003;
wobble += sin( vuv.y * 120.0 - t * 1.7 ) * 0.0015;
vuv.x += wobble * motion;
// Occasional vertical jitter, a dropped-frame kick.
float jitter = step( 0.97, hash( vec2( floor( t * 4.0 ), 0.0 ) ) );
vuv.y += jitter * 0.012 * sin( t * 50.0 ) * motion;
// Chroma bleed: split the channels sideways for the smeared-color look.
vec3 vcol;
vcol.r = texture2D( uTexture, vuv + vec2( 0.004, 0.0 ) ).r;
vcol.g = texture2D( uTexture, vuv ).g;
vcol.b = texture2D( uTexture, vuv - vec2( 0.004, 0.0 ) ).b;
// A rolling noise band sweeping up the frame, frozen in place when calm.
float bandY = fract( t * 0.1 * motion );
float band = smoothstep( 0.0, 0.03, abs( vuv.y - bandY ) );
vcol = mix( vec3( hash( vuv + t ) ), vcol, band );
// Slight desaturation, then a touch of grain.
float luma = dot( vcol, vec3( 0.299, 0.587, 0.114 ) );
vcol = mix( vec3( luma ), vcol, 0.85 );
vcol += ( hash( uv * uResolution + t ) - 0.5 ) * 0.06 * motion;
col = mix( col, vcol, vhsAmt );
}
// --- CRT pass: an independent pass composed on top, including over water and VHS when they are on ---
float crtAmt = uCrtAmt;
if ( crtAmt > 0.001 ) {
// Barrel distortion bows the whole composited image outward like a curved tube; resample through the water warp (itself gated by the water amount, so CRT alone curves a flat image with no bend).
vec2 dc = uv - 0.5;
float d2 = dot( dc, dc );
vec2 cuv = uv + dc * d2 * 0.18 * crtAmt;
cuv += warp;
vec3 ccol;
ccol.r = texture2D( uTexture, cuv + warp * 1.18 ).r;
ccol.g = texture2D( uTexture, cuv ).g;
ccol.b = texture2D( uTexture, cuv + warp * 0.82 ).b;
// Blend the barrel-sampled image in so the curve applies to whatever the lower passes produced.
col = mix( col, mix( col, ccol, 0.6 ), crtAmt );
// Scanlines, a phosphor flicker (held still when calm), a warm phosphor tint, and a vignette.
float scan = sin( uv.y * uResolution.y * 1.4 ) * 0.08 * crtAmt;
col -= scan;
float flicker = 0.97 + 0.03 * sin( t * 9.0 ) * ( 1.0 - uCalm );
col *= mix( 1.0, flicker, crtAmt );
col.r *= 1.0 + 0.06 * crtAmt;
col.g *= 1.0 + 0.02 * crtAmt;
float vig = 1.0 - d2 * 1.6 * crtAmt;
col *= vig;
}
// A soft inner rim so the glass body reads as a panel, not a full-bleed wash, and the alpha fades the very edge into the card border.
float edge = smoothstep( 0.0, 0.06, uv.x ) * smoothstep( 1.0, 0.94, uv.x )
* smoothstep( 0.0, 0.05, uv.y ) * smoothstep( 1.0, 0.95, uv.y );
// Fold the soft inner rim onto the captured alpha so the panel edge fades into the card border while the transparent regions stay transparent.
alpha *= 0.85 + 0.15 * edge;
gl_FragColor = vec4( col, alpha );
}
`;
try {
const program = gl.createProgram();
if ( ! program ) {
throw new Error( 'glass-card: could not allocate the shader program' );
}
gl.attachShader( program, this.compileShader( gl, gl.VERTEX_SHADER, vertexSource ) );
gl.attachShader( program, this.compileShader( gl, gl.FRAGMENT_SHADER, fragmentSource ) );
gl.linkProgram( program );
if ( ! gl.getProgramParameter( program, gl.LINK_STATUS ) ) {
throw new Error( `glass-card: program link failed: ${ gl.getProgramInfoLog( program ) ?? 'unknown' }` );
}
gl.useProgram( program );
const buffer = gl.createBuffer();
gl.bindBuffer( gl.ARRAY_BUFFER, buffer );
gl.bufferData( gl.ARRAY_BUFFER, new Float32Array( [ -1, -1, 3, -1, -1, 3 ] ), gl.STATIC_DRAW );
const position = gl.getAttribLocation( program, 'position' );
gl.enableVertexAttribArray( position );
gl.vertexAttribPointer( position, 2, gl.FLOAT, false, 0, 0 );
// The texture the captured card is uploaded into each frame, clamped and linearly filtered so the refracted text stays smooth.
this.glTexture = gl.createTexture();
gl.bindTexture( gl.TEXTURE_2D, this.glTexture );
gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE );
gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE );
gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR );
gl.texParameteri( gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR );
// Clear to a fully transparent buffer and blend with straight (non-premultiplied) alpha, so the card's translucent glass panel and the underwater field show through the shader output instead of compositing onto an opaque black box.
gl.enable( gl.BLEND );
gl.blendFunc( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA );
gl.clearColor( 0, 0, 0, 0 );
this.glUniforms = {
texture: gl.getUniformLocation( program, 'uTexture' ),
time: gl.getUniformLocation( program, 'uTime' ),
resolution: gl.getUniformLocation( program, 'uResolution' ),
pointer: gl.getUniformLocation( program, 'uPointer' ),
accent: gl.getUniformLocation( program, 'uAccent' ),
calm: gl.getUniformLocation( program, 'uCalm' ),
strength: gl.getUniformLocation( program, 'uStrength' ),
waterAmt: gl.getUniformLocation( program, 'uWaterAmt' ),
vhsAmt: gl.getUniformLocation( program, 'uVhsAmt' ),
crtAmt: gl.getUniformLocation( program, 'uCrtAmt' ),
};
} catch {
// A driver that rejects the program drops the tier rather than showing a black canvas; the status line already names the fallback.
this.teardownGl();
this.supportsShader = false;
}
}
/**
* Renders one shader frame: sizes the drawing buffer to the device pixel ratio, uploads the freshly captured card as the source texture, and feeds the live time, pointer, accent, and the three per-effect amounts as uniforms.
*
* Each effect amount is pre-multiplied by its toggle on the CPU side, so a disabled effect uploads exactly zero and the shader skips it, which is what makes amount zero and the toggle off produce an identical, effect-free image.
*
* @param elapsed - The animation time in seconds, held at zero when motion is reduced so the surface sits still.
*/
private renderGl( elapsed: number ): void {
const gl = this.gl;
const uniforms = this.glUniforms;
if ( ! gl || ! uniforms || ! this.$glCanvas || ! this.$stageCanvas || ! this.glTexture ) {
return;
}
const dpr = Math.min( window.devicePixelRatio || 1, 2 );
const width = Math.max( 1, Math.round( this.$glCanvas.clientWidth * dpr ) );
const height = Math.max( 1, Math.round( this.$glCanvas.clientHeight * dpr ) );
if ( this.$glCanvas.width !== width || this.$glCanvas.height !== height ) {
this.$glCanvas.width = width;
this.$glCanvas.height = height;
}
gl.viewport( 0, 0, width, height );
// Wipe the buffer to transparent before each pass so where the captured card and the shader leave alpha low, the field and glass panel beneath show through rather than the previous frame or an opaque black.
gl.clear( gl.COLOR_BUFFER_BIT );
// Upload the captured card; a zero-sized staging bitmap before the first paint would make texImage2D throw, so guard it.
if ( this.$stageCanvas.width > 0 && this.$stageCanvas.height > 0 ) {
gl.bindTexture( gl.TEXTURE_2D, this.glTexture );
gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.$stageCanvas );
}
const accent = this.readColor( '--accent', '#1f9fd1' );
// Map the effective water amount onto the shader refraction strength, kept readable at the low end and dramatic at the top: the shader tier has no separate distortion control, so the Water intensity slider sets both the bend character here and the overall water contribution through uWaterAmt.
const strength = 0.008 + this.effectiveAmount( this.water, this.waterAmt ) * 0.06;
gl.activeTexture( gl.TEXTURE0 );
gl.bindTexture( gl.TEXTURE_2D, this.glTexture );
gl.uniform1i( uniforms.texture, 0 );
gl.uniform1f( uniforms.time, elapsed );
gl.uniform2f( uniforms.resolution, width, height );
gl.uniform2f( uniforms.pointer, this.pointer.x, this.pointer.y );
gl.uniform3fv( uniforms.accent, accent );
gl.uniform1f( uniforms.calm, this.motionReduced ? 1 : 0 );
gl.uniform1f( uniforms.strength, strength );
gl.uniform1f( uniforms.waterAmt, this.effectiveAmount( this.water, this.waterAmt ) );
gl.uniform1f( uniforms.vhsAmt, this.effectiveAmount( this.vhs, this.vhsAmt ) );
gl.uniform1f( uniforms.crtAmt, this.effectiveAmount( this.crt, this.crtAmt ) );
gl.drawArrays( gl.TRIANGLES, 0, 3 );
}
/**
* Releases the WebGL context, its texture, and its cached program so a tier change does not leak GPU resources.
*/
private teardownGl(): void {
if ( ! this.gl ) {
return;
}
if ( this.glTexture ) {
this.gl.deleteTexture( this.glTexture );
this.glTexture = null;
}
const lose = this.gl.getExtension( 'WEBGL_lose_context' );
if ( lose ) {
lose.loseContext();
}
this.gl = null;
this.glUniforms = null;
}
/**
* A one-line, honest account of what the reader is looking at, so the active tier and any fallback are never hidden.
*
* @returns The status line for the current request.
*/
private statusLine(): string {
if ( this.tier === 'shader' && this.activeTier !== 'shader' ) {
return 'Shader over live HTML needs the chrome://flags/#canvas-draw-element build, so this is the SVG refraction fallback, not the GPU pass.';
}
if ( this.tier === 'refraction' && this.activeTier !== 'refraction' ) {
return 'SVG refraction: filter chain unsupported, showing the frosted card.';
}
if ( this.activeTier === 'shader' ) {
const layers = [ this.water ? 'water' : null, this.vhs ? 'VHS' : null, this.crt ? 'CRT' : null ].filter( ( name ) => name !== null );
const stack = layers.length > 0 ? layers.join( ' plus ' ) : 'no effect';
return `Shader over live HTML: drawElementImage captures the live card into a texture and a WebGL pass composes it (${ stack }), beyond any CSS filter.`;
}
if ( this.activeTier === 'refraction' ) {
return 'SVG refraction: animated feDisplacementMap warping the card content, which stays selectable.';
}
return 'Frosted glass: backdrop-filter blur, the tier that ships today.';
}
/**
* The distortion the SVG refraction surface should actually apply, clamped to 0..1 and capped while motion is reduced so the warp can never push the text past legibility.
*
* It is the source the refraction displacement scale reads, so a reader on reduced motion gets a calm, readable surface no matter where the slider sits. The shader tier does not read it: its water bend comes from `waterAmt` instead, so the frontier tier needs no separate distortion control.
*
* @returns The effective distortion in 0..1.
*/
private effectiveDistortion(): number {
const clamped = Math.min( 1, Math.max( 0, this.distortion ) );
return this.motionReduced ? Math.min( clamped, 0.35 ) : clamped;
}
/**
* The amount a single shader-tier effect should actually contribute, folding its toggle, a 0..1 clamp, and the reduced-motion cap into the one value its pass uniform reads.
*
* A disabled effect returns exactly zero so the shader skips it, which is what makes amount zero and the toggle off produce an identical, effect-free image; an enabled effect is clamped and then capped while motion is reduced so a reader on reduced motion keeps a calm, readable card no matter where the slider sits.
*
* @param enabled - Whether the effect's toggle is on.
* @param amount - The effect's raw 0..1 amount from its slider.
* @returns The effective amount in 0..1, zero when the effect is off.
*/
private effectiveAmount( enabled: boolean, amount: number ): number {
if ( ! enabled ) {
return 0;
}
const clamped = Math.min( 1, Math.max( 0, amount ) );
return this.motionReduced ? Math.min( clamped, 0.5 ) : clamped;
}
/**
* The frosted blur radius in pixels, mapping the clamped 0..1 `frost` control onto a range that stays readable at the low end and reads as a deep frost at the top.
*
* The mid default lands on the card's classic sixteen-pixel look, so a card that never touches the control looks exactly as it always has, and only the frosted tier reads this since the distorting tiers keep their own fixed base blur.
*
* @returns The effective frosted blur radius in CSS pixels.
*/
private effectiveBlur(): number {
const clamped = Math.min( 1, Math.max( 0, this.frost ) );
return 2 + clamped * 28;
}
/**
* The inline SVG filter that bends the card content, with a three-pass RGB split for chromatic aberration and the displacement map the animated turbulence drives.
*
* Feeding it through `filter:` on the card's own content (not `backdrop-filter: url()`, which is Chromium-only) keeps the warp cross-engine and keeps the warped text real, selectable DOM, while the frame loop rewrites the turbulence frequency so the warp moves like liquid.
*
* @returns The SVG `<filter>` definition referenced by the refraction tier.
*/
private refractionFilter() {
// Map the 0..1 distortion onto a displacement scale that stays readable at the low default and turns dramatic at the top, while the floor keeps a touch of water even at zero.
const scale = ( 6 + this.effectiveDistortion() * 64 ).toFixed( 1 );
return svg`
<filter id="glass-refraction" x="-25%" y="-25%" width="150%" height="150%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.016" numOctaves="3" seed="7" result="noise" />
<feGaussianBlur in="noise" stdDeviation="1.1" result="softNoise" />
<feDisplacementMap in="SourceGraphic" in2="softNoise" scale=${ scale } xChannelSelector="R" yChannelSelector="G" result="warp" />
<feColorMatrix in="warp" type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="r" />
<feColorMatrix in="warp" type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="g" />
<feColorMatrix in="warp" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="b" />
<feOffset in="r" dx="1.6" dy="0" result="rShift" />
<feOffset in="b" dx="-1.6" dy="0" result="bShift" />
<feBlend in="rShift" in2="g" mode="screen" result="rg" />
<feBlend in="rg" in2="bShift" mode="screen" />
</filter>
`;
}
/**
* Types `addEventListener( 'tier-change', ... )` so the listener receives a `CustomEvent<GlassTierChangeDetail>` with no cast.
* Falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof GlassCardEventMap>(
type: K,
listener: ( this: GlassCard, ev: GlassCardEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the `.content` block holding the live, selectable slot, the real DOM the glass sits over on every tier.
*
* It is a single helper so the same slot can be placed either as a normal card child (frosted and refraction) or as the laid-out child the staging canvas captures (shader), without the slot ever existing twice in one render.
*/
private renderContent() {
return html`
<div class="content">
<slot>
<h3>Frosted by default</h3>
<p>Real, selectable text behind the glass, with a working control underneath it.</p>
<button class="cta" type="button">Configure</button>
</slot>
</div>
`;
}
/**
* Renders the glass scene, the live content behind it, the WebGL shader surface, the verdict badge, and the honest status line.
*
* On the shader tier the content moves inside a `layoutsubtree` staging canvas wrapped in `.capture`, so `drawElementImage` can sample it into the GPU texture while the stacked, pointer-transparent GL canvas shows the composited shader over it; on the other tiers the same content is a normal card child, since a canvas hides its children on every canvas-supporting browser.
* The slotted content stays the same real DOM nodes in both placements, so the card is a live surface, not a screenshot of one.
* The staging canvas keeps `aria-hidden="false"` because that live content, including its focusable button, is the real surface assistive tech should reach, while the stacked GL canvas is purely visual and stays hidden.
*/
render() {
const active = this.activeTier;
const verdict = this.verdicts[ this.tier ];
// The frosted tier is a pure blur, so its one live control is the blur radius; the distorting tiers leave this unset and keep the fixed base blur from the stylesheet.
const cardStyle = active === 'frosted' ? `--card-blur: ${ this.effectiveBlur().toFixed( 1 ) }px` : nothing;
return html`
<div class="scene" @pointermove=${ this.onPointerMove }>
<div class="bubbles" aria-hidden="true">
<span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span>
</div>
<div class="card" data-tier=${ active } style=${ cardStyle }>
${ active === 'shader'
? html`
<canvas class="stage" layoutsubtree aria-hidden="false">
<div class="capture">${ this.renderContent() }</div>
</canvas>
<canvas class="gl" aria-hidden="true"></canvas>
`
: this.renderContent() }
<span class="rim"></span>
<span class="badge" data-verdict=${ verdict }>
<span class="dot"></span>
${ this.tier } - ${ verdict }
</span>
</div>
${ active === 'refraction'
? html`<svg class="filter-defs" aria-hidden="true">${ this.refractionFilter() }</svg>`
: nothing }
</div>
<p class="status">Active tier: <span class="tier">${ active }</span>. ${ this.statusLine() }</p>
`;
}
}The next two are a pair, because the only way to prove a form on a 3D screen is real is to stand the plain form next to it. This first one is the control: a working product-configuration form with a name field, an email field, a finish select, and a submit, with native validation and a local confirmation, no server and nothing experimental. Submit it with the keyboard, or leave a field blank to see the browser’s own validation. It ships everywhere. It lands at Ship, the plain-DOM baseline everything else is measured against.
import { LitElement, html, css } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import type { ConfiguratorFormSubmitDetail, ConfiguratorFormValues } from './types';
/**
* A small, fully working product-configuration form built from plain DOM controls.
*
* It is the deliberately ordinary baseline for the HTML-in-Canvas piece, a name field, an email field, a finish select, and a submit, with native constraint validation and a local confirmation, no server and no network.
* Everything a user operates is a real `<input>`, `<select>`, or `<button>`, so focus, tab order, autofill, validation, and submission are the browser's, not redrawn by hand.
* It renders only in the flat control demo; the 3D-room demo builds its own on-monitor form from the same kind of plain DOM controls, so this card is the baseline the drawn screen is judged against.
*
* @element configurator-form
* @fires form-submit - Fired after native validation passes, carrying a `ConfiguratorFormSubmitDetail` with the entered values.
* @cssprop [--configurator-accent=#2257e6] - The accent used for focus rings and the submit button, shared with the configurator.
*/
@customElement( 'configurator-form' )
export class ConfiguratorForm extends LitElement {
/**
* The heading shown above the fields, naming what the form configures.
*
* It is a property so a consuming demo can retitle the card without forking the component.
*/
@property( { type: String } )
heading = 'Configure your build';
/**
* The submitted values, held after a successful submit so the confirmation can read them back.
*
* It is `null` until the first valid submit, which is also the flag the template uses to switch from the form to the confirmation.
*/
@state()
private submitted: ConfiguratorFormValues | null = null;
/**
* The form element inside the shadow root, resolved by Lit and used to read values and trigger native validation.
*/
@query( 'form' )
private $form!: HTMLFormElement;
/**
* Styles for the component, scoped to its shadow root.
*
* Colors come from the design tokens with literal fallbacks, so the form looks right inside the sandboxed demo iframe where the token sheet may not have loaded.
*/
static styles = css`
:host {
display: block;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
}
.card {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 340px;
padding: 20px;
background: var( --surface, #fff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-md, 11px );
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
}
.lbl {
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var( --ink-faint, #71717b );
}
input,
select {
font-family: var( --font-sans, sans-serif );
font-size: 14px;
color: var( --ink, #17171a );
background: var( --bg, #fcfcfb );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-sm, 7px );
padding: 9px 11px;
}
input:focus-visible,
select:focus-visible {
outline: 2px solid var( --configurator-accent, var( --accent, #2257e6 ) );
outline-offset: 1px;
}
button {
margin-top: 2px;
padding: 11px 16px;
border: none;
border-radius: var( --radius, 8px );
background: var( --configurator-accent, var( --accent, #2257e6 ) );
color: var( --accent-ink, #fff );
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var( --ink, #17171a );
outline-offset: 2px;
}
.confirm {
display: flex;
flex-direction: column;
gap: 8px;
}
.confirm h3 {
color: var( --configurator-accent, var( --accent, #2257e6 ) );
}
.confirm dl {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 13px;
}
.confirm dt {
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var( --ink-faint, #71717b );
align-self: center;
}
.confirm dd {
margin: 0;
color: var( --ink-soft, #56565c );
}
.again {
align-self: flex-start;
margin-top: 4px;
padding: 7px 12px;
background: transparent;
color: var( --configurator-accent, var( --accent, #2257e6 ) );
border: 1px solid var( --line, #e7e6e2 );
}
`;
/**
* Validates and submits the form, firing `form-submit` and showing the local confirmation.
*
* The native `submit` event already gates on constraint validation, so reaching this handler means the fields are well-formed.
* It calls `preventDefault` because there is no server, so the confirmation is rendered in place, and the typed event lets a parent react to the same submit.
*
* @param event - The form's native submit event.
*/
private onSubmit = ( event: SubmitEvent ): void => {
event.preventDefault();
const data = new FormData( this.$form );
const values: ConfiguratorFormValues = {
name: String( data.get( 'name' ) ?? '' ),
email: String( data.get( 'email' ) ?? '' ),
finish: String( data.get( 'finish' ) ?? '' ),
};
this.submitted = values;
const detail: ConfiguratorFormSubmitDetail = { values };
this.dispatchEvent( new CustomEvent( 'form-submit', { detail, bubbles: true, composed: true } ) );
};
/**
* Clears the confirmation and returns to the empty form, so the demo can be run again without a reload.
*/
private reset = (): void => {
this.submitted = null;
};
/**
* Renders the form, or the confirmation once a valid submit has happened.
*/
render() {
if ( this.submitted ) {
return html`
<div class="card confirm">
<h3>Configuration saved</h3>
<dl>
<dt>Name</dt>
<dd>${ this.submitted.name }</dd>
<dt>Email</dt>
<dd>${ this.submitted.email }</dd>
<dt>Finish</dt>
<dd>${ this.submitted.finish }</dd>
</dl>
<button type="button" class="again" @click=${ this.reset }>Configure another</button>
</div>
`;
}
return html`
<form class="card" @submit=${ this.onSubmit }>
<h3>${ this.heading }</h3>
<label>
<span class="lbl">Name</span>
<input name="name" type="text" required autocomplete="name" placeholder="Studio bench" />
</label>
<label>
<span class="lbl">Email</span>
<input name="email" type="email" required autocomplete="email" placeholder="you@example.com" />
</label>
<label>
<span class="lbl">Finish</span>
<select name="finish">
<option value="Matte black">Matte black</option>
<option value="Brushed steel">Brushed steel</option>
<option value="Walnut">Walnut</option>
</select>
</label>
<button type="submit">Save configuration</button>
</form>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'configurator-form': ConfiguratorForm;
}
}Now the spectacle. This is a first-person 3D office, a Three.js scene with live HTML drawn onto the monitor on the back-wall desk. Drag to look and move with WASD or the arrow keys; the room follows the page theme, a bright white studio in light mode and a dim, warmer office in dark.
On a flagged Chromium build the monitor is our real sign-up form captured into a texture through the experimental path and redrawn every frame, so the screen is live HTML rather than a picture of it. The browser doesn’t hit-test that surface the way it hit-tests a normal page, so the room forwards interaction through a bridge it writes by hand. A ray from the crosshair finds the spot on the screen, the room focuses the field under it, and the browser then delivers your keystrokes to that focused input and runs its own validation and submit, its own confirmation, the way the flat form does. With the flag off, the same navigable room renders the form as a static texture with an in-scene note pointing at the flag, so it never goes blank. That hand-written bridge is why this is a research spectacle rather than a shipping pattern, even though the form, the keystrokes, and the native submit under it are all real. The on-monitor form is the offscreen capture source, so it’s intentionally hidden from the accessibility tree and kept out of the tab order, while the room itself stays navigable. One ships today and one is a flagged prototype. It lands at Research, impressive but not production UI.
import { LitElement, html, css } from 'lit';
import { supportsHtmlInCanvas } from '@demos/supports-html-in-canvas';
import { customElement, property, state, query } from 'lit/decorators.js';
import * as THREE from 'three';
import type { RoomControlMode, RoomFormValues, RoomPalette, RoomTextureMode, RoomTheme } from './types';
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here with the same shape the shared configurator uses.
* The room only calls it on the live path, after feature-detecting it; on every other build the monitor falls back to a static painted texture.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface HTMLCanvasElement {
/**
* Opts a staging canvas in to the experimental model, so its direct children lay out and become drawable.
*
* Reflects the `layoutsubtree` HTML attribute, and is absent from `lib.dom.d.ts` because the feature is still flag-gated.
*/
layoutSubtree: boolean;
/**
* Forces a `paint` event on the next frame for a `layoutsubtree` canvas, the way `requestAnimationFrame` schedules a draw.
*/
requestPaint(): void;
/**
* The handler the experimental model calls when a `layoutsubtree` canvas needs to recapture its DOM children into the bitmap.
*/
onpaint: ( ( this: HTMLCanvasElement, event: Event ) => void ) | null;
}
}
/**
* The size of one wall plane, used to lay out the room and clamp the camera.
*/
const ROOM_W = 10;
const ROOM_H = 3.5;
const ROOM_D = 8;
const HALF_W = ROOM_W / 2;
const HALF_D = ROOM_D / 2;
/**
* The movement keys the room listens for, used to gate which key codes drive walking versus type into the monitor form.
*/
const MOVEMENT_KEYS = new Set( [ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD' ] );
/**
* The two scene palettes the room renders, one per page theme, so the studio and its lighting follow the rest of the page.
*
* `light` is the original bright white office, evenly lit so the monitor reads from any angle.
* `dark` keeps the same room legible but dims it: the walls, floor, and ceiling drop to lifted dark neutrals (kept well above the page's near-black tokens so the monitor still pops rather than vanishing into the wall), and the lighting is dimmer and warmer so the scene reads as an evening office instead of a muddy black box.
*/
const ROOM_PALETTES: Record<RoomTheme, RoomPalette> = {
light: {
background: 0xaeb4c2,
floor: 0xe4e2dd,
rug: 0xd2dae6,
ceiling: 0xf6f6f4,
wall: 0xf1f1ee,
baseboard: 0xd8d8d2,
desk: 0xcdb79a,
ambient: 2.1,
hemisphere: 1.1,
hemisphereSky: 0xffffff,
hemisphereGround: 0xdadce2,
fillColor: 0xfff6ec,
fillIntensity: 0.7,
},
dark: {
background: 0x1a1b22,
floor: 0x34343d,
rug: 0x3a4052,
ceiling: 0x26262e,
wall: 0x3c3c47,
baseboard: 0x2c2c34,
desk: 0x5a4d40,
ambient: 1.5,
hemisphere: 0.95,
hemisphereSky: 0x4a4e60,
hemisphereGround: 0x1c1b22,
fillColor: 0xffe6c4,
fillIntensity: 0.9,
},
};
/**
* A pairing of a `layoutsubtree` staging canvas with the regular mirror canvas its pixels are copied into each frame.
*
* Three.js's `CanvasTexture` upload path stalls when handed a `layoutsubtree` canvas directly, so the mirror decouples the two: the experimental paint writes the staging bitmap, the loop copies it into the mirror, and Three.js re-uploads the mirror like any ordinary canvas.
*/
interface StagingSurface {
/**
* The off-screen `layoutsubtree` canvas the experimental `drawElementImage` paints the HTML content into.
*/
staging: HTMLCanvasElement;
/**
* The regular canvas the staging pixels are copied into, used as the live `THREE.CanvasTexture` source.
*/
mirror: HTMLCanvasElement;
/**
* The mirror's 2D context, cached so the per-frame copy does not re-acquire it.
*/
mirrorCtx: CanvasRenderingContext2D;
/**
* The texture mapped onto the 3D surface, backed by the mirror canvas.
*/
texture: THREE.CanvasTexture;
}
/**
* A first-person 3D room with a single live-HTML surface: the back-wall monitor shows the room's own real sign-up form, plain DOM controls like the flat configurator-form demo's, drawn through the experimental HTML-in-Canvas path.
*
* Adapted from a standalone Three.js room reference into a Lit element, then pared down to one surface: the room markup mounts in the shadow root, Three.js loads as a bundled ESM dependency, and the script's root resolves to the render root so `getElementById`, `querySelectorAll`, and `pointerLockElement` work inside the shadow boundary.
* The room and the monitor form both follow the page theme, a bright white office with a light form in light mode and a dim, warmer office with a dark form in dark mode, resolved the same way the demo page decides its theme and kept in sync at runtime.
* On a flagged Chromium build the monitor is the live form captured into a `THREE.CanvasTexture` every frame; everywhere else the same navigable room renders the form as a static painted texture plus an in-scene note pointing at the flag, so the demo is never blank or broken.
* The scene renders directly on load with the camera framed on the monitor, and the render loop pauses when the canvas scrolls offscreen and stops on disconnect so an idle demo costs nothing; drag-to-look plus WASD or arrow keys keeps it usable in the embedded iframe where pointer lock is denied, and where pointer lock is granted the control hint tells the reader Escape frees the captured cursor.
*
* @element configurator-3d-room
*/
@customElement( 'configurator-3d-room' )
export class Configurator3dRoom extends LitElement {
/**
* Which draw path resolved at runtime, reported in the in-scene caption so the reader knows whether the monitor is live HTML or a static rendering.
*
* It is `@state` so flipping it after the feature check re-renders the caption copy.
*/
@state()
private textureMode: RoomTextureMode = 'placeholder';
/**
* How the camera is steered, set after the first pointer-lock attempt so the on-screen hint matches what actually works in this frame.
*/
@state()
private controlMode: RoomControlMode = 'drag-look';
/**
* Which page theme the room is rendering for, resolved on connect and kept in sync at runtime.
*
* It is a reflected `@property` rather than `@state` because the reflection is the point: only a property can write the host's `data-room-theme` attribute, which is what the monitor-form CSS keys its dark palette off, so the Three.js scene and the captured form re-theme from one source of truth.
* It stays `private` because the theme is detected from the page, never handed in; the reflected attribute is output for the stylesheet, not an input for a harness.
*/
@property( { type: String, reflect: true, attribute: 'data-room-theme' } )
private theme: RoomTheme = 'light';
/**
* The submitted values, held after a successful submit so the confirmation can read them back.
*
* It is `null` until the first valid submit, which is also the flag the monitor template uses to switch from the form to the confirmation.
*/
@state()
private submitted: RoomFormValues | null = null;
/**
* The viewport canvas Three.js renders into, resolved by Lit after the first render.
*/
@query( '.viewport' )
private $viewport!: HTMLCanvasElement;
/**
* The monitor staging canvas, a `layoutsubtree` canvas hosting the live sign-up form DOM.
*/
@query( '.monitor-canvas' )
private $monitorCanvas!: HTMLCanvasElement;
/**
* The interaction hint shown while a monitor form field is focused.
*/
@query( '.interaction-hint' )
private $interactionHint!: HTMLElement;
/**
* The Three.js scene, camera, and renderer, created once the viewport exists.
*/
private scene: THREE.Scene | null = null;
private camera: THREE.PerspectiveCamera | null = null;
private renderer: THREE.WebGLRenderer | null = null;
/**
* The monitor mesh on the back-wall desk, kept as a field because the form raycast tests against it specifically.
*/
private monitorMesh: THREE.Mesh | null = null;
/**
* The single live surface, populated only on the live draw path.
*/
private surfaces: StagingSurface[] = [];
/**
* The mirror canvas, its context, and the texture for the static monitor placeholder, kept only on the placeholder path so a theme change can repaint the fallback screen in the new palette.
*/
private placeholderSurface: { mirror: HTMLCanvasElement; ctx: CanvasRenderingContext2D; texture: THREE.CanvasTexture } | null = null;
/**
* The theme-driven scene materials, kept by role so a runtime theme change can recolor them in place rather than rebuilding the room.
*
* The wall slot holds the shared base material and its three clones together, since the four walls are one tone the theme swaps as a unit.
*/
private themedMaterials: {
floor: THREE.MeshStandardMaterial | null;
rug: THREE.MeshStandardMaterial | null;
ceiling: THREE.MeshStandardMaterial | null;
walls: THREE.MeshStandardMaterial[];
baseboard: THREE.MeshStandardMaterial | null;
desk: THREE.MeshStandardMaterial | null;
} = { floor: null, rug: null, ceiling: null, walls: [], baseboard: null, desk: null };
/**
* The theme-driven lights, kept so a theme change can dim or warm them in place to match the palette.
*/
private themedLights: {
ambient: THREE.AmbientLight | null;
hemisphere: THREE.HemisphereLight | null;
fills: THREE.PointLight[];
} = { ambient: null, hemisphere: null, fills: [] };
/**
* The media query the room watches so it follows the OS color scheme when the page has not pinned a theme, retained so its listener tears down on disconnect.
*/
private themeMedia: MediaQueryList | null = null;
/**
* The observer watching `data-theme` on the document element, so an in-page theme toggle re-themes the room at runtime, retained so it disconnects cleanly.
*/
private themeObserver: MutationObserver | null = null;
/**
* The media query that fires when `devicePixelRatio` changes, so the renderer can be re-corrected on a zoom or a move to a different-density display.
*
* It is recreated on each change because the query is pinned to a single resolution, which is the lifecycle the canvas makes the author own.
*/
private dprQuery: MediaQueryList | null = null;
/**
* The reusable raycaster for the crosshair-aim form interaction, allocated once.
*/
private readonly raycaster = new THREE.Raycaster();
/**
* The reusable Euler used to apply mouse-look without allocating per move.
*/
private readonly euler = new THREE.Euler( 0, 0, 0, 'YXZ' );
/**
* Whether pointer lock is currently held, which gates mouse-look and movement.
*
* It is `@state` so the control hint re-renders the moment the cursor is captured or released, including when the browser exits lock on Escape.
*/
@state()
private isLocked = false;
/**
* Whether a drag-to-look gesture is in progress, the fallback when pointer lock is unavailable.
*/
private isDragging = false;
/**
* The id of the pointer that started the current drag-look gesture, so a second finger or pen mid-drag cannot steer or end a gesture it does not own.
*
* `isPrimary` is not enough as a filter, since touch and pen each get their own primary pointer; the id is the one key that names this exact contact.
* The pointer-lock path never reads it, because free look under lock consumes raw movement deltas and owns no gesture state.
*/
private activePointerId: number | null = null;
/**
* The last position the owning pointer was sampled at, so drag-look motion is measured as a client-space delta, which also carries a touch drag, where movement deltas are not reliably reported.
*/
private lastPointer = { x: 0, y: 0 };
/**
* Whether the viewport canvas is currently intersecting the page, so the render loop only runs while the demo is on screen.
*/
private isVisible = true;
/**
* Whether {@link setUp} has wired the scene, listeners, timers, and observers, so its two entry points, the first update and a reconnect, can never double-wire them.
*/
private isSetUp = false;
/**
* The currently focused monitor form field, or `null` when the reader is just walking.
*/
private $interactiveTarget: HTMLElement | null = null;
/**
* The set of held movement keys, filtered so WASD types into the form while a field is focused.
*/
private readonly keys: Record<string, boolean> = {};
/**
* The timestamp of the previous animation frame, used to scale movement by elapsed time.
*/
private prevTime = 0;
/**
* The animation-frame handle, cleared on disconnect or when the demo scrolls offscreen so a hidden room stops its loop.
*/
private frame = 0;
/**
* Whether the next animation frame has work to do.
*
* Nothing in the room self-animates, so the loop keeps its frame cadence but skips the movement step, the texture upload, and the render until something marks the scene dirty through {@link requestRender}: a look delta from either path, a resize or device-pixel-ratio correction, a theme change, a monitor repaint on the live path, an interaction-hint change, or {@link startLoop} priming the first frame back. Held movement keys are continuous input that streams no events, so the loop marks itself dirty while one is down.
*/
private needsRender = false;
/**
* The repaint timers and observers, tracked so they tear down cleanly on disconnect.
*/
private readonly timers: number[] = [];
private resizeObserver: ResizeObserver | null = null;
private intersectionObserver: IntersectionObserver | null = null;
/**
* The move speed in world units per second, and the look sensitivity in radians per pixel.
*/
private readonly moveSpeed = 3;
private readonly lookSpeed = 0.002;
/**
* Styles for the room shell and its in-scene HTML, scoped to the shadow root.
*
* The shell chrome (caption and hints) reads the page theme tokens so it stays legible in light and dark mode, and the rendered scene and the monitor form both follow the page theme: a bright white studio with a light form in light mode, a dim warm room with a dark form in dark mode.
* The single staging surface stays laid out and painted in a clipped one-by-one container so the experimental draw has a cached paint record to read, and its form markup is real DOM controls so the drawn screen is a live form rather than a picture of one.
*/
static styles = css`
:host {
display: block;
position: relative;
width: 100%;
/* The host is a size container, so the compact height kicks in on the embed's own width rather than the viewport's; the height itself lives on the canvas below, because a container query cannot size the container it measures. */
container-type: inline-size;
/* The shell background matches the light scene clear color so the brief pre-paint flash is the room's own color, not a grey gap; the dark theme overrides it below. */
background: #aeb4c2;
color: var( --ink, #17171a );
font-family: var( --font-sans, 'Inter', system-ui, -apple-system, sans-serif );
overflow: hidden;
border-radius: var( --radius-lg, 14px );
}
:host( [data-room-theme='dark'] ) {
background: #14151a;
}
.viewport {
display: block;
width: 100%;
/* A fixed pixel height, not a vh, because the demo iframe auto-fits to content height, so a viewport-relative height resolves to zero before the iframe is sized; it sits on the canvas rather than the host so the container query below can shrink it. */
height: 520px;
touch-action: none;
cursor: grab;
}
.viewport:active {
cursor: grabbing;
}
@container ( width <= 560px ) {
.viewport {
height: 420px;
}
}
.crosshair {
position: absolute;
top: 50%;
left: 50%;
width: 4px;
height: 4px;
margin: -2px 0 0 -2px;
background: rgba( 20, 22, 30, 0.5 );
border-radius: 50%;
z-index: 5;
pointer-events: none;
}
/* A persistent control reminder, so the reader always knows how to look, move, and type. */
.control-hint {
position: absolute;
top: 12px;
left: 12px;
padding: 6px 11px;
background: rgba( 255, 255, 255, 0.86 );
border: 1px solid rgba( 20, 22, 30, 0.12 );
border-radius: 8px;
font-size: 0.7rem;
line-height: 1.5;
color: #2a2a33;
z-index: 6;
pointer-events: none;
max-width: 280px;
}
/* The mode note is the honest face of a Tier 2 feature: it names whether the monitor is live or a static rendering. */
.mode-note {
position: absolute;
right: 12px;
bottom: 12px;
max-width: 320px;
padding: 9px 13px;
background: rgba( 255, 255, 255, 0.9 );
border: 1px solid rgba( 20, 22, 30, 0.12 );
border-radius: 10px;
font-size: 0.68rem;
line-height: 1.5;
color: #2a2a33;
z-index: 6;
pointer-events: none;
}
.mode-note code {
font-family: var( --font-mono, 'JetBrains Mono', ui-monospace, monospace );
font-size: 0.64rem;
color: #2257e6;
}
.mode-note .hl {
color: #0c8c80;
font-weight: 600;
}
/*
* In dark mode the floating chrome flips to dark panels with light text so the hints read as part of the dark room rather than bright cards punched over it, and the crosshair lightens so it stays visible against the dim scene.
*/
:host( [data-room-theme='dark'] ) .crosshair {
background: rgba( 235, 237, 240, 0.7 );
}
:host( [data-room-theme='dark'] ) .control-hint,
:host( [data-room-theme='dark'] ) .mode-note {
background: rgba( 24, 24, 27, 0.86 );
border-color: rgba( 235, 237, 240, 0.16 );
color: #d4d4da;
}
:host( [data-room-theme='dark'] ) .mode-note code {
color: #6f9bff;
}
:host( [data-room-theme='dark'] ) .mode-note .hl {
color: #46cabf;
}
.interaction-hint {
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX( -50% );
padding: 7px 15px;
background: rgba( 34, 87, 230, 0.92 );
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
color: #fff;
z-index: 8;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
:host( [data-room-theme='dark'] ) .interaction-hint {
background: rgba( 63, 111, 224, 0.95 );
}
.interaction-hint.visible {
opacity: 1;
}
/*
* The staging canvas must stay laid out and painted for the experimental draw to read a cached paint record, so it lives in a clipped one-by-one container rather than display:none.
* It is absolutely positioned at the container origin and overflows it; overflow:hidden clips it to one pixel while the browser still lays it out and paints it.
*/
.staging-container {
position: absolute;
right: 0;
bottom: 0;
width: 1px;
height: 1px;
overflow: hidden;
pointer-events: none;
}
.staging {
position: absolute;
top: 0;
left: 0;
}
.monitor-canvas {
width: 760px;
height: 488px;
}
/*
* A scoped box-sizing reset for the on-monitor form, so the inputs, select, and submit at width:100% with padding measure to their box rather than overflowing past it on the right.
* It is confined to the monitor content subtree so it never leaks into the rest of the shadow root.
*/
.monitor-content,
.monitor-content * {
box-sizing: border-box;
}
/*
* Monitor form content, the room's own plain-DOM sign-up form, drawn onto the screen.
* The 760 by 488 box matches the monitor plane's 2.46 by 1.58 aspect so the texture is not stretched, and the padding, gaps, and type sizes are tuned so the whole form, submit, and fineprint sit inside the screen with margin on every side rather than spilling onto the bezel.
* The single child is the centered inner block, so the column centers it both vertically (justify-content) and horizontally (align-items) inside the screen.
*/
.monitor-content {
width: 760px;
height: 488px;
padding: 26px 46px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
background: linear-gradient( 165deg, #ffffff 0%, #eef1f6 100% );
color: #17171a;
font-family: 'Inter', system-ui, sans-serif;
}
/*
* The centered form block, capped at about half the monitor width so the eyebrow, title, fields, and submit sit in one column with equal margin on both sides rather than filling the screen edge to edge.
* It owns the vertical rhythm between the eyebrow, title, and form, and centers their text so the whole sign-up reads as a centered card on the screen.
*/
.monitor-inner {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 380px;
margin: 0 auto;
text-align: center;
}
.monitor-eyebrow {
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.16em;
color: #2257e6;
}
.monitor-title {
margin: 0;
font-size: 1.6rem;
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
color: #17171a;
}
.monitor-form {
display: flex;
flex-direction: column;
gap: 11px;
margin-top: 2px;
}
.monitor-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.monitor-field label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6a6a76;
}
.monitor-field input,
.monitor-field select {
width: 100%;
padding: 10px 14px;
font: inherit;
font-size: 0.92rem;
line-height: 1.2;
color: #17171a;
background: #ffffff;
border: 1px solid #d6d8df;
border-radius: 9px;
outline: none;
appearance: none;
}
.monitor-field input::placeholder {
color: currentColor;
opacity: 0.45;
}
.monitor-field input:focus,
.monitor-field select:focus {
border-color: #2257e6;
box-shadow: 0 0 0 3px rgba( 34, 87, 230, 0.18 );
}
.monitor-submit {
width: 100%;
margin-top: 4px;
padding: 11px 20px;
font: inherit;
font-size: 0.98rem;
font-weight: 700;
color: #fff;
background: #2257e6;
border: none;
border-radius: 9px;
cursor: pointer;
}
.monitor-fineprint {
margin-top: 2px;
font-size: 0.7rem;
line-height: 1.35;
color: #6a6a76;
}
/* The local confirmation shown after a valid submit, mirroring the flat form's confirmation panel and sized to sit inside the same screen; its own left alignment is restored since it shares the centered inner block with the form. */
.monitor-confirm {
display: flex;
flex-direction: column;
gap: 12px;
text-align: left;
}
.monitor-confirm h3 {
margin: 0;
font-size: 1.5rem;
font-weight: 800;
color: #0c8c80;
}
.monitor-confirm dl {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 20px;
font-size: 0.95rem;
}
.monitor-confirm dt {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6a6a76;
align-self: center;
}
.monitor-confirm dd {
margin: 0;
color: #2a2a33;
}
.monitor-again {
align-self: flex-start;
margin-top: 4px;
padding: 10px 16px;
font: inherit;
font-size: 0.92rem;
font-weight: 700;
color: #2257e6;
background: transparent;
border: 1px solid #d6d8df;
border-radius: 9px;
cursor: pointer;
}
/*
* The dark monitor form, switched on by the reflected data-room-theme attribute so the live, captured form follows the page theme the same way the rest of the page does.
* The colors mirror the dark palette the static placeholder painter uses, so the live screen and the fallback screen read the same in dark mode: a dark surface, light text, and themed inputs and accents.
*/
:host( [data-room-theme='dark'] ) .monitor-content {
background: linear-gradient( 165deg, #17171c 0%, #1f1f25 100% );
color: #ededf0;
}
:host( [data-room-theme='dark'] ) .monitor-eyebrow {
color: #6f9bff;
}
:host( [data-room-theme='dark'] ) .monitor-title {
color: #ededf0;
}
:host( [data-room-theme='dark'] ) .monitor-field label {
color: #9a9aa5;
}
:host( [data-room-theme='dark'] ) .monitor-field input,
:host( [data-room-theme='dark'] ) .monitor-field select {
color: #ededf0;
background: #222228;
border-color: #34343c;
}
:host( [data-room-theme='dark'] ) .monitor-field input:focus,
:host( [data-room-theme='dark'] ) .monitor-field select:focus {
border-color: #6f9bff;
box-shadow: 0 0 0 3px rgba( 111, 155, 255, 0.22 );
}
:host( [data-room-theme='dark'] ) .monitor-submit {
background: #3f6fe0;
color: #ffffff;
}
:host( [data-room-theme='dark'] ) .monitor-fineprint {
color: #9a9aa5;
}
:host( [data-room-theme='dark'] ) .monitor-confirm h3 {
color: #46cabf;
}
:host( [data-room-theme='dark'] ) .monitor-confirm dt {
color: #9a9aa5;
}
:host( [data-room-theme='dark'] ) .monitor-confirm dd {
color: #d4d4da;
}
:host( [data-room-theme='dark'] ) .monitor-again {
color: #6f9bff;
border-color: #34343c;
}
`;
/**
* Resolves the page theme before the first render so the host reflects `data-room-theme` from the start, and the monitor form is captured in the page's palette rather than flashing the light form first.
*
* On a reconnect it also re-runs {@link setUp}: the shadow DOM survives a re-parenting, so `hasUpdated` stays true, `firstUpdated` never fires again, and the wiring {@link tearDown} removed has to come back here, with the `@query` fields still resolving into the kept shadow root.
*/
connectedCallback(): void {
super.connectedCallback();
this.theme = this.detectTheme();
if ( this.hasUpdated ) {
this.setUp();
}
}
/**
* Runs the shared {@link setUp} once the viewport canvas exists after the first render.
*/
protected firstUpdated(): void {
this.setUp();
}
/**
* Builds the scene, wires the controls, observers, and theme listeners, starts the repaint timers, and kicks the loop.
*
* It is shared by `firstUpdated` and a reconnect, because `firstUpdated` only ever fires once: without `connectedCallback` re-running this, a room moved to a new parent would come back looking navigable but dead, with everything {@link tearDown} released still gone.
* The `isSetUp` guard keeps the two entry points from wiring the room twice.
* It also refuses to wire a detached element: an append and remove in the same task still runs the pending first update after disconnection, and the document and window listeners wired here would never hear the disconnect that already happened.
* The theme is already resolved on connect, so the scene and the monitor form open in the page's palette.
*/
private setUp(): void {
if ( this.isSetUp || ! this.isConnected ) {
return;
}
this.isSetUp = true;
this.resolveTextureMode();
this.initScene();
this.bindEvents();
this.bindThemeListeners();
this.observeDevicePixelRatio();
this.startContentTimers();
this.prevTime = performance.now();
this.startLoop();
}
/**
* Tears the room down through the shared {@link tearDown} when it leaves the document.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.tearDown();
}
/**
* Stops the loop and tears down every timer, listener, observer, every scene geometry and material, and the WebGL context so a removed room leaks nothing.
*
* It is the mirror of {@link setUp} rather than the body of `disconnectedCallback`, so a reconnected room can rebuild exactly what this released; clearing `isSetUp` is what re-arms that rebuild.
* The gesture, key, lock, and focus state is reset alongside the wiring, because the listeners that would have released a held key or an active drag are removed here and can never deliver those events to a detached room.
* The live surfaces are emptied, not just disposed, so a reconnect's rebuilt monitor does not sit beside a dead entry the loop would keep uploading.
*/
private tearDown(): void {
this.isSetUp = false;
this.stopLoop();
for ( const id of this.timers ) {
clearInterval( id );
}
this.timers.length = 0;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
this.dprQuery = null;
this.themeMedia?.removeEventListener( 'change', this.onThemeMediaChange );
this.themeMedia = null;
this.themeObserver?.disconnect();
this.themeObserver = null;
this.renderRoot.removeEventListener( 'keydown', this.onKeyDown );
this.renderRoot.removeEventListener( 'keyup', this.onKeyUp );
document.removeEventListener( 'pointerlockchange', this.onPointerLockChange );
document.removeEventListener( 'pointermove', this.onPointerMove );
window.removeEventListener( 'pointerup', this.onPointerUp );
window.removeEventListener( 'pointercancel', this.onPointerCancel );
this.$viewport?.removeEventListener( 'pointerdown', this.onPointerDown );
this.$viewport?.removeEventListener( 'click', this.onViewportClick );
for ( const code of MOVEMENT_KEYS ) {
this.keys[ code ] = false;
}
this.isDragging = false;
this.activePointerId = null;
this.isLocked = false;
this.$interactiveTarget = null;
for ( const surface of this.surfaces ) {
surface.staging.onpaint = null;
surface.texture.dispose();
}
this.surfaces = [];
this.placeholderSurface?.texture.dispose();
this.placeholderSurface = null;
this.disposeScene();
this.renderer?.dispose();
this.scene = null;
this.camera = null;
this.renderer = null;
this.monitorMesh = null;
}
/**
* Disposes every geometry and material the room built, walking the scene graph so the floor, rug, ceiling, walls, baseboards, desk, legs, and the monitor, bezel, and stand are all freed.
*
* It traverses the scene rather than the kept material handles because the geometries and the untracked leg, bezel, and monitor-screen materials are never held in a field, and `renderer.dispose()` releases the renderer's own state but not the meshes' GPU buffers; the monitor's `CanvasTexture` is already disposed above, and a second `.map` dispose is not needed.
*/
private disposeScene(): void {
this.scene?.traverse( ( object ) => {
if ( ! ( object instanceof THREE.Mesh ) ) {
return;
}
object.geometry.dispose();
const material = object.material;
if ( Array.isArray( material ) ) {
for ( const entry of material ) {
entry.dispose();
}
} else {
material.dispose();
}
} );
}
/**
* The render root used as the script's `root`, so DOM lookups, hit testing, and the locked-element check stay inside the shadow boundary.
*
* A Lit shadow root is a `DocumentOrShadowRoot`, so it answers `getElementById`, `querySelectorAll`, and `pointerLockElement` the way a standalone reference expected `document` to.
* The component always renders into a shadow root, so the `instanceof` narrows `renderRoot` to `ShadowRoot` without a cast, and the throw is the honest fallback for the impossible light-DOM case.
*/
private get root(): ShadowRoot {
if ( this.renderRoot instanceof ShadowRoot ) {
return this.renderRoot;
}
throw new Error( 'configurator-3d-room expected a shadow root' );
}
/**
* Records whether the monitor can be live HTML, through the shared {@link supportsHtmlInCanvas} predicate that feature-detects `drawElementImage` on a live 2D context rather than sniffing a UA string.
*/
private resolveTextureMode(): void {
this.textureMode = supportsHtmlInCanvas() ? 'live' : 'placeholder';
}
/**
* Resolves the page theme the same way the demo page decides it: an explicit `data-theme` on the document element wins, and the OS color scheme is the fallback when nothing is pinned.
*
* The demo page writes `data-theme` from a saved preference or `prefers-color-scheme` on load, so reading that attribute first keeps the room in step with an in-page toggle, and the media-query fallback covers the bare case where the attribute is absent.
*
* @returns The theme the room should render for right now.
*/
private detectTheme(): RoomTheme {
const pinned = document.documentElement.dataset.theme;
if ( pinned === 'dark' || pinned === 'light' ) {
return pinned;
}
return window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light';
}
/**
* The palette for the current theme, the single object both the scene materials and the monitor-form painter read.
*/
private get palette(): RoomPalette {
return ROOM_PALETTES[ this.theme ];
}
/**
* Wires the two ways the page theme can change at runtime: the OS color scheme and an in-page `data-theme` toggle.
*
* The media-query listener follows a system theme switch when nothing is pinned, and the mutation observer follows a toggle that writes `data-theme` on the document element; both funnel through {@link refreshTheme} so a change re-themes the scene and the form from one place.
*/
private bindThemeListeners(): void {
this.themeMedia = window.matchMedia( '(prefers-color-scheme: dark)' );
this.themeMedia.addEventListener( 'change', this.onThemeMediaChange );
this.themeObserver = new MutationObserver( () => this.refreshTheme() );
this.themeObserver.observe( document.documentElement, { attributes: true, attributeFilter: [ 'data-theme' ] } );
}
/**
* Re-resolves the OS color scheme into the room when the system theme flips.
*/
private onThemeMediaChange = (): void => {
this.refreshTheme();
};
/**
* Re-resolves the theme and, when it actually changed, recolors the scene and repaints the monitor so the room follows the page.
*
* It is a no-op when the resolved theme is unchanged, so an unrelated `data-theme` mutation or a media event that does not cross the light-dark boundary costs nothing.
*/
private refreshTheme(): void {
const next = this.detectTheme();
if ( next === this.theme ) {
return;
}
this.theme = next;
this.applyTheme();
}
/**
* Pushes the current palette onto the live scene: the background, every themed material, the lights, and the static monitor placeholder, so a runtime theme change lands without rebuilding the room.
*
* The live monitor form re-themes through its own CSS off the reflected `data-room-theme` attribute, so this only needs to repaint the static placeholder; a `requestPaint` keeps the live path's caption and caret current too.
*/
private applyTheme(): void {
const palette = this.palette;
if ( this.scene ) {
this.scene.background = new THREE.Color( palette.background );
}
this.themedMaterials.floor?.color.setHex( palette.floor );
this.themedMaterials.rug?.color.setHex( palette.rug );
this.themedMaterials.ceiling?.color.setHex( palette.ceiling );
this.themedMaterials.baseboard?.color.setHex( palette.baseboard );
this.themedMaterials.desk?.color.setHex( palette.desk );
for ( const wall of this.themedMaterials.walls ) {
wall.color.setHex( palette.wall );
}
if ( this.themedLights.ambient ) {
this.themedLights.ambient.intensity = palette.ambient;
}
if ( this.themedLights.hemisphere ) {
this.themedLights.hemisphere.intensity = palette.hemisphere;
this.themedLights.hemisphere.color.setHex( palette.hemisphereSky );
this.themedLights.hemisphere.groundColor.setHex( palette.hemisphereGround );
}
for ( const fill of this.themedLights.fills ) {
fill.color.setHex( palette.fillColor );
fill.intensity = palette.fillIntensity;
}
this.repaintMonitorPlaceholder();
this.$monitorCanvas.requestPaint?.();
this.requestRender();
}
/**
* Repaints the static monitor placeholder in the current palette, used after a theme change so the fallback screen matches the dark room.
*
* It only does work on the placeholder path, where a single surface backs the monitor with a painted mirror; on the live path the form is captured from the DOM and re-themes through CSS, so there is nothing to repaint here.
*/
private repaintMonitorPlaceholder(): void {
const monitor = this.placeholderSurface;
if ( ! monitor ) {
return;
}
this.paintMonitorPlaceholder( monitor.ctx, monitor.mirror.width, monitor.mirror.height );
monitor.texture.needsUpdate = true;
}
/**
* The current size of the viewport canvas in CSS pixels, read from the element rather than the window so it is right in both standalone and embedded contexts.
*/
private viewportSize(): { width: number; height: number } {
return {
width: this.$viewport.clientWidth || 1,
height: this.$viewport.clientHeight || 1,
};
}
/**
* Creates the scene, camera, renderer, room geometry, the desk, the monitor surface, and the lighting, then frames the camera on the monitor.
*/
private initScene(): void {
const palette = this.palette;
const scene = new THREE.Scene();
scene.background = new THREE.Color( palette.background );
this.scene = scene;
const size = this.viewportSize();
const camera = new THREE.PerspectiveCamera( 60, size.width / size.height, 0.01, 100 );
// Stand a couple of metres back from the back-wall monitor, eye height, looking straight at the screen so the form is framed and readable on load.
camera.position.set( 0, 1.7, 0.4 );
camera.lookAt( 0, 1.78, -HALF_D );
this.camera = camera;
const renderer = new THREE.WebGLRenderer( { canvas: this.$viewport, antialias: true } );
renderer.setSize( size.width, size.height, false );
renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
this.renderer = renderer;
this.buildRoom( scene );
this.buildDesk( scene );
this.buildMonitor( scene );
this.buildLighting( scene );
}
/**
* Builds the floor, rug, ceiling, four walls, and baseboards from the current palette, and keeps each material so a theme change can recolor it.
*
* In the light theme every large surface is a white or very light neutral so the room reads as a clean, well-lit office; in the dark theme the same surfaces drop to lifted dark neutrals so the room reads as a dim evening office while the monitor still stands out against the wall.
*
* @param scene - The scene to add the room shell to.
*/
private buildRoom( scene: THREE.Scene ): void {
const palette = this.palette;
const floorMat = new THREE.MeshStandardMaterial( { color: palette.floor, roughness: 0.85, metalness: 0.02 } );
const floor = new THREE.Mesh( new THREE.PlaneGeometry( ROOM_W, ROOM_D ), floorMat );
floor.rotation.x = -Math.PI / 2;
scene.add( floor );
this.themedMaterials.floor = floorMat;
const rugMat = new THREE.MeshStandardMaterial( { color: palette.rug, roughness: 0.95 } );
const rug = new THREE.Mesh( new THREE.PlaneGeometry( 4.5, 3.5 ), rugMat );
rug.rotation.x = -Math.PI / 2;
rug.position.set( 0, 0.005, -0.4 );
scene.add( rug );
this.themedMaterials.rug = rugMat;
const ceilingMat = new THREE.MeshStandardMaterial( { color: palette.ceiling, roughness: 0.95 } );
const ceiling = new THREE.Mesh( new THREE.PlaneGeometry( ROOM_W, ROOM_D ), ceilingMat );
ceiling.rotation.x = Math.PI / 2;
ceiling.position.y = ROOM_H;
scene.add( ceiling );
this.themedMaterials.ceiling = ceilingMat;
const wallMat = new THREE.MeshStandardMaterial( { color: palette.wall, roughness: 0.9, metalness: 0.02 } );
const backWall = new THREE.Mesh( new THREE.PlaneGeometry( ROOM_W, ROOM_H ), wallMat );
backWall.position.set( 0, ROOM_H / 2, -HALF_D );
scene.add( backWall );
const frontWallMat = wallMat.clone();
const frontWall = new THREE.Mesh( new THREE.PlaneGeometry( ROOM_W, ROOM_H ), frontWallMat );
frontWall.position.set( 0, ROOM_H / 2, HALF_D );
frontWall.rotation.y = Math.PI;
scene.add( frontWall );
const leftWallMat = wallMat.clone();
const leftWall = new THREE.Mesh( new THREE.PlaneGeometry( ROOM_D, ROOM_H ), leftWallMat );
leftWall.position.set( -HALF_W, ROOM_H / 2, 0 );
leftWall.rotation.y = Math.PI / 2;
scene.add( leftWall );
const rightWallMat = wallMat.clone();
const rightWall = new THREE.Mesh( new THREE.PlaneGeometry( ROOM_D, ROOM_H ), rightWallMat );
rightWall.position.set( HALF_W, ROOM_H / 2, 0 );
rightWall.rotation.y = -Math.PI / 2;
scene.add( rightWall );
this.themedMaterials.walls = [ wallMat, frontWallMat, leftWallMat, rightWallMat ];
const baseMat = new THREE.MeshStandardMaterial( { color: palette.baseboard, roughness: 0.8 } );
const addBaseboard = ( width: number, x: number, z: number, rotY: number ): void => {
const mesh = new THREE.Mesh( new THREE.BoxGeometry( width, 0.1, 0.03 ), baseMat );
mesh.position.set( x, 0.05, z );
mesh.rotation.y = rotY;
scene.add( mesh );
};
addBaseboard( ROOM_W, 0, -HALF_D + 0.015, 0 );
addBaseboard( ROOM_W, 0, HALF_D - 0.015, 0 );
addBaseboard( ROOM_D, -HALF_W + 0.015, 0, Math.PI / 2 );
addBaseboard( ROOM_D, HALF_W - 0.015, 0, Math.PI / 2 );
this.themedMaterials.baseboard = baseMat;
}
/**
* Builds the desk top and its four legs under the monitor.
*
* @param scene - The scene to add the desk to.
*/
private buildDesk( scene: THREE.Scene ): void {
const deskMat = new THREE.MeshStandardMaterial( { color: this.palette.desk, roughness: 0.6, metalness: 0.1 } );
const deskTop = new THREE.Mesh( new THREE.BoxGeometry( 3, 0.08, 0.9 ), deskMat );
deskTop.position.set( 0, 0.8, -3.55 );
scene.add( deskTop );
this.themedMaterials.desk = deskMat;
const legMat = new THREE.MeshStandardMaterial( { color: 0xb7b7b1, roughness: 0.5, metalness: 0.3 } );
const legGeo = new THREE.BoxGeometry( 0.06, 0.8, 0.06 );
const legPositions: ReadonlyArray<[ number, number, number ]> = [
[ -1.4, 0.4, -3.9 ],
[ 1.4, 0.4, -3.9 ],
[ -1.4, 0.4, -3.2 ],
[ 1.4, 0.4, -3.2 ],
];
for ( const [ x, y, z ] of legPositions ) {
const leg = new THREE.Mesh( legGeo, legMat );
leg.position.set( x, y, z );
scene.add( leg );
}
}
/**
* Builds the monitor with its texture, bezel, and stand on the back-wall desk.
*
* On the live path the screen gets a `THREE.CanvasTexture` backed by a mirror of the form's staging canvas; on the placeholder path it gets a static painted texture so the screen is never blank.
*
* @param scene - The scene to add the monitor to.
*/
private buildMonitor( scene: THREE.Scene ): void {
const monitorTexture = this.makeSurfaceTexture( this.$monitorCanvas, 760, 488, ( ctx, w, h ) => this.paintMonitorPlaceholder( ctx, w, h ) );
const bezelMat = new THREE.MeshStandardMaterial( { color: 0x2a2a30, roughness: 0.35, metalness: 0.7 } );
const monitorBezel = new THREE.Mesh( new THREE.BoxGeometry( 2.62, 1.74, 0.06 ), bezelMat );
monitorBezel.position.set( 0, 1.78, -3.97 );
scene.add( monitorBezel );
const monitorMesh = new THREE.Mesh(
new THREE.PlaneGeometry( 2.46, 1.58 ),
new THREE.MeshBasicMaterial( { map: monitorTexture } ),
);
monitorMesh.position.set( 0, 1.78, -3.93 );
scene.add( monitorMesh );
this.monitorMesh = monitorMesh;
const stand = new THREE.Mesh( new THREE.BoxGeometry( 0.15, 0.2, 0.15 ), bezelMat );
stand.position.set( 0, 0.94, -3.7 );
scene.add( stand );
const standBase = new THREE.Mesh( new THREE.BoxGeometry( 0.5, 0.04, 0.3 ), bezelMat );
standBase.position.set( 0, 0.84, -3.6 );
scene.add( standBase );
}
/**
* Makes the texture for the monitor, wiring the live capture path when supported and painting a static placeholder otherwise.
*
* On the live path it registers the staging canvas's `onpaint` to draw its HTML child, kicks an initial paint, and records a {@link StagingSurface} so the loop copies the staging bitmap into the mirror each frame.
* On the placeholder path it paints the supplied static scene into the mirror once, so the screen still shows the form with no experimental API.
*
* @param $staging - The `layoutsubtree` staging canvas hosting the form HTML.
* @param width - The texture width in pixels.
* @param height - The texture height in pixels.
* @param paintPlaceholder - Paints the static fallback into a 2D context.
* @returns The texture mapped onto the monitor.
*/
private makeSurfaceTexture(
$staging: HTMLCanvasElement,
width: number,
height: number,
paintPlaceholder: ( ctx: CanvasRenderingContext2D, width: number, height: number ) => void,
): THREE.CanvasTexture {
const $mirror = document.createElement( 'canvas' );
$mirror.width = width;
$mirror.height = height;
const mirrorCtx = $mirror.getContext( '2d' );
const texture = new THREE.CanvasTexture( $mirror );
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.colorSpace = THREE.SRGBColorSpace;
if ( ! mirrorCtx ) {
return texture;
}
if ( this.textureMode === 'live' ) {
const $content = $staging.firstElementChild;
const stagingCtx = $staging.getContext( '2d' );
if ( $content && stagingCtx ) {
$staging.layoutSubtree = true;
$staging.onpaint = (): void => {
stagingCtx.clearRect( 0, 0, $staging.width, $staging.height );
stagingCtx.drawElementImage( $content, 0, 0 );
// The staging bitmap just changed, so the gated loop owes one frame to copy it into the mirror and re-upload the texture; this is the only signal the live monitor gives.
this.requestRender();
};
$staging.requestPaint();
this.surfaces.push( { staging: $staging, mirror: $mirror, mirrorCtx, texture } );
return texture;
}
}
paintPlaceholder( mirrorCtx, width, height );
texture.needsUpdate = true;
this.placeholderSurface = { mirror: $mirror, ctx: mirrorCtx, texture };
return texture;
}
/**
* Paints the static monitor placeholder, a sign-up panel that mirrors the live form so the screen reads as the same control even without the experimental API, in the current theme's palette.
*
* The light theme paints a white panel and the dark theme a dark one with light text, matching the live form's two palettes, and the layout is sized to sit inside the 760 by 488 screen with margin rather than spilling onto the bezel.
*
* @param ctx - The mirror context to paint into.
* @param width - The texture width.
* @param height - The texture height.
*/
private paintMonitorPlaceholder( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
const dark = this.theme === 'dark';
const colors = dark
? { top: '#17171c', bottom: '#1f1f25', accent: '#6f9bff', title: '#ededf0', label: '#9a9aa5', inputFill: '#222228', inputBorder: '#34343c', inputText: '#ededf0', button: '#3f6fe0' }
: { top: '#ffffff', bottom: '#eef1f6', accent: '#2257e6', title: '#17171a', label: '#6a6a76', inputFill: '#ffffff', inputBorder: '#d6d8df', inputText: '#17171a', button: '#2257e6' };
const gradient = ctx.createLinearGradient( 0, 0, 0, height );
gradient.addColorStop( 0, colors.top );
gradient.addColorStop( 1, colors.bottom );
ctx.fillStyle = gradient;
ctx.fillRect( 0, 0, width, height );
// The live form sits in a centered column capped at 380px, half of the 760px monitor content width, so the painted fallback derives the same column here and centers it inside the padded content box, rather than running the fields and button at the old near-full width that overflowed to the right.
const contentPadding = 46;
const contentLeft = contentPadding;
const contentWidth = width - contentPadding * 2;
const columnWidth = Math.min( 380, contentWidth / 2 );
const columnX = contentLeft + ( contentWidth - columnWidth ) / 2;
const textInset = 15;
ctx.fillStyle = colors.accent;
ctx.font = '700 20px Inter, system-ui, sans-serif';
ctx.fillText( 'SIGN UP', columnX, 70 );
ctx.fillStyle = colors.title;
ctx.font = '800 34px Inter, system-ui, sans-serif';
ctx.fillText( 'Drop us a note', columnX, 112 );
const fields: ReadonlyArray<[ string, string ]> = [
[ 'YOUR NAME', 'Jane Doe' ],
[ 'EMAIL', 'jane@example.com' ],
[ 'WHAT IS ON YOUR MIND?', 'HTML-in-Canvas feedback' ],
];
let y = 156;
for ( const [ label, value ] of fields ) {
ctx.fillStyle = colors.label;
ctx.font = '700 12px Inter, system-ui, sans-serif';
ctx.fillText( label, columnX, y );
ctx.fillStyle = colors.inputFill;
ctx.strokeStyle = colors.inputBorder;
ctx.lineWidth = 1;
this.roundRect( ctx, columnX, y + 9, columnWidth, 40, 9 );
ctx.fill();
ctx.stroke();
// The live fields now start empty with placeholder hints, so the painted fallback dims its sample values to read as the same placeholders.
ctx.fillStyle = colors.inputText;
ctx.globalAlpha = 0.55;
ctx.font = '15px Inter, system-ui, sans-serif';
ctx.fillText( value, columnX + textInset, y + 34 );
ctx.globalAlpha = 1;
y += 76;
}
ctx.fillStyle = colors.button;
this.roundRect( ctx, columnX, y + 2, columnWidth, 46, 9 );
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '700 16px Inter, system-ui, sans-serif';
ctx.fillText( 'Save configuration', columnX + textInset, y + 31 );
}
/**
* Strokes a rounded rectangle path for the placeholder fields, since the 2D context has no rounded-rect fill shorthand on every build.
*
* @param ctx - The context to draw the path into.
* @param x - The left edge.
* @param y - The top edge.
* @param width - The rectangle width.
* @param height - The rectangle height.
* @param radius - The corner radius.
*/
private roundRect( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number ): void {
ctx.beginPath();
ctx.moveTo( x + radius, y );
ctx.arcTo( x + width, y, x + width, y + height, radius );
ctx.arcTo( x + width, y + height, x, y + height, radius );
ctx.arcTo( x, y + height, x, y, radius );
ctx.arcTo( x, y, x + width, y, radius );
ctx.closePath();
}
/**
* Lights the studio from the current palette: an ambient base, a hemisphere bounce, and two soft ceiling fills, each kept so a theme change can dim or warm it.
*
* In the light theme the lighting is strong and neutral so the white office reads as one even light with no muddy shadows; in the dark theme the same rig is dimmer and warmer so the room reads as a softly lit evening office that is still clearly legible rather than a black box.
*
* @param scene - The scene to light.
*/
private buildLighting( scene: THREE.Scene ): void {
const palette = this.palette;
const ambient = new THREE.AmbientLight( 0xffffff, palette.ambient );
scene.add( ambient );
this.themedLights.ambient = ambient;
const hemisphere = new THREE.HemisphereLight( palette.hemisphereSky, palette.hemisphereGround, palette.hemisphere );
scene.add( hemisphere );
this.themedLights.hemisphere = hemisphere;
const frontFill = new THREE.PointLight( palette.fillColor, palette.fillIntensity, 26 );
frontFill.position.set( 0, 3.2, 2.2 );
scene.add( frontFill );
const backFill = new THREE.PointLight( palette.fillColor, palette.fillIntensity, 26 );
backFill.position.set( 0, 3.2, -2.4 );
scene.add( backFill );
this.themedLights.fills = [ frontFill, backFill ];
}
/**
* Wires keyboard, pointer-lock, pointer, click, resize, and intersection listeners.
*
* Movement keys listen on the render root rather than `document` so the room only steers while it has keyboard focus: the demo runs in a `LiveExample` iframe, and a `document` listener never fires unless the iframe holds focus, while a render-root listener catches keystrokes from the focused viewport canvas when walking and from a focused monitor field when typing, yet stays silent when the reader's focus is anywhere else on the page, so the room never hijacks the page keyboard.
* Pointer-lock changes and captured or dragged pointer motion are still delivered at the document and window level, so `pointerlockchange`, `pointermove`, and the releasing `pointerup` and `pointercancel` stay there; the look handler self-gates on lock or an owned drag, so it does nothing until the reader is interacting, and a drag that leaves the canvas keeps looking.
* The resize observer watches the viewport so the renderer tracks the embedded stage, and the intersection observer pauses the loop when the demo scrolls offscreen.
*/
private bindEvents(): void {
this.renderRoot.addEventListener( 'keydown', this.onKeyDown );
this.renderRoot.addEventListener( 'keyup', this.onKeyUp );
document.addEventListener( 'pointerlockchange', this.onPointerLockChange );
document.addEventListener( 'pointermove', this.onPointerMove );
window.addEventListener( 'pointerup', this.onPointerUp );
window.addEventListener( 'pointercancel', this.onPointerCancel );
this.$viewport.addEventListener( 'pointerdown', this.onPointerDown );
this.$viewport.addEventListener( 'click', this.onViewportClick );
this.resizeObserver = new ResizeObserver( () => this.onResize() );
this.resizeObserver.observe( this.$viewport );
this.intersectionObserver = new IntersectionObserver(
( entries ) => this.onIntersection( entries ),
{ threshold: 0.01 },
);
this.intersectionObserver.observe( this.$viewport );
}
/**
* Whether a key code should drive movement right now.
*
* While a form field is focused every key belongs to the field, arrows included, because a caret that cannot move is a broken input while a room that pauses walking mid-type is just polite.
* Steering resumes the moment the field blurs, which Escape or a click away from the monitor does.
*
* @param code - The keyboard event code.
* @returns Whether the code should be treated as a movement key.
*/
private isMovementKey( code: string ): boolean {
if ( ! MOVEMENT_KEYS.has( code ) ) {
return false;
}
return ! this.$interactiveTarget;
}
/**
* Records a held movement key, and treats Escape as the single release: it blurs any focused field and frees a captured cursor.
*
* The browser already exits pointer lock on Escape, but when a form field is focused the keystroke is delivered to the field, so the handler blurs the field and calls `exitPointerLock` itself, so one Escape both stops typing and hands the cursor back rather than needing two presses.
* The parameter is typed `Event` rather than `KeyboardEvent` so the handler satisfies the render root's `EventTarget.addEventListener` listener type, since a shadow root carries no typed keyboard-event overload; it is narrowed to `KeyboardEvent` up front, which always holds because it is only ever bound to `keydown`.
*
* @param event - The keydown event, narrowed to a `KeyboardEvent` for use.
*/
private onKeyDown = ( event: Event ): void => {
if ( ! ( event instanceof KeyboardEvent ) ) {
return;
}
if ( event.key === 'Escape' ) {
if ( this.$interactiveTarget ) {
this.$interactiveTarget.blur();
}
if ( this.isLocked ) {
document.exitPointerLock();
}
}
if ( this.isMovementKey( event.code ) ) {
this.keys[ event.code ] = true;
}
};
/**
* Clears a released movement key.
*
* The release clears unconditionally, unlike the gated keydown: a key still held when a form field takes focus would otherwise stay latched through {@link isMovementKey} returning false, and the gated loop would keep walking and rendering on a key the reader let go of long ago.
* The parameter is typed `Event` for the same render-root listener reason as {@link onKeyDown}, and narrowed to `KeyboardEvent` before use.
*
* @param event - The keyup event, narrowed to a `KeyboardEvent` for use.
*/
private onKeyUp = ( event: Event ): void => {
if ( ! ( event instanceof KeyboardEvent ) ) {
return;
}
if ( MOVEMENT_KEYS.has( event.code ) ) {
this.keys[ event.code ] = false;
}
};
/**
* Tracks whether pointer lock is held, using the render root so the check works inside the shadow boundary.
*/
private onPointerLockChange = (): void => {
this.isLocked = this.root.pointerLockElement === this.$viewport;
};
/**
* Pauses or resumes the render loop as the demo scrolls in and out of view, so an offscreen room costs no frames.
*
* @param entries - The intersection records for the observed viewport.
*/
private onIntersection( entries: IntersectionObserverEntry[] ): void {
for ( const entry of entries ) {
this.isVisible = entry.isIntersecting;
if ( this.isVisible ) {
this.prevTime = performance.now();
this.startLoop();
} else {
this.stopLoop();
}
}
}
/**
* Begins a drag-to-look gesture, focuses the viewport for keyboard walking, and lazily resolves the control mode on the first press in an embedded frame.
*
* Only the primary pointer's primary button starts a gesture: a right or middle click falls through to the browser, since a context menu can swallow the matching release and leave a phantom drag armed, and a gesture already in progress owns the viewport, so a new contact of any type is ignored until the owning pointer releases, because `isPrimary` is per pointer type and a first touch mid-mouse-drag would otherwise pass the guard and steal the gesture. The same contract lets a touch drag steer the fallback look, since the viewport already opts out of native panning with `touch-action: none`.
* The scene renders on load with no entry overlay, so the first press is where the room decides between captured pointer lock and the drag-look fallback: it requests pointer lock, and if the request is unavailable or denied it stays in drag-look, which is fully usable with the pointer held down plus WASD or the arrow keys.
* The handler calls `preventDefault` to suppress the native text selection and grab gesture, which also suppresses the implicit focus a press would give the canvas, so it focuses the viewport explicitly: without focus the render-root `keydown` listeners never fire in the embedded iframe, which is exactly why a click was needed before the keys would move the camera. The focus is taken with `preventScroll` so clicking into the scene never yanks the page to the demo.
*
* @param event - The pointerdown on the viewport.
*/
private onPointerDown = ( event: PointerEvent ): void => {
if ( this.activePointerId !== null || ! event.isPrimary || event.button !== 0 ) {
return;
}
this.isDragging = true;
this.activePointerId = event.pointerId;
this.lastPointer = { x: event.clientX, y: event.clientY };
event.preventDefault();
this.$viewport.focus( { preventScroll: true } );
if ( this.controlMode === 'drag-look' && ! this.isLocked ) {
this.tryPointerLock();
}
};
/**
* Attempts pointer lock, upgrading the control mode when it lands and staying in drag-look when it is denied.
*
* Pointer lock is often blocked in an embedded iframe, so any rejection leaves the room in the drag-look fallback rather than failing.
*/
private tryPointerLock(): void {
const request = this.$viewport.requestPointerLock();
if ( request && typeof request.then === 'function' ) {
request.then(
() => {
this.controlMode = 'pointer-lock';
},
() => {
this.controlMode = 'drag-look';
},
);
return;
}
// Older browsers return undefined from requestPointerLock; confirm the lock landed on the next tick, else stay in drag-look.
window.setTimeout( () => {
if ( this.root.pointerLockElement === this.$viewport ) {
this.controlMode = 'pointer-lock';
}
}, 120 );
}
/**
* Ends the drag-look gesture when its owning pointer is released anywhere.
*
* Only the pointer that started the gesture can end it, so releasing an unrelated contact leaves a live drag alone; the `click` that follows the primary release still runs the crosshair-aim form flow on its own event, unchanged.
*
* @param event - The pointerup on the window.
*/
private onPointerUp = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
};
/**
* Abandons the drag-look gesture when the browser cancels the owning pointer, a touch scroll takeover or a system sheet, without treating the interruption as a look or a click.
*
* Only the gesture's own pointer can cancel it, so an unrelated contact being cancelled leaves a live drag alone.
*/
private onPointerCancel = ( event: PointerEvent ): void => {
if ( event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
};
/**
* Applies look while pointer lock is held or an owned drag gesture is active.
*
* The two paths deliberately read different deltas. Free look under lock consumes the event's raw movement deltas, gated only on `isLocked`, because a captured cursor has no meaningful client position and the gesture fields stay untouched, which is the pointer-lock contract this handler preserves.
* Drag look is a real gesture, so it only follows the pointer that started it: the move is filtered by the owning pointer id, and the delta is measured between client samples, which keeps a second contact from steering and carries a touch drag where movement deltas are unreliable.
*
* @param event - The pointermove on the document, carrying the frame's movement or position delta.
*/
private onPointerMove = ( event: PointerEvent ): void => {
if ( this.isLocked ) {
this.applyLook( event.movementX, event.movementY );
return;
}
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
const dx = event.clientX - this.lastPointer.x;
const dy = event.clientY - this.lastPointer.y;
this.lastPointer = { x: event.clientX, y: event.clientY };
this.applyLook( dx, dy );
};
/**
* Yaws and pitches the camera by a pixel delta, with pitch clamped just short of straight up or down so the view never flips, then marks the frame dirty so the gated loop draws the new angle.
*
* It is the one place both look paths land, so the sensitivity and the clamp cannot drift between pointer lock and drag look.
*
* @param dx - The horizontal look delta in pixels.
* @param dy - The vertical look delta in pixels.
*/
private applyLook( dx: number, dy: number ): void {
if ( ! this.camera ) {
return;
}
this.euler.setFromQuaternion( this.camera.quaternion );
this.euler.y -= dx * this.lookSpeed;
this.euler.x -= dy * this.lookSpeed;
this.euler.x = Math.max( -Math.PI / 2 + 0.05, Math.min( Math.PI / 2 - 0.05, this.euler.x ) );
this.camera.quaternion.setFromEuler( this.euler );
this.requestRender();
}
/**
* Handles a crosshair-aim click: raycasts at screen center, and focuses the monitor form field under the hit.
*
* A click raycasts from screen center in both control modes, so aiming the camera at a field and clicking focuses it whether or not pointer lock is held.
*/
private onViewportClick = (): void => {
if ( ! this.camera || ! this.monitorMesh ) {
return;
}
this.raycaster.setFromCamera( new THREE.Vector2( 0, 0 ), this.camera );
const hits = this.raycaster.intersectObject( this.monitorMesh, false );
const hit = hits.length > 0 && hits[ 0 ].uv ? hits[ 0 ] : null;
if ( this.$interactiveTarget ) {
if ( hit && hit.uv && this.focusFormElement( hit.uv ) ) {
this.updateInteractionHint();
return;
}
this.$interactiveTarget.blur();
this.$interactiveTarget = null;
this.updateInteractionHint();
return;
}
if ( hit && hit.uv && this.focusFormElement( hit.uv ) ) {
this.updateInteractionHint();
}
};
/**
* Focuses the monitor form control under a UV hit on the monitor mesh, or actions a button or select.
*
* The UV is converted to a pixel point in the staging canvas's layout box, then matched against the form's interactive children by their bounding rectangles, since the clipped staging container makes `elementFromPoint` unreliable.
* A submit button click and a select cycle are handled inline, and a text or email input is focused with its content selected, so the browser delivers keystrokes to it and typing replaces whatever was there.
*
* @param uv - The UV coordinate of the hit on the monitor mesh.
* @returns Whether a field was focused, as opposed to a one-shot action or a miss.
*/
private focusFormElement( uv: THREE.Vector2 ): boolean {
const $staging = this.$monitorCanvas;
const width = $staging.clientWidth || $staging.width;
const height = $staging.clientHeight || $staging.height;
const px = uv.x * width;
const py = ( 1 - uv.y ) * height;
const canvasRect = $staging.getBoundingClientRect();
const $candidates = $staging.querySelectorAll<HTMLElement>( 'input, select, textarea, button' );
let $target: HTMLElement | null = null;
for ( const $element of $candidates ) {
const rect = $element.getBoundingClientRect();
const localX = rect.left - canvasRect.left;
const localY = rect.top - canvasRect.top;
if ( px >= localX && px < localX + rect.width && py >= localY && py < localY + rect.height ) {
$target = $element;
break;
}
}
if ( ! $target ) {
return false;
}
if ( $target instanceof HTMLButtonElement ) {
$target.click();
return false;
}
if ( $target instanceof HTMLSelectElement ) {
const count = $target.options.length;
if ( count > 0 ) {
$target.selectedIndex = ( $target.selectedIndex + 1 ) % count;
$target.dispatchEvent( new Event( 'change', { bubbles: true } ) );
this.$monitorCanvas.requestPaint?.();
}
return false;
}
$target.focus( { preventScroll: true } );
// The ray resolves a control, not a caret: mapping the hit to a character offset is more bridge than this research path earns, so existing text is selected instead, and typing replaces it while Backspace clears it.
if ( $target instanceof HTMLInputElement ) {
$target.select();
}
this.$interactiveTarget = $target;
$target.addEventListener(
'blur',
() => {
if ( this.$interactiveTarget === $target ) {
this.$interactiveTarget = null;
}
this.updateInteractionHint();
},
{ once: true },
);
return true;
}
/**
* The persistent control reminder, written to match the control mode that actually resolved in this frame.
*
* The walking keys only fire once the scene holds keyboard focus, which the reader gives it by clicking or dragging into the room, so every variant leads with that click cue rather than letting the reader press WASD against a scene that has not been focused yet.
* On a build that grants pointer lock the cursor is captured on click, so while it is held the hint names the free-look mouse and tells the reader the one thing that is not obvious, that Escape releases the cursor and leaves the demo; once Escape frees the cursor the same build prompts that clicking re-captures it.
* The drag-look fallback keeps the held-button wording and needs no Escape, since the cursor was never captured.
*
* @returns The control hint copy for the current control state.
*/
private controlHintText(): string {
if ( this.controlMode === 'pointer-lock' ) {
if ( this.isLocked ) {
return 'Move the mouse to look, WASD or arrows to move, click a field to type, Esc to free the cursor.';
}
return 'Click the scene to look around, then WASD or arrows to move, Esc frees the cursor again.';
}
return 'Click the scene first, then drag to look and walk with WASD or the arrow keys; aim at a field and click to type.';
}
/**
* Shows or hides the typing hint depending on whether a form field is focused, and marks the scene dirty.
*
* The hint itself is DOM chrome outside the canvas, but every call here is a focus handoff on the monitor, whose focus ring lands in the captured form, so the gated loop owes a frame to show the screen's new state.
*/
private updateInteractionHint(): void {
this.requestRender();
if ( this.$interactiveTarget ) {
this.$interactionHint.textContent = 'Typing into the form, press Esc to release';
this.$interactionHint.classList.add( 'visible' );
return;
}
this.$interactionHint.classList.remove( 'visible' );
}
/**
* Resizes the renderer and updates the camera aspect when the viewport's CSS box changes, then marks the scene dirty so the gated loop redraws at the new size.
*/
private onResize(): void {
if ( ! this.camera || ! this.renderer ) {
return;
}
const size = this.viewportSize();
this.camera.aspect = size.width / size.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize( size.width, size.height, false );
this.requestRender();
}
/**
* Watches the device pixel ratio through a query pinned to the current resolution, corrects the renderer, and rebuilds the one-shot listener for the next change.
*
* `matchMedia( '(resolution: Ndppx)' )` only watches the current ratio, so a move to a different-density display needs the query rebuilt each time; the renderer caps the ratio at two so a very dense display does not balloon the backing store.
*/
private observeDevicePixelRatio(): void {
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
const dpr = window.devicePixelRatio || 1;
this.dprQuery = window.matchMedia( `(resolution: ${ dpr }dppx)` );
this.dprQuery.addEventListener( 'change', this.onDevicePixelRatioChange, { once: true } );
this.renderer?.setPixelRatio( Math.min( dpr, 2 ) );
}
/**
* Re-corrects the renderer after the device pixel ratio changes, then re-arms the watcher and marks the scene dirty through the resize path.
*/
private onDevicePixelRatioChange = (): void => {
this.observeDevicePixelRatio();
this.onResize();
};
/**
* Starts the timer that keeps the monitor form repainting on the live path.
*
* `requestPaint` only does work on the live path, so the timer is cheap when the experimental API is absent: a slow idle repaint keeps the texture fresh, and a fast repaint while a field is focused makes the caret and live text show up promptly in the scene.
*/
private startContentTimers(): void {
this.timers.push( window.setInterval( () => this.$monitorCanvas.requestPaint?.(), 2000 ) );
this.timers.push(
window.setInterval( () => {
if ( this.$interactiveTarget ) {
this.$monitorCanvas.requestPaint?.();
}
}, 100 ),
);
}
/**
* Advances the camera from the held movement keys, clamped to the room and blocked from walking into the desk.
*
* Movement runs whenever the room is on screen, so the reader can walk with the keyboard the moment the scene loads, with or without pointer lock.
*
* @param dt - The elapsed time since the previous frame, in seconds.
*/
private updateMovement( dt: number ): void {
if ( ! this.camera ) {
return;
}
const forward = new THREE.Vector3( 0, 0, -1 ).applyQuaternion( this.camera.quaternion );
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3( 1, 0, 0 ).applyQuaternion( this.camera.quaternion );
right.y = 0;
right.normalize();
const velocity = new THREE.Vector3();
if ( this.keys.KeyW || this.keys.ArrowUp ) {
velocity.add( forward );
}
if ( this.keys.KeyS || this.keys.ArrowDown ) {
velocity.sub( forward );
}
if ( this.keys.KeyD || this.keys.ArrowRight ) {
velocity.add( right );
}
if ( this.keys.KeyA || this.keys.ArrowLeft ) {
velocity.sub( right );
}
if ( velocity.lengthSq() === 0 ) {
return;
}
velocity.normalize().multiplyScalar( this.moveSpeed * dt );
const next = this.camera.position.clone().add( velocity );
const margin = 0.4;
next.x = Math.max( -HALF_W + margin, Math.min( HALF_W - margin, next.x ) );
next.z = Math.max( -HALF_D + margin, Math.min( HALF_D - margin, next.z ) );
if ( next.z < -3.0 && Math.abs( next.x ) < 1.7 ) {
next.z = Math.max( next.z, -3.0 );
}
this.camera.position.copy( next );
}
/**
* Starts the render loop if it is not already running and the demo is on screen, and marks the scene dirty so the first frame back repaints.
*/
private startLoop(): void {
if ( this.frame === 0 && this.isVisible ) {
this.frame = requestAnimationFrame( this.renderLoop );
}
this.requestRender();
}
/**
* Stops the render loop and clears the frame handle so it can be restarted cleanly.
*/
private stopLoop(): void {
if ( this.frame !== 0 ) {
cancelAnimationFrame( this.frame );
this.frame = 0;
}
}
/**
* Marks the scene dirty so the next animation frame runs the movement step, the texture upload, and the render.
*/
private requestRender(): void {
this.needsRender = true;
}
/**
* Whether any movement key is currently held, checked by the loop each frame because a held key streams no events to mark dirty from.
*
* @returns Whether a held key should keep the loop rendering.
*/
private hasMovementInput(): boolean {
for ( const code of MOVEMENT_KEYS ) {
if ( this.keys[ code ] ) {
return true;
}
}
return false;
}
/**
* The render loop: advances movement, syncs the live monitor surface into its texture, and draws the frame.
*
* The loop stays hot at the frame cadence while the demo is on screen, but the work is gated on {@link needsRender}: an idle frame re-arms itself, advances the clock, and returns, so a room nobody is walking or looking around renders nothing, and the first frame after a change does the full pass.
* Held movement keys mark the frame dirty here rather than from an event, since a key held down streams nothing between keydown and keyup, and the clock advances on idle frames too so the first dirty frame after a pause steps by one frame rather than the whole pause.
*/
private renderLoop = (): void => {
this.frame = requestAnimationFrame( this.renderLoop );
if ( ! this.renderer || ! this.scene || ! this.camera ) {
return;
}
const now = performance.now();
const dt = Math.min( ( now - this.prevTime ) / 1000, 0.1 );
this.prevTime = now;
if ( this.hasMovementInput() ) {
this.requestRender();
}
if ( ! this.needsRender ) {
return;
}
this.needsRender = false;
this.updateMovement( dt );
for ( const surface of this.surfaces ) {
surface.mirrorCtx.clearRect( 0, 0, surface.mirror.width, surface.mirror.height );
surface.mirrorCtx.drawImage( surface.staging, 0, 0 );
surface.texture.needsUpdate = true;
}
this.renderer.render( this.scene, this.camera );
};
/**
* Renders the viewport, the crosshair and hints, and the clipped staging canvas that hosts the live sign-up form.
*
* The staging markup is identical across both paths so the experimental draw has real DOM to capture; the mode note is the one piece that changes, naming whether the monitor is live or a static rendering.
*/
render() {
const live = this.textureMode === 'live';
return html`
<canvas
class="viewport"
role="img"
tabindex="0"
aria-label="A navigable 3D office with a monitor showing a sign-up form. Click the scene, then walk with WASD or the arrow keys."
></canvas>
<div class="control-hint">${ this.controlHintText() }</div>
<div class="crosshair"></div>
<div class="interaction-hint"></div>
<div class="mode-note">
${ live
? html`<span class="hl">Live.</span> The monitor is our real sign-up form, captured into a <code>CanvasTexture</code> every frame. Aim and click a field to type.`
: html`The monitor shows a static rendering of our form. The live, focusable form needs the flag at <code>chrome://flags/#canvas-draw-element</code>; the room stays navigable either way.` }
</div>
<!--
The staging form is an offscreen texture-capture source, not an operable surface. It is aria-hidden so the captured DOM is not double-exposed in the accessibility tree, and every control carries tabindex="-1" so a keyboard Tab cannot land on this 1px clipped form. The bridge still focuses fields programmatically via target.focus() and activates buttons via .click(), and the browser delivers keystrokes and runs native validation and submit on the focused input, so removing the controls from the tab order leaves the research interaction bridge working. This is the deliberate, proportionate choice for a Research-tier demo of texture capture plus hand-written keyboard delivery, not accessible 3D interaction.
-->
<div class="staging-container" aria-hidden="true">
<canvas class="staging monitor-canvas" width="760" height="488">
<div class="monitor-content">
<div class="monitor-inner">${ this.renderMonitorForm() }</div>
</div>
</canvas>
</div>
`;
}
/**
* Renders the on-monitor form, or the local confirmation once a valid submit has happened.
*
* The markup is the room's own plain-DOM form, a name input, an email input, a topic select, and a submit, with native `required` and `type="email"` validation, so the drawn screen is a real control rather than a redraw.
* The text fields start empty with placeholder examples, so anything in them is the reader's own typing and native `required` validation has something honest to catch on submit.
* The confirmation is rendered in place with no network, the way submitting the flat form yields its own confirmation, which is how the demo proves the drawn screen is real interactive HTML.
*/
private renderMonitorForm() {
if ( this.submitted ) {
return html`
<div class="monitor-confirm">
<h3>Configuration saved</h3>
<dl>
<dt>Name</dt>
<dd>${ this.submitted.name }</dd>
<dt>Email</dt>
<dd>${ this.submitted.email }</dd>
<dt>Topic</dt>
<dd>${ this.submitted.topic }</dd>
</dl>
<button type="button" class="monitor-again" tabindex="-1" @click=${ this.onResetForm }>Configure another</button>
</div>
`;
}
return html`
<div class="monitor-eyebrow">Sign up</div>
<h3 class="monitor-title">Drop us a note</h3>
<form class="monitor-form" @submit=${ this.onMonitorSubmit }>
<div class="monitor-field">
<label for="monitor-name">Your name</label>
<input id="monitor-name" name="name" type="text" required autocomplete="off" tabindex="-1" placeholder="Jane Doe" />
</div>
<div class="monitor-field">
<label for="monitor-email">Email</label>
<input id="monitor-email" name="email" type="email" required autocomplete="off" tabindex="-1" placeholder="jane@example.com" />
</div>
<div class="monitor-field">
<label for="monitor-topic">What is on your mind?</label>
<select id="monitor-topic" name="topic" tabindex="-1">
<option value="HTML-in-Canvas feedback">HTML-in-Canvas feedback</option>
<option value="A demo idea">A demo idea</option>
<option value="Just saying hi">Just saying hi</option>
</select>
</div>
<button class="monitor-submit" type="submit" tabindex="-1">Save configuration</button>
<div class="monitor-fineprint">A real form, drawn onto a monitor. No network, nothing leaves the page.</div>
</form>
`;
}
/**
* Shows the local confirmation when the monitor form passes native validation, with no network, then repaints the surface.
*
* The native `submit` event already gates on the `required` and email constraints, so reaching this handler means the fields are well-formed.
*
* @param event - The monitor form's native submit event.
*/
private onMonitorSubmit = ( event: SubmitEvent ): void => {
event.preventDefault();
const $form = event.currentTarget;
if ( ! ( $form instanceof HTMLFormElement ) ) {
return;
}
const data = new FormData( $form );
this.submitted = {
name: String( data.get( 'name' ) ?? '' ),
email: String( data.get( 'email' ) ?? '' ),
topic: String( data.get( 'topic' ) ?? '' ),
};
this.$monitorCanvas.requestPaint?.();
};
/**
* Clears the confirmation and returns to the empty form, so the demo can be run again without a reload, then repaints the surface.
*/
private onResetForm = (): void => {
this.submitted = null;
this.$monitorCanvas.requestPaint?.();
};
}
declare global {
interface HTMLElementTagNameMap {
'configurator-3d-room': Configurator3dRoom;
}
}Those three demos are the ceiling of what the frontier reaches for. The rest of this piece is the floor and the staircase between them, the version that ships today and the repeatable way a senior decides whether any of that frontier is production, progressive enhancement, or research. The plan is to build the shippable version first, a canvas scene with the controls as real HTML over it, and only then feature-detect the experimental path as an enhancement on top. By the end you’ll have a configurator you can ship today, a clear picture of what HTML-in-Canvas would change, and a method you can point at the next frontier API too.
A quick note on letting AI code this
Hand “build a canvas product configurator” to an AI and you’ll get working code fast, so do that. The catch is that the prompt is underspecified, and a perfectly working answer can still be wrong, because nobody asked which parts of that screen need the browser’s built-in behavior. The generic answer draws the controls and moves on. The good answer knows to ask whether a control should be the DOM instead, to feature-detect drawElementImage on the live context rather than guess at the name, to sync the returned transform every frame, to keep the overlay aligned across a device-pixel-ratio change, and to treat a flag-gated origin trial as an enhancement rather than a shipping decision. Those questions are the value you add, and they get more valuable as the models improve, because the bottleneck stops being the code and becomes knowing what good looks like for this exact problem.
The contracts hiding in one canvas
A canvas invites you to start drawing the interface. Hiding in it is a stack of separate contracts, each with its own answer to who should own it now that the page is half pixels and half DOM. Sort them before writing anything, because the answer changes per row, and getting it wrong is how you rebuild something the browser already does or ship something it doesn’t.
| Concern | Canvas should own | HTML should own | Senior review question |
|---|---|---|---|
| Scene rendering | Yes | No | Is this visual data, or is it interface? |
| Text labels | Sometimes | Usually | Does the user need to select, translate, zoom, or hear this? |
| Buttons | No | Yes | Why rebuild focus, hover, active, and disabled by hand? |
| Forms | No | Yes | Are we throwing away validation, submission, and autofill? |
| Focus and tab order | Rarely | Yes | What is the keyboard path through this screen? |
| Hit testing | Shared | Shared | Which layer owns selection, and which owns the action? |
| Accessibility | Provides the fallback | Yes | What is actually in the accessibility tree? |
| Screenshot and export | Canvas | Maybe | Are we rendering for a user, or exporting an image? |
The judgment in those rows is what carries over. A new API like HTML-in-Canvas just moves where it lives. The question stops being “can I draw this?” and splits in two, should this be drawn, and if it is, what did I just take away from the browser?
How to read an experimental API page
The durable skill comes before any more code. When you land on the explainer for a new primitive, don’t read it only for syntax. Read it for ownership. The checklist I run is the part of this piece worth keeping when the specific API has moved on:
- What behavior did our old code recreate?
- Does the new API preserve that behavior, or only make the demo look cleaner?
- Can the product ship without it?
- What fails when the feature is unavailable?
- What do I need to test by hand, because the docs can’t prove it?
Run HTML-in-Canvas through it. Status first, and the honest read as of today is single-engine: available in Chromium through an origin trial, and locally testable behind chrome://flags/#canvas-draw-element, not in Firefox or Safari. Mozilla keeps an open position request with fingerprinting and compatibility concerns raised and nothing filed, and WebKit hasn’t taken a formal position on the current proposal either, though its thread on the withdrawn precursor carried complexity and API-design concerns, and the current position request itself notes the precursor’s retained-mode concern was addressed by returning to an immediate-mode API. Not Baseline, not a shipping decision. The problem it tackles is real, drawing styled, laid-out HTML into a canvas while keeping the parts the browser owns. The non-goals hide under “future considerations” and “read-back-allowed rendering” rather than a heading that says non-goals. The drawn image deliberately omits cross-origin content, url() references, visited-link state, and pending autofill, which is the privacy answer to that fingerprinting concern. And the ownership words are right there in the surface, layoutsubtree, drawElementImage, the paint event, and the line about accessibility and hit testing relying on the element’s DOM location.
One reader-facing rule covers the rest of the standards history. Frontier APIs move, so feature-detect the exact methods you call and re-check the live IDL before you publish. This one has already churned from drawElement to drawElementImage, and the flag still carries the old name, so detecting yesterday’s spelling is its own trap.
The bar we’re holding it to
The finished configurator should let you test, across all three ownership models:
- keyboard focus and a coherent tab order,
- pointer hit testing, where the canvas owns selection and HTML owns the action,
- text selection and native menus where the content is real text,
- form controls that validate and submit,
- what’s actually in the accessibility tree,
- device pixel ratio and canvas scaling, drawn text versus real DOM,
- feature detection of the experimental API on the live context, with the correct method name,
- a fallback that preserves the product when the API is missing.
It should make the failures visible rather than hide them. A canvas-only mode shows an empty tab order and a zero focus count, the overlay drifts on a DPR change when the sync is naive, and the experimental path with its transform sync omitted puts the focus ring off the element. A single flow to hold it to: click a part, then Tab, and the next stop is a real control for that part, with the inspector after it, all without touching the mouse.
What you’ll need
Be comfortable with TypeScript and the DOM. No Lit experience required, since it’s a thin layer over web components and I’ll explain the Lit bits inline. Beyond that the build leans on Lit, TypeScript, and Three.js for the lamp itself, plus your browser devtools (the Accessibility panel to see what’s in the tree, the Elements panel to watch the overlay) and a screen reader for a few honest minutes. For the experimental tier, a Chromium build with chrome://flags/#canvas-draw-element set to Enabled. No design tool, because the styling is incidental and the teaching is behavior, placement, and accessibility.
Try it yourself first
Give it a real attempt before reading on. Start from the version that ships, a WebGL canvas for the 3D lamp, with real HTML buttons positioned over it for the controls rather than drawn into it. Cast a ray from the camera through the click to pick the part under it, since there’s no built-in canvas hit test. Then go looking for the hard parts, the device-pixel-ratio handling that keeps the lamp crisp and the projection math that keeps a label glued to a part as the lamp orbits. Those are the parts the experimental API proposes to take over, so feeling them by hand first is how you’ll judge whether the trade is worth it.
Building it
We’ll build one running configurator across the next several steps, shippable parts first, and assemble the whole component at the end, each step showing only the new or changed slice.
Step 1: the canvas-only trap
The lamp itself is the part that genuinely belongs on the canvas. It’s a small procedural desk lamp built from Three.js primitives, a tapered shade, a slim arm, a glass bulb, and a weighted base, and we keep the whole geometry build behind one helper so the lesson never turns into a Three.js tutorial:
lamp.ts
// lamp.ts, the geometry behind a helper
import { buildLamp } from './lamp';
// buildLamp returns the group to add to the scene, the four part meshes, and the label anchors.
// The geometry stays in this helper so the component body works against named parts, not primitives.That much earns the canvas, because a shaded product you can orbit is exactly what a <div> or an <svg> can’t render. The trap arrives once the scene is up, as the pull to draw the labels and the inspector onto the canvas too. Stack a flat 2D canvas over the WebGL one and paint the UI into it, and the result looks complete with no backing DOM at all:
scratch.ts
// scratch.ts, the canvas-only trap
const ctx = $overlay.getContext( '2d' )!;
/**
* Paints a part label straight onto the overlay canvas, with no backing DOM node.
*/
function paintLabel( cx: number, cy: number, label: string ): void {
ctx.font = '600 12px system-ui, sans-serif';
const width = ctx.measureText( label ).width + 18;
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.fillRect( cx - width / 2, cy - 11, width, 22 );
ctx.fillStyle = '#17171a';
ctx.fillText( label, cx - width / 2 + 9, cy + 4 );
}It renders, and a mouse click can even pick a part once you add the raycast from step 3. What it can’t do is everything the browser would have done for a real element, and each loss is a row from the contracts table:
- The accessibility tree is empty, because raw drawing doesn’t populate canvas fallback content, so a screen reader has no meaningful controls or labels to reach.
- There’s no focus and no tab order, because no focusable element exists. The Baseline
drawFocusIfNeeded( element )only rings a fallback descendant that’s already focusable; it can’t manufacture focus where there’s no element. - There’s no hit testing the platform knows about. The canvas Hit Regions API that once promised this was removed years ago, so you hand-roll the pick, a ray cast from the camera, returning a mesh with no DOM element behind it.
- The native affordances are gone, so text selection, the context menu, find-in-page, IME, and autofill all stop working.
- And the drawn text won’t re-rasterize crisply on zoom or a higher-density display, the way real text does for free.
That’s the trap from the opener, and it’s why “draw the UI” is the wrong instinct for anything a user operates.
Step 2: define the boundary
More canvas doesn’t fix it. The fix is deciding which layer owns each control, and the contracts table above is that decision, made once. The lamp stays on the canvas because it’s visual data, while the part labels and the inspector that recolors the shade, switches the base finish, and toggles the bulb are interface, so they belong to the DOM. The next step builds exactly that split, and it’s the version that ships.
Step 3: the shippable DOM overlay
This is the step that actually ships. The WebGL canvas keeps rendering the lamp, real HTML labels and an inspector are positioned over it, and a click on the canvas casts a ray from the camera to the part under it, and the selection drives a real inspector. The work the overlay owns is the whole lesson, so let’s not pretend it’s free.
A part is a 3D mesh now rather than a rectangle, so its public shape is just an id, a name, and the box the teaching harnesses still draw their flat illustrations in. The ids are a closed set, since the geometry, the projected anchors, and the inspector controls are all keyed by them. Both types go in types.ts:
types.ts
// types.ts
/**
* The id of a selectable lamp part, one of the four named meshes the procedural lamp is built from.
*
* The ids are a closed set because the lamp geometry, the part anchors the overlay projects, and the inspector controls are all keyed by them; a `SceneObject` whose id is one of these binds to its real mesh, and any other id is ignored by the scene.
*/
export type LampPartId = 'shade' | 'arm' | 'bulb' | 'base';
/**
* One selectable part of the product, named by the parent and bound to a real 3D mesh by the component.
*
* The configurator no longer draws this rectangle: the part is a Three.js mesh built procedurally from {@link LampPartId}, and the canvas owns the pixels while a raycast owns hit testing.
* The box coordinates are retained because the teaching harnesses still read them, the overlay demo to annotate where the projected labels sit and the device-pixel-ratio demo to draw its own side-by-side canvases, so they describe the part's place in the harness's flat 600 by 360 design space rather than anything the 3D scene reads.
*/
export type SceneObject = {
/**
* A stable id used to bind the part to its 3D mesh, mark the selection, and key its overlay label and inspector controls.
*
* It is typed as the open `string` rather than {@link LampPartId} so a harness can pass any scene, and the component maps the four known ids onto lamp meshes and skips the rest.
*/
id: string;
/**
* The human-readable name shown on the projected label and in the inspector heading.
*/
label: string;
/**
* The part's left edge in the harness's flat design space, in design units.
*
* Retained for the teaching harnesses that still draw in 2D; the 3D scene does not read it.
*/
x: number;
/**
* The part's top edge in the harness's flat design space, in design units.
*
* Retained for the teaching harnesses that still draw in 2D; the 3D scene does not read it.
*/
y: number;
/**
* The part's width in the harness's flat design space, in design units.
*
* Retained for the teaching harnesses that still draw in 2D; the 3D scene does not read it.
*/
width: number;
/**
* The part's height in the harness's flat design space, in design units.
*
* Retained for the teaching harnesses that still draw in 2D; the 3D scene does not read it.
*/
height: number;
};The hit test is the boundary in code, and going 3D doesn’t hand it back to the platform. There’s still no built-in canvas hit test, so you convert the pointer into normalized device coordinates, build a ray from the camera with raycaster.setFromCamera, and intersectObjects against the lamp’s meshes, walking the nearest hit up to the part it belongs to since every visible mesh carries its part’s id. This is the raycast that replaces the old isPointInPath, the same lesson moved into three dimensions:
ts
/**
* Raycasts from the camera through a pointer position and selects the lamp part it hits, or clears the selection on a miss.
*
* This is the article's 3D hit-testing lesson: there is still no built-in canvas hit test, so the pointer is converted into normalized device coordinates, `raycaster.setFromCamera` builds the ray, and `intersectObjects` against the whole lamp group resolves the nearest hit to a part id, the raycast that replaces the old `isPointInPath`.
* Every visible mesh carries the id of the part it belongs to, so a click on a cosmetic piece like the shade cap or the neck still selects; a hit whose chain carries no id, the additive glow halo, falls through to the next hit rather than eating the click, and the selection only clears when nothing in the list resolves.
* In `canvas-only` the painted UI sits on top of the scene, so its pills, swatches, and panel are hit-tested first, more controls whose geometry has to be re-derived by hand because no DOM exists to do it.
*
* @param event - The pointer event whose position to pick at.
*/
private pickAt( event: PointerEvent ): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
if ( this.activeMode === 'canvas-only' && this.pickFauxUiAt( event ) ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const ndc = new THREE.Vector2(
( ( event.clientX - rect.left ) / rect.width ) * 2 - 1,
-( ( event.clientY - rect.top ) / rect.height ) * 2 + 1,
);
this.raycaster.setFromCamera( ndc, this.camera );
const hits = this.raycaster.intersectObjects( this.lamp.group.children, true );
for ( const hit of hits ) {
const id = this.partIdForObject( hit.object );
if ( id ) {
this.select( id, 'canvas' );
return;
}
}
this.select( null, 'canvas' );
}The labels are real <button> elements over the canvas, but a part of a 3D lamp has no fixed screen position, so you can’t lay them out in CSS and walk away. Each part carries a local-space anchor (buildLamp hands those back beside the meshes), and on every frame the lamp renders you take that anchor into world space through the lamp’s matrix, project it with the camera, and convert the normalized device coordinates into CSS pixels against the canvas rect. That projection is the alignment bookkeeping the overlay owns, and it’s what keeps a label glued to its part as the lamp orbits:
index.ts
// index.ts, projecting each part anchor to a CSS-pixel screen position
/**
* Projects each part's 3D anchor to a CSS-pixel screen position and stores it, then repaints the flat overlay for the active mode.
*
* This is the alignment bookkeeping the article says the overlay owns: the anchor is taken into world space through the lamp's matrix, projected with the camera, and converted from normalized device coordinates to CSS pixels against the canvas rect, which keeps the projection device-pixel-ratio correct because the rect is in CSS pixels while the WebGL backing store carries the ratio separately.
*/
private syncOverlay(): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
this.lamp.group.updateMatrixWorld();
const next: Record<string, { x: number; y: number; visible: boolean }> = {};
for ( const id of PART_IDS ) {
this.projected.copy( this.lamp.anchors[ id ] );
this.lamp.group.localToWorld( this.projected );
this.projected.project( this.camera );
next[ id ] = {
x: ( this.projected.x * 0.5 + 0.5 ) * width,
y: ( -this.projected.y * 0.5 + 0.5 ) * height,
visible: this.projected.z < 1,
};
}
this.anchorPoints = next;
this.paintFlatOverlay();
}A click on the canvas selects through the raycast, and so does a click on a label, which is the path a keyboard user takes, since the canvas click needs a pointer.
Poke at this one. Drag to orbit the lamp, click a part, and the inspector updates, where you can recolor the shade, switch the base finish, and toggle the bulb to relight the scene. Toggle the DOM boxes on to see the real nodes tracking the parts as you turn the model, and Tab through them to land in the inspector, since the labels are in the tab order. The hit-testing log names the layer that fired each pick, a raycast resolving to a part versus an overlay button doing the same, so the canvas-owns-selection and HTML-owns-the-action split is visible as you go.
import { LitElement, html, css, nothing } from 'lit';
import { supportsHtmlInCanvas } from '@demos/supports-html-in-canvas';
import { customElement, property, state, query } from 'lit/decorators.js';
import * as THREE from 'three';
import { buildLamp, disposeLamp, setBaseFinish, setBulbState, setShadeColor, STUDIO_PALETTES } from './lamp';
import type { LampHandle } from './lamp';
import type {
BaseFinish,
ConfiguratorConfigChangeDetail,
ConfiguratorLayout,
ConfiguratorMode,
ConfiguratorSelectDetail,
ConfiguratorSelectSource,
ConfiguratorTheme,
LampConfig,
LampPartId,
ProductConfiguratorEventMap,
SceneObject,
ShadeSwatch,
} from './types';
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here.
* Writing the returned matrix into the element's `style.transform` is what keeps focus, hit testing, and accessibility geometry on the drawn pixels.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface OffscreenCanvasRenderingContext2D {
/**
* The offscreen mirror of {@link CanvasRenderingContext2D.drawElementImage}, declared for completeness so a worker path type-checks too.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface HTMLCanvasElement {
/**
* Opts the canvas in to the experimental model, so its direct children lay out and become drawable and hit-testable.
*
* Reflects the `layoutsubtree` HTML attribute, and is absent from `lib.dom.d.ts` because the feature is still flag-gated.
*/
layoutSubtree: boolean;
/**
* Forces a `paint` event on the next frame, the way `requestAnimationFrame` schedules a draw.
*/
requestPaint(): void;
}
}
/**
* The swatches the shade can be recolored to, a small neutral-to-bold range so the recolor reads in both themes.
*
* It is a module constant rather than a property because the swatch set is part of the lamp's design, not data a harness owns, and the first entry is the default the lamp opens on.
*/
const SHADE_SWATCHES: ReadonlyArray<ShadeSwatch> = [
{ id: 'porcelain', label: 'Porcelain', hex: '#e9e7e0' },
{ id: 'cobalt', label: 'Cobalt', hex: '#2257e6' },
{ id: 'moss', label: 'Moss', hex: '#3f7d5a' },
{ id: 'clay', label: 'Clay', hex: '#c2683f' },
];
/**
* The aspect ratio of the canvas, its height as a fraction of its width, so the stage reserves the right height before WebGL sizes itself.
*/
const CANVAS_ASPECT = 0.62;
/**
* The labelled parts the lamp draws, in the order the overlay stacks them, so the four ids the geometry and inspector know about have a single declared source.
*/
const PART_IDS: ReadonlyArray<LampPartId> = [ 'shade', 'arm', 'bulb', 'base' ];
/**
* A shared black color reused when a part's selection highlight is cleared, so the highlight reset allocates nothing per frame.
*/
const BLACK = new THREE.Color( 0x000000 );
/**
* A real-time 3D product configurator: a procedural desk lamp in a WebGL canvas, with a raycast selection, an orbit drag, and an inspector, that moves its UI between three ownership models.
*
* The lamp is graphics the DOM cannot draw, owned by the canvas, and the labels and inspector are interface, owned by the DOM, so `mode` decides which layer carries them.
* `canvas-only` paints the labels and a faux inspector onto a flat overlay canvas with no focusable DOM, the trap; `dom-overlay` is the version that ships, projecting each part's 3D anchor to a real HTML label kept glued as the lamp orbits, with the real inspector beside it; `html-in-canvas` feature-detects the experimental `drawElementImage` path, draws the real label nodes into the flat overlay canvas, and writes the returned alignment transform back, falling back to `dom-overlay` when the API is absent.
* Selection is a raycast from the camera, the 3D replacement for the old 2D hit test, and the inspector recolors the shade, switches the base finish, and toggles the bulb, which relights the scene.
*
* @element product-configurator
* @slot header - Sits above the stage in the left column, for a harness caption over the canvas.
* @slot panel - Stacks under the real inspector in the right column of the `split` layout, for a harness control panel beside the canvas.
* @slot footer - Spans both columns under the stage in the `split` layout, for a tall report or log that would otherwise strand the canvas column if stacked in the narrow side column.
* @fires object-select - Fired when the selected part changes, carrying a `ConfiguratorSelectDetail`.
* @fires config-change - Fired after an inspector edit, carrying the whole `LampConfig`.
* @cssprop [--configurator-accent=#2257e6] - The selection and focus color used by the labels and the inspector.
*/
@customElement( 'product-configurator' )
export class ProductConfigurator extends LitElement {
/**
* The selectable parts, the four named meshes of the lamp.
*
* It is `attribute: false` because an array binds as a property (`.objects=${parts}`) and never round-trips through a string attribute.
* It has no default, since inventing the parts inside the component would hide the contract that the parent names the scene; the component builds a mesh for each known id and skips any other.
*/
@property( { attribute: false } )
objects: SceneObject[] = [];
/**
* Which ownership model the configurator renders.
*
* `html-in-canvas` is a request, not a guarantee: when the experimental API is absent the component renders `dom-overlay` instead, so the selected mode and the active mode can differ.
*/
@property( { type: String } )
mode: ConfiguratorMode = 'dom-overlay';
/**
* How the canvas and the inspector are arranged.
*
* Defaults to `stacked` so a standalone embed keeps the inspector under the canvas; a harness sets `split` to get the glass-demo shape, the canvas in a left column and the inspector plus a `panel` slot in one right column, collapsing back to a single stacked column on a narrow width.
*/
@property( { type: String } )
layout: ConfiguratorLayout = 'stacked';
/**
* Whether to deliberately skip writing the alignment transform back to the drawn label.
*
* Teaching switch for the experimental path: with the sync omitted, the focus ring and click target drift off the drawn pixels, which is the failure the article makes visible.
* It does nothing outside `html-in-canvas` mode on a supporting build.
*/
@property( { type: Boolean, attribute: 'omit-transform-sync' } )
omitTransformSync = false;
/**
* The id of the currently selected part, or `null` when nothing is selected.
*
* It is a controllable property: leave it unset and the component manages selection itself from a raycast or a keyboard pick, or bind it (`.selectedId=${id}`) to drive several configurators from one shared selection, the way the review demo syncs three modes.
* Either way consumers also hear changes through the `object-select` event, and the component writes the property on every pick so the uncontrolled case stays in sync.
*/
@property( { attribute: 'selected-id' } )
selectedId: string | null = null;
/**
* The viewport canvas Three.js renders the lamp into, resolved by Lit after the first render.
*/
@query( '.viewport' )
private $viewport!: HTMLCanvasElement;
/**
* The flat 2D overlay canvas stacked over the WebGL canvas, used to paint the faux UI in `canvas-only` and to host the drawn labels in `html-in-canvas`.
*/
@query( '.overlay-canvas' )
private $overlayCanvas?: HTMLCanvasElement;
/**
* The selected page theme, resolved on connect and kept in sync at runtime, so the studio is lit to match the page in light and dark.
*
* It is `@state` so a runtime theme flip re-renders, and the scene recolors through {@link applyTheme} rather than a rebuild.
*/
@state()
private theme: ConfiguratorTheme = 'light';
/**
* The lamp's configurable state, the shade swatch, the base finish, and the bulb, edited by the inspector.
*
* It is `@state` so an edit re-renders the inspector and re-runs the overlay draw, and it opens on the first swatch with a matte base and a lit bulb so the lamp reads as switched on.
*/
@state()
private config: LampConfig = {
shadeSwatchId: SHADE_SWATCHES[ 0 ].id,
baseFinish: 'matte',
bulbOn: true,
};
/**
* Whether the live 2D context exposes the experimental `drawElementImage` method.
*
* Resolved on connect from a scratch canvas, so it gates the experimental path per render rather than trusting the requested `mode`.
* It is `@state` so flipping it triggers the re-render that mounts the drawn label nodes, since `activeMode` reads it during `render`.
*/
@state()
private supportsHtmlInCanvas = false;
/**
* The Three.js scene, camera, and renderer, created once the viewport exists.
*/
private scene: THREE.Scene | null = null;
private camera: THREE.PerspectiveCamera | null = null;
private renderer: THREE.WebGLRenderer | null = null;
/**
* The built lamp and the handles to its parts, materials, and light, or `null` before the scene is built.
*/
private lamp: LampHandle | null = null;
/**
* The studio floor mesh, kept so its geometry and material can be disposed on teardown.
*/
private floorMesh: THREE.Mesh | null = null;
/**
* The studio floor material, kept so a theme change can recolor it in place.
*/
private floorMaterial: THREE.MeshStandardMaterial | null = null;
/**
* The themed studio lights, kept so a theme change can dim or lift them to match the palette.
*/
private themedLights: {
ambient: THREE.AmbientLight | null;
key: THREE.DirectionalLight | null;
rim: THREE.DirectionalLight | null;
} = { ambient: null, key: null, rim: null };
/**
* The reusable raycaster for the part-selection pick, allocated once.
*/
private readonly raycaster = new THREE.Raycaster();
/**
* A reusable vector for projecting a part anchor, so the overlay sync allocates nothing per frame.
*/
private readonly projected = new THREE.Vector3();
/**
* The overlay canvas 2D context, cached after the first paint so the per-frame draw does not re-acquire it.
*/
private overlayCtx: CanvasRenderingContext2D | null = null;
/**
* The CSS-pixel size the overlay backing store was last sized for, so it is only resized when the box actually changes rather than every frame.
*/
private overlaySize = { width: 0, height: 0, dpr: 0 };
/**
* The resolved selection accent as a Three.js color, cached so the per-frame highlight does not call `getComputedStyle`, and refreshed on a theme change.
*/
private readonly accent = new THREE.Color( 0x2257e6 );
/**
* The themed colors the flat overlay paints with, cached and refreshed on a theme change so the canvas-only and drawn-label paints do not read the DOM every frame.
*
* The canvas cannot inherit a CSS color, so these mirror the tokens the rest of the page uses, resolved once per theme rather than once per frame.
*/
private overlayColors = {
ink: '#17171a',
surface: '#ffffff',
accent: '#2257e6',
accentInk: '#ffffff',
fontSans: 'system-ui, sans-serif',
fontMono: 'monospace',
};
/**
* The current screen position of each part's overlay label, in CSS pixels, recomputed on every rendered frame while the lamp is on screen.
*
* It is the bridge from the 3D scene to the 2D layers: the DOM-overlay labels read it for their `left` and `top`, and the canvas-only and drawn-label paints read it to place text, so all three layers stay glued to the orbiting model from one projection.
*/
@state()
private anchorPoints: Record<string, { x: number; y: number; visible: boolean }> = {};
/**
* The orbit yaw and pitch in radians, driven by the drag and applied to the lamp group each frame.
*
* The lamp rotates rather than the camera, so the studio lighting stays fixed and the part the reader turns toward stays lit; the pitch is bounded so the lamp never tips past a readable angle.
*/
private orbit = { yaw: 0.5, pitch: 0.05 };
/**
* Whether a drag-to-orbit gesture is in progress, and the last pointer position it was sampled at.
*/
private isDragging = false;
/**
* The id of the pointer that started the current gesture, so a second finger or pen mid-drag cannot steer, end, or cancel a gesture it does not own.
*
* `isPrimary` is not enough as a filter, since touch and pen each get their own primary pointer; the id is the one key that names this exact contact.
*/
private activePointerId: number | null = null;
private lastPointer = { x: 0, y: 0 };
/**
* Whether the pointer moved past a small threshold during the current gesture, so a press-and-release in place still counts as a select rather than being eaten as a drag.
*/
private dragMoved = false;
/**
* Whether the viewport is currently on screen, so the render loop only runs while the demo is visible.
*/
private isVisible = true;
/**
* Whether {@link setUp} has wired the scene, listeners, and observers, so its two entry points, the first update and a reconnect, can never double-wire them.
*/
private isSetUp = false;
/**
* The media query that fires when `devicePixelRatio` changes, so the renderer and the overlay can be re-corrected on a zoom or a move to a different-density display.
*
* It is recreated on each change because the query is pinned to a single resolution, which is the lifecycle the canvas makes the author own.
*/
private dprQuery: MediaQueryList | null = null;
/**
* The media query the studio watches so it follows the OS color scheme when the page has not pinned a theme, retained so its listener tears down on disconnect.
*/
private themeMedia: MediaQueryList | null = null;
/**
* The observer watching `data-theme` on the document element, so an in-page theme toggle re-lights the studio at runtime, retained so it disconnects cleanly.
*/
private themeObserver: MutationObserver | null = null;
/**
* The resize and intersection observers, tracked so they tear down cleanly on disconnect.
*/
private resizeObserver: ResizeObserver | null = null;
private intersectionObserver: IntersectionObserver | null = null;
/**
* The animation-frame handle, cleared on disconnect or when the demo scrolls offscreen so a hidden lamp stops its loop.
*/
private frame = 0;
/**
* Whether the next animation frame has work to do.
*
* Nothing in the scene self-animates, so the loop keeps its frame cadence but skips the render and the overlay sync until an orbit, a selection, a config edit, a theme flip, or a resize marks the scene dirty through {@link requestRender}; label-only rendering changes in the experimental mode, a focus ring appearing on a drawn label, arrive through the canvas `paint` event and never need the flag.
*/
private needsRender = false;
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
:host {
display: block;
/* The host is a size container, so the split layout collapses on the embed's own width rather than the viewport's. */
container-type: inline-size;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
/* Resolve the selection accent into a private name; reading --configurator-accent first, then the themed --accent, then the brand, avoids the self-reference cycle that redefining --accent here would cause when a host sets --configurator-accent to the page accent. */
--cfg-accent: var( --configurator-accent, var( --accent, #2257e6 ) );
}
/* The stacked default keeps the canvas and inspector in one column; the split layout puts the canvas on the left and the inspector plus the harness panel in one right column, the glass-demo shape. */
.layout {
display: grid;
gap: var( --space-4, 16px );
}
.layout.split {
grid-template-columns: minmax( 0, 1fr ) 17rem;
grid-template-areas:
'main side'
'footer footer';
align-items: start;
}
.layout.split .main {
grid-area: main;
}
.layout.split .side {
grid-area: side;
}
/* The tall trailing content spans both columns under the stage, so a long report no longer strands the canvas column. */
.layout.split .footer {
grid-area: footer;
}
/* The left column: anything slotted into the header sits above the stage, and the stage carries the canvas. */
.main {
display: flex;
flex-direction: column;
gap: var( --space-3, 12px );
min-width: 0;
}
/* The header slot collapses to nothing when the harness slots no caption, so a bare embed shows just the stage. */
slot[name='header']::slotted( * ) {
margin: 0;
}
/* The right column of the split layout: the real inspector stacked above whatever the harness slots in, so every control reads as one column beside the canvas. */
.side {
display: flex;
flex-direction: column;
gap: var( --space-4, 16px );
min-width: 0;
}
.layout.split .inspector {
margin-top: 0;
}
/* Collapse to a single stacked column when the embed itself is too narrow for a comfortable two-column split. */
@container ( width <= 38rem ) {
.layout.split {
grid-template-columns: minmax( 0, 1fr );
grid-template-areas:
'main'
'side'
'footer';
}
}
.stage {
position: relative;
width: 100%;
aspect-ratio: 100 / 62;
border-radius: var( --radius-md, 11px );
border: 1px solid var( --line, #e7e6e2 );
overflow: hidden;
background: var( --surface, #fff );
}
canvas.viewport {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.stage.dragging canvas.viewport {
cursor: grabbing;
}
/* The flat overlay canvas sits exactly over the WebGL canvas in CSS-pixel space, the painted faux UI in canvas-only and the drawn-label host in html-in-canvas. */
canvas.overlay-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* The DOM-overlay layer sits over the canvas in CSS-pixel space, the bookkeeping the experimental API folds into the platform. */
.overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.overlay .label {
position: absolute;
transform: translate( -50%, -50% );
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
.overlay .label:hover {
border-color: var( --cfg-accent );
}
.overlay .label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
.overlay .label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
/*
* The drawn labels are real children of the overlay canvas in the experimental path: laid out and hit-testable, never display:none, anchored at the canvas origin with style.transform reserved for the platform alignment matrix that places and keeps them on the drawn pixels.
* Their look is kept identical to the DOM-overlay label above, the small light pill with ink text and a hairline border, so the only difference a reader can tell between the two modes is the mechanism, not the size or color of the pill.
*/
canvas.overlay-canvas .drawn-label {
position: absolute;
top: 0;
left: 0;
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
canvas.overlay-canvas .drawn-label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
canvas.overlay-canvas .drawn-label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.inspector {
margin-top: var( --space-3, 12px );
padding: var( --space-4, 16px );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-md, 11px );
background: var( --surface, #fff );
}
.inspector .head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var( --space-3, 12px );
margin-bottom: var( --space-3, 12px );
}
.inspector h3 {
margin: 0;
font-size: 14px;
color: var( --ink, #17171a );
}
.inspector .part-tag {
font-family: var( --font-mono, monospace );
font-size: 11px;
color: var( --ink-faint, #71717b );
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var( --space-3, 12px );
}
.field:last-child {
margin-bottom: 0;
}
.field > .field-label {
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var( --ink-faint, #71717b );
}
.swatches {
display: flex;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
border: 2px solid var( --line, #e7e6e2 );
cursor: pointer;
}
.swatch[aria-pressed='true'] {
border-color: var( --cfg-accent );
box-shadow: 0 0 0 2px var( --surface, #fff ), 0 0 0 4px var( --cfg-accent );
}
.segmented {
display: inline-flex;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-sm, 7px );
overflow: hidden;
width: max-content;
}
.segmented button {
padding: 7px 14px;
font: inherit;
font-size: 13px;
border: 0;
background: var( --bg, #fcfcfb );
color: var( --ink, #17171a );
cursor: pointer;
}
.segmented button + button {
border-left: 1px solid var( --line, #e7e6e2 );
}
.segmented button[aria-pressed='true'] {
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 13px;
color: var( --ink, #17171a );
cursor: pointer;
}
.switch .track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: var( --line, #e7e6e2 );
transition: background 0.2s ease;
flex: none;
}
.switch .track.on {
background: var( --cfg-accent );
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s ease;
}
.switch .track.on .knob {
transform: translateX( 16px );
}
`;
/**
* Resolves the page theme and the experimental-API support before the first render, so the studio opens in the page's palette and the first render already knows the active mode.
*
* On a reconnect it also re-runs {@link setUp}: the shadow DOM survives a re-parenting, so `hasUpdated` stays true, `firstUpdated` never fires again, and the wiring {@link tearDown} removed has to come back here, with the `@query` fields still resolving into the kept shadow root.
*/
connectedCallback(): void {
super.connectedCallback();
this.theme = this.detectTheme();
this.resolveSupport();
if ( this.hasUpdated ) {
this.setUp();
}
}
/**
* Runs the shared {@link setUp} once the viewport canvas exists after the first render.
*/
protected firstUpdated(): void {
this.setUp();
}
/**
* Builds the scene, wires the controls, observers, and theme listeners, and kicks the loop.
*
* It is shared by `firstUpdated` and a reconnect, because `firstUpdated` only ever fires once: without `connectedCallback` re-running this, a configurator moved to a new parent would come back with everything {@link tearDown} released still gone.
* The `isSetUp` guard keeps the two entry points from wiring the scene twice.
* It also refuses to wire a detached element: an append and remove in the same task still runs the pending first update after disconnection, and window listeners wired there would never hear the disconnect that already happened.
*/
private setUp(): void {
if ( this.isSetUp || ! this.isConnected ) {
return;
}
this.isSetUp = true;
this.refreshAccent();
this.initScene();
this.applySelectionHighlight();
this.bindEvents();
this.bindThemeListeners();
this.observeDevicePixelRatio();
this.startLoop();
}
/**
* Re-runs the overlay paint whenever a property that changes the flat layers updates, so a mode switch, a selection, or a config edit repaints the canvas-only UI or the drawn labels.
*
* A selection, mode, or objects change also marks the scene dirty through {@link requestRender}, so the gated loop draws the change on its next frame.
* The 3D scene redraws itself in the loop, so this only touches the flat overlay, and only when the loop is paused does it need to force one; the guarded call keeps a visible, animating demo from double-painting.
* The `anchorPoints` guard is what keeps a paused offscreen demo from looping: {@link syncOverlay} writes the `anchorPoints` `@state`, which schedules another update, so re-running `syncOverlay` on that follow-up would project, write `anchorPoints` again, and never terminate; skipping the call when the only change is `anchorPoints` lets a real change like a theme, selection, mode, or config edit sync the overlay exactly once and then settle.
*/
protected updated( changed: Map<PropertyKey, unknown> ): void {
if ( changed.has( 'selectedId' ) ) {
this.applySelectionHighlight();
this.requestRender();
}
if ( changed.has( 'mode' ) || changed.has( 'objects' ) ) {
this.requestRender();
}
if ( this.frame === 0 && ! changed.has( 'anchorPoints' ) ) {
this.syncOverlay();
}
}
/**
* Tears the configurator down through the shared {@link tearDown} when it leaves the document.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.tearDown();
}
/**
* Stops the loop and tears down every observer, listener, and the WebGL context so a removed configurator leaks nothing.
*
* It is the mirror of {@link setUp} rather than the body of `disconnectedCallback`, so a reconnected configurator can rebuild exactly what this released; clearing `isSetUp` is what re-arms that rebuild.
*/
private tearDown(): void {
this.isSetUp = false;
this.stopLoop();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
this.dprQuery = null;
this.themeMedia?.removeEventListener( 'change', this.onThemeMediaChange );
this.themeMedia = null;
this.themeObserver?.disconnect();
this.themeObserver = null;
this.$viewport?.removeEventListener( 'pointerdown', this.onPointerDown );
window.removeEventListener( 'pointermove', this.onPointerMove );
window.removeEventListener( 'pointerup', this.onPointerUp );
window.removeEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.removeEventListener( 'paint', this.onOverlayPaint );
if ( this.lamp ) {
disposeLamp( this.lamp );
this.lamp = null;
}
this.floorMesh?.geometry.dispose();
this.floorMesh = null;
this.floorMaterial?.dispose();
this.floorMaterial = null;
this.renderer?.dispose();
this.renderer = null;
// The window listeners that would release a live gesture are gone now, so the flags reset here; a stale drag would otherwise let plain hover motion orbit the lamp after a reconnect, since a mouse keeps a stable pointerId.
this.isDragging = false;
this.dragMoved = false;
this.activePointerId = null;
}
/**
* The mode actually rendered, which is the requested mode unless `html-in-canvas` was asked for without support.
*
* This is the single place the requested-versus-active split is resolved, so the template and the overlay paint agree on which layer owns the labels.
*/
private get activeMode(): ConfiguratorMode {
if ( this.mode === 'html-in-canvas' && ! this.supportsHtmlInCanvas ) {
return 'dom-overlay';
}
return this.mode;
}
/**
* The studio palette for the current theme, the one object the scene materials and lights read.
*/
private get palette() {
return STUDIO_PALETTES[ this.theme ];
}
/**
* The four selectable parts that map onto a real lamp mesh, in the lamp's stacking order.
*
* The component only knows the four lamp ids, so it filters the bound scene to them and keeps them in a stable order, which keeps the overlay labels and the raycast list aligned with the geometry the helper built.
*/
private get parts(): SceneObject[] {
const byId = new Map( this.objects.map( ( object ) => [ object.id, object ] ) );
const ordered: SceneObject[] = [];
for ( const id of PART_IDS ) {
const object = byId.get( id );
if ( object ) {
ordered.push( object );
}
}
return ordered;
}
/**
* The selected part, or `undefined` when nothing is selected or the selection is not a known lamp part.
*/
private get selectedPart(): SceneObject | undefined {
return this.parts.find( ( part ) => part.id === this.selectedId );
}
/**
* Records whether the drawn-label path can run, through the shared {@link supportsHtmlInCanvas} predicate that feature-detects the full path, `drawElementImage` on a live 2D context and `requestPaint` on the canvas, rather than sniffing a UA string.
*
* It runs from `connectedCallback`, since the probe needs only `document`, so the first render already knows the answer instead of flipping the mode an update cycle later.
*/
private resolveSupport(): void {
this.supportsHtmlInCanvas = supportsHtmlInCanvas();
}
/**
* Resolves the page theme the same way the demo page decides it: an explicit `data-theme` on the document element wins, and the OS color scheme is the fallback when nothing is pinned.
*
* @returns The theme the studio should light for right now.
*/
private detectTheme(): ConfiguratorTheme {
const pinned = document.documentElement.dataset.theme;
if ( pinned === 'dark' || pinned === 'light' ) {
return pinned;
}
return window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light';
}
/**
* The current size of the viewport canvas in CSS pixels, read from the element so it is right in both standalone and embedded contexts.
*/
private viewportSize(): { width: number; height: number } {
return {
width: this.$viewport.clientWidth || 600,
height: this.$viewport.clientHeight || Math.round( 600 * CANVAS_ASPECT ),
};
}
/**
* Creates the scene, camera, renderer, the studio floor and lighting, and the lamp, then frames the camera on the lamp.
*/
private initScene(): void {
const palette = this.palette;
const scene = new THREE.Scene();
scene.background = new THREE.Color( palette.background );
this.scene = scene;
const size = this.viewportSize();
const camera = new THREE.PerspectiveCamera( 38, size.width / size.height, 0.1, 100 );
camera.position.set( 0, 0.55, 5.6 );
camera.lookAt( 0, 0.26, 0 );
this.camera = camera;
const renderer = new THREE.WebGLRenderer( { canvas: this.$viewport, antialias: true, alpha: true } );
renderer.setSize( size.width, size.height, false );
renderer.setPixelRatio( Math.min( window.devicePixelRatio || 1, 2 ) );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
this.renderer = renderer;
this.buildStudio( scene );
this.buildLampInto( scene );
}
/**
* Builds the studio floor and the three-point lighting from the current palette, keeping each so a theme change can recolor or re-level it.
*
* @param scene - The scene to add the studio to.
*/
private buildStudio( scene: THREE.Scene ): void {
const palette = this.palette;
const floorMaterial = new THREE.MeshStandardMaterial( { color: palette.floor, roughness: 0.95, metalness: 0.05 } );
const floor = new THREE.Mesh( new THREE.CircleGeometry( 6, 64 ), floorMaterial );
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.95;
scene.add( floor );
this.floorMesh = floor;
this.floorMaterial = floorMaterial;
const ambient = new THREE.AmbientLight( 0xffffff, palette.ambient );
scene.add( ambient );
this.themedLights.ambient = ambient;
const key = new THREE.DirectionalLight( 0xffffff, palette.key );
key.position.set( 2.6, 4, 3.2 );
scene.add( key );
this.themedLights.key = key;
const rim = new THREE.DirectionalLight( 0xbcd0ff, palette.rim );
rim.position.set( -3, 2.2, -2.6 );
scene.add( rim );
this.themedLights.rim = rim;
}
/**
* Builds the lamp from the helper in the lamp's current config and adds it to the scene, applying the current orbit so it opens at a readable three-quarter angle.
*
* @param scene - The scene to add the lamp to.
*/
private buildLampInto( scene: THREE.Scene ): void {
const lamp = buildLamp( this.palette, this.shadeHex(), this.config.baseFinish, this.config.bulbOn );
lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
scene.add( lamp.group );
this.lamp = lamp;
}
/**
* The hex color of the currently selected shade swatch, falling back to the first swatch when the stored id is unknown.
*/
private shadeHex(): string {
const swatch = SHADE_SWATCHES.find( ( entry ) => entry.id === this.config.shadeSwatchId ) ?? SHADE_SWATCHES[ 0 ];
return swatch.hex;
}
/**
* Wires the orbit pointer listeners, the overlay's `paint` listener, the resize observer that keeps the renderer matched to the embedded stage, and the intersection observer that pauses the loop offscreen.
*
* The orbit starts on a `pointerdown` on the viewport and tracks on the window so a drag that leaves the canvas keeps rotating, and a release without a meaningful move is treated as a select on the same press.
* The `paint` listener registers once, because the overlay canvas is rendered in every mode and so survives a mode switch; on a build without the experimental API the event never fires, which leaves the handler inert.
*/
private bindEvents(): void {
this.$viewport.addEventListener( 'pointerdown', this.onPointerDown );
window.addEventListener( 'pointermove', this.onPointerMove );
window.addEventListener( 'pointerup', this.onPointerUp );
window.addEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.addEventListener( 'paint', this.onOverlayPaint );
this.resizeObserver = new ResizeObserver( () => this.onResize() );
this.resizeObserver.observe( this.$viewport );
this.intersectionObserver = new IntersectionObserver(
( entries ) => this.onIntersection( entries ),
{ threshold: 0.01 },
);
this.intersectionObserver.observe( this.$viewport );
}
/**
* Wires the two ways the page theme can change at runtime: the OS color scheme and an in-page `data-theme` toggle, both funnelled through {@link refreshTheme}.
*/
private bindThemeListeners(): void {
this.themeMedia = window.matchMedia( '(prefers-color-scheme: dark)' );
this.themeMedia.addEventListener( 'change', this.onThemeMediaChange );
this.themeObserver = new MutationObserver( () => this.refreshTheme() );
this.themeObserver.observe( document.documentElement, { attributes: true, attributeFilter: [ 'data-theme' ] } );
}
/**
* Re-resolves the OS color scheme into the studio when the system theme flips.
*/
private onThemeMediaChange = (): void => {
this.refreshTheme();
};
/**
* Re-resolves the theme and, when it actually changed, recolors the studio and the shared metal so the lamp follows the page.
*/
private refreshTheme(): void {
const next = this.detectTheme();
if ( next === this.theme ) {
return;
}
this.theme = next;
this.applyTheme();
}
/**
* Pushes the current palette onto the live scene: the background, the floor, the lights, and the shared metal tone of the arm and base, so a runtime theme change lands without rebuilding the scene.
*/
private applyTheme(): void {
const palette = this.palette;
if ( this.scene ) {
this.scene.background = new THREE.Color( palette.background );
}
this.floorMaterial?.color.setHex( palette.floor );
if ( this.themedLights.ambient ) {
this.themedLights.ambient.intensity = palette.ambient;
}
if ( this.themedLights.key ) {
this.themedLights.key.intensity = palette.key;
}
if ( this.themedLights.rim ) {
this.themedLights.rim.intensity = palette.rim;
}
if ( this.lamp ) {
this.lamp.baseMaterial.color.setHex( palette.metal );
this.lamp.armMaterial.color.setHex( palette.metal );
}
this.refreshAccent();
this.applySelectionHighlight();
this.requestRender();
}
/**
* Sizes the WebGL backing store and the overlay backing store for the current device pixel ratio, and rebuilds the one-shot listener for the next change.
*
* `matchMedia( '(resolution: Ndppx)' )` only watches the current ratio, so a move to a different-density display needs the query rebuilt each time; the renderer caps the ratio at two so a very dense display does not balloon the backing store.
*/
private observeDevicePixelRatio(): void {
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
const dpr = window.devicePixelRatio || 1;
this.dprQuery = window.matchMedia( `(resolution: ${ dpr }dppx)` );
this.dprQuery.addEventListener( 'change', this.onDevicePixelRatioChange, { once: true } );
this.renderer?.setPixelRatio( Math.min( dpr, 2 ) );
}
/**
* Re-corrects the backing stores after the device pixel ratio changes, then re-arms the watcher and repaints.
*/
private onDevicePixelRatioChange = (): void => {
this.observeDevicePixelRatio();
this.onResize();
};
/**
* Resizes the renderer and updates the camera aspect when the viewport's CSS box changes, then repaints the overlay.
*/
private onResize(): void {
if ( ! this.camera || ! this.renderer ) {
return;
}
const size = this.viewportSize();
this.camera.aspect = size.width / size.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize( size.width, size.height, false );
this.requestRender();
this.syncOverlay();
}
/**
* Pauses or resumes the render loop as the demo scrolls in and out of view, so an offscreen lamp costs no frames.
*
* @param entries - The intersection records for the observed viewport.
*/
private onIntersection( entries: IntersectionObserverEntry[] ): void {
for ( const entry of entries ) {
this.isVisible = entry.isIntersecting;
if ( this.isVisible ) {
this.startLoop();
} else {
this.stopLoop();
}
}
}
/**
* Begins a drag-to-orbit gesture, recording the start pointer so motion is measured as a delta and a press in place can still resolve to a select.
*
* Only the primary pointer's primary button starts a gesture: a right or middle click falls through to the browser, since a context menu can swallow the matching release and leave a phantom drag armed, and a gesture already in progress owns the viewport, so a new contact of any type is ignored until the owning pointer releases, because `isPrimary` is per pointer type and a first touch mid-mouse-drag would otherwise pass the guard and steal the gesture.
*
* @param event - The pointerdown on the viewport.
*/
private onPointerDown = ( event: PointerEvent ): void => {
if ( this.activePointerId !== null || ! event.isPrimary || event.button !== 0 ) {
return;
}
this.isDragging = true;
this.dragMoved = false;
this.activePointerId = event.pointerId;
this.lastPointer = { x: event.clientX, y: event.clientY };
};
/**
* Applies a drag delta to the orbit yaw and pitch while a gesture is active, bounding the pitch so the lamp stays framed.
*
* Once the pointer has moved past a few pixels the gesture is marked as a drag, so the matching release orbits rather than selecting.
*
* @param event - The pointermove on the window.
*/
private onPointerMove = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
const dx = event.clientX - this.lastPointer.x;
const dy = event.clientY - this.lastPointer.y;
this.lastPointer = { x: event.clientX, y: event.clientY };
if ( Math.abs( dx ) + Math.abs( dy ) > 3 ) {
this.dragMoved = true;
}
this.orbit.yaw += dx * 0.01;
this.orbit.pitch += dy * 0.01;
this.orbit.pitch = Math.max( -0.5, Math.min( 0.6, this.orbit.pitch ) );
this.requestRender();
};
/**
* Ends the gesture, and when the pointer never moved past the drag threshold, treats the release as a raycast select on the part under it.
*
* @param event - The pointerup on the window.
*/
private onPointerUp = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
if ( ! this.dragMoved ) {
this.pickAt( event );
}
};
/**
* Abandons the gesture when the browser cancels the owning pointer, a touch scroll takeover or a system sheet, without treating the interruption as a select.
*
* Only the gesture's own pointer can cancel it, so an unrelated contact being cancelled leaves a live drag alone.
*/
private onPointerCancel = ( event: PointerEvent ): void => {
if ( event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
};
/**
* Raycasts from the camera through a pointer position and selects the lamp part it hits, or clears the selection on a miss.
*
* This is the article's 3D hit-testing lesson: there is still no built-in canvas hit test, so the pointer is converted into normalized device coordinates, `raycaster.setFromCamera` builds the ray, and `intersectObjects` against the whole lamp group resolves the nearest hit to a part id, the raycast that replaces the old `isPointInPath`.
* Every visible mesh carries the id of the part it belongs to, so a click on a cosmetic piece like the shade cap or the neck still selects; a hit whose chain carries no id, the additive glow halo, falls through to the next hit rather than eating the click, and the selection only clears when nothing in the list resolves.
* In `canvas-only` the painted UI sits on top of the scene, so its pills, swatches, and panel are hit-tested first, more controls whose geometry has to be re-derived by hand because no DOM exists to do it.
*
* @param event - The pointer event whose position to pick at.
*/
private pickAt( event: PointerEvent ): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
if ( this.activeMode === 'canvas-only' && this.pickFauxUiAt( event ) ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const ndc = new THREE.Vector2(
( ( event.clientX - rect.left ) / rect.width ) * 2 - 1,
-( ( event.clientY - rect.top ) / rect.height ) * 2 + 1,
);
this.raycaster.setFromCamera( ndc, this.camera );
const hits = this.raycaster.intersectObjects( this.lamp.group.children, true );
for ( const hit of hits ) {
const id = this.partIdForObject( hit.object );
if ( id ) {
this.select( id, 'canvas' );
return;
}
}
this.select( null, 'canvas' );
}
/**
* Hit-tests the painted faux UI by hand, the part label pills first, then the inspector's swatch row, then the panel body, and applies whatever the matching real control would do.
*
* This mirrors the exact geometry {@link paintPill} and {@link paintFauxInspector} paint (the pill boxes around the visible anchors, the swatch centers and radius, the {@link fauxPanelRect} box), because painted pixels carry no hit regions of their own.
* That duplication is the point the trap teaches: the mouse path can be made to feel complete, but every painted control costs a hand-derived hit region like this, the demo now pays that cost for all of them, and focus, the accessibility tree, and text selection still get nothing.
* A hit on the panel body that lands on no control still returns `true`, so a click on the painted inspector never raycasts the scene through it.
* The swatch hit radius is a couple of pixels wider than the painted circle so the click is comfortable, the kind of forgiveness a real `<button>` gives for free.
*
* @param event - The pointer event whose position to test.
* @returns Whether the point landed on the painted UI, with the matching action applied.
*/
private pickFauxUiAt( event: PointerEvent ): boolean {
const rect = this.$viewport.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
// Test in reverse paint order, so the topmost painted thing wins: the swatches sit on the panel, the panel covers any pill it overlaps, and the pills sit over the scene.
const panel = this.fauxPanelRect( rect.width, rect.height );
const swatchY = panel.y + 54 + 8;
let swatchX = panel.x + 12 + 8;
for ( const swatch of SHADE_SWATCHES ) {
if ( Math.hypot( px - swatchX, py - swatchY ) <= 10 ) {
this.onSelectSwatch( swatch.id );
return true;
}
swatchX += 24;
}
if ( px >= panel.x && px <= panel.x + panel.width && py >= panel.y && py <= panel.y + panel.height ) {
return true;
}
const ctx = this.overlayCtx;
if ( ctx ) {
ctx.font = `600 12px ${ this.overlayColors.fontSans }`;
// Within the pills the test also runs in reverse paint order, since a later-painted pill covers an earlier one where they overlap: one more stacking decision the DOM normally owns and painted UI has to re-derive.
for ( const part of [ ...this.parts ].reverse() ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
const padX = 9;
const boxWidth = ctx.measureText( part.label ).width + padX * 2;
const boxHeight = 22;
const x = point.x - boxWidth / 2;
const y = point.y - boxHeight / 2;
if ( px >= x && px <= x + boxWidth && py >= y && py <= y + boxHeight ) {
this.select( part.id, 'canvas' );
return true;
}
}
}
return false;
}
/**
* Resolves the part a hit object belongs to by walking its ancestry to the first stamped `userData.partId`.
*
* The geometry helper stamps every visible mesh, cosmetic pieces included, so a hit anywhere on the lamp maps to the part a user believes they clicked; the glow sprite is left unstamped on purpose, so this returns `null` for it and the caller moves on to the next hit.
*
* @param object - The object the raycaster returned.
* @returns The part id stamped on the object or an ancestor, or `null` when the chain carries none.
*/
private partIdForObject( object: THREE.Object3D ): LampPartId | null {
let current: THREE.Object3D | null = object;
while ( current ) {
const stamped = current.userData.partId;
const id = PART_IDS.find( ( partId ) => partId === stamped );
if ( id ) {
return id;
}
current = current.parent;
}
return null;
}
/**
* Starts the render loop if it is not already running and the demo is on screen, and marks the scene dirty so the first frame back repaints.
*/
private startLoop(): void {
if ( this.frame === 0 && this.isVisible ) {
this.frame = requestAnimationFrame( this.renderLoop );
}
this.requestRender();
}
/**
* Stops the render loop and clears the frame handle so it can be restarted cleanly.
*/
private stopLoop(): void {
if ( this.frame !== 0 ) {
cancelAnimationFrame( this.frame );
this.frame = 0;
}
}
/**
* Marks the scene dirty so the next animation frame renders the WebGL view and re-syncs the overlay.
*/
private requestRender(): void {
this.needsRender = true;
}
/**
* The render loop: applies the orbit to the lamp, draws the WebGL frame, and re-syncs the overlay so the labels stay glued as the model turns.
*
* The loop stays hot at the frame cadence while the demo is on screen, but the work is gated on {@link needsRender}: an idle frame re-arms itself and returns, so a lamp nobody is touching renders nothing and writes no fresh `anchorPoints`, and the first frame after a change does the full pass.
*/
private renderLoop = (): void => {
this.frame = requestAnimationFrame( this.renderLoop );
if ( ! this.needsRender ) {
return;
}
if ( ! this.renderer || ! this.scene || ! this.camera || ! this.lamp ) {
return;
}
this.needsRender = false;
this.lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
this.renderer.render( this.scene, this.camera );
this.syncOverlay();
};
/**
* Lifts the emissive of the selected part and rests every other part, so the selection reads in 3D as a glow on the chosen mesh in either theme.
*
* It runs on a selection or theme change rather than every frame, since the emissive persists in the material between renders, and the cached accent keeps it off the `getComputedStyle` path.
* The bulb is left to its own lit glow, so selecting it does not fight the bulb toggle; its overlay label and inspector heading still mark the selection.
*/
private applySelectionHighlight(): void {
if ( ! this.lamp ) {
return;
}
for ( const id of PART_IDS ) {
if ( id === 'bulb' ) {
continue;
}
const material = this.lamp.parts[ id ].material;
if ( Array.isArray( material ) || ! ( material instanceof THREE.MeshStandardMaterial ) ) {
continue;
}
const selected = id === this.selectedId;
material.emissive.copy( selected ? this.accent : BLACK );
material.emissiveIntensity = selected ? 0.32 : 0;
}
}
/**
* Projects each part's 3D anchor to a CSS-pixel screen position and stores it, then repaints the flat overlay for the active mode.
*
* This is the alignment bookkeeping the article says the overlay owns: the anchor is taken into world space through the lamp's matrix, projected with the camera, and converted from normalized device coordinates to CSS pixels against the canvas rect, which keeps the projection device-pixel-ratio correct because the rect is in CSS pixels while the WebGL backing store carries the ratio separately.
*/
private syncOverlay(): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
this.lamp.group.updateMatrixWorld();
const next: Record<string, { x: number; y: number; visible: boolean }> = {};
for ( const id of PART_IDS ) {
this.projected.copy( this.lamp.anchors[ id ] );
this.lamp.group.localToWorld( this.projected );
this.projected.project( this.camera );
next[ id ] = {
x: ( this.projected.x * 0.5 + 0.5 ) * width,
y: ( -this.projected.y * 0.5 + 0.5 ) * height,
visible: this.projected.z < 1,
};
}
this.anchorPoints = next;
this.paintFlatOverlay();
}
/**
* Repaints the flat overlay canvas for the active mode: the faux UI in `canvas-only`, a requested `paint` event for the drawn labels in `html-in-canvas`, and a clear in `dom-overlay` where real HTML owns the labels.
*
* The experimental branch neither draws nor clears here; it calls `requestPaint()` and {@link onOverlayPaint} clears and draws inside the event, so a platform-initiated paint, a focus ring appearing on a drawn label, repaints the whole canvas atomically instead of compositing over pixels this pass left behind. The shared {@link supportsHtmlInCanvas} predicate gates the mode on both methods, so the branch only runs where the request is real.
*/
private paintFlatOverlay(): void {
const $canvas = this.$overlayCanvas;
if ( ! $canvas ) {
return;
}
if ( ! this.overlayCtx ) {
this.overlayCtx = $canvas.getContext( '2d' );
}
const ctx = this.overlayCtx;
if ( ! ctx ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = rect.width || 1;
const cssHeight = rect.height || 1;
// Resize the backing store when the box or the device pixel ratio changes, since assigning width or height clears and reallocates the canvas, which would flicker if done every frame; keying on dpr too reallocates when only the ratio shifts (a display move or browser zoom that leaves the CSS box bit-identical), matching the WebGL side that already re-sizes for the new ratio.
if ( this.overlaySize.width !== cssWidth || this.overlaySize.height !== cssHeight || this.overlaySize.dpr !== dpr ) {
$canvas.width = Math.round( cssWidth * dpr );
$canvas.height = Math.round( cssHeight * dpr );
this.overlaySize = { width: cssWidth, height: cssHeight, dpr };
}
if ( this.activeMode === 'html-in-canvas' ) {
$canvas.requestPaint();
return;
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
ctx.clearRect( 0, 0, cssWidth, cssHeight );
if ( this.activeMode === 'canvas-only' ) {
this.paintFauxUi( ctx, cssWidth, cssHeight );
}
}
/**
* Paints the canvas-only trap: every part label and a faux inspector panel, all as raw pixels with no DOM behind them.
*
* It looks like a working configurator, but the text is pixels that cannot be selected, found in page, or reached with a Tab key or a screen reader, which is the trap a real 3D scene invites because it is exactly where someone reaches for drawing the UI onto the canvas too.
*
* @param ctx - The overlay 2D context, scaled so one unit is one CSS pixel.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxUi( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
this.paintPill( ctx, part.label, point.x, point.y, part.id === this.selectedId );
}
this.paintFauxInspector( ctx, width, height );
}
/**
* Paints one rounded label pill centered on a point, the faux equivalent of an overlay label, from the cached themed colors.
*
* @param ctx - The overlay 2D context.
* @param text - The label text.
* @param cx - The pill center x in CSS pixels.
* @param cy - The pill center y in CSS pixels.
* @param selected - Whether to fill the pill with the accent.
*/
private paintPill( ctx: CanvasRenderingContext2D, text: string, cx: number, cy: number, selected: boolean ): void {
const colors = this.overlayColors;
ctx.font = `600 12px ${ colors.fontSans }`;
const padX = 9;
const boxWidth = ctx.measureText( text ).width + padX * 2;
const boxHeight = 22;
const x = cx - boxWidth / 2;
const y = cy - boxHeight / 2;
const pill = new Path2D();
pill.roundRect( x, y, boxWidth, boxHeight, 11 );
ctx.fillStyle = selected ? colors.accent : colors.surface;
ctx.fill( pill );
ctx.lineWidth = 1;
ctx.strokeStyle = selected ? colors.accent : this.withAlpha( colors.ink, 0.18 );
ctx.stroke( pill );
ctx.fillStyle = selected ? colors.accentInk : colors.ink;
ctx.textBaseline = 'middle';
ctx.fillText( text, x + padX, y + boxHeight / 2 );
}
/**
* The faux inspector panel's box in CSS pixels, one derivation shared by the painter and the hand-built hit test so the drawn panel and its hit region cannot drift apart.
*
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
* @returns The panel's position and size.
*/
private fauxPanelRect( width: number, height: number ): { x: number; y: number; width: number; height: number } {
const panelWidth = Math.min( 190, width - 24 );
const panelHeight = 96;
return {
x: width - panelWidth - 12,
y: height - panelHeight - 12,
width: panelWidth,
height: panelHeight,
};
}
/**
* Paints the faux inspector panel into the corner of the canvas, a picture of the real inspector with no DOM behind it, from the cached themed colors.
*
* The panel responds to the mouse only because {@link pickFauxUiAt} re-derives this exact geometry by hand, from the same {@link fauxPanelRect} box this painter draws; there is still no element, so nothing here can take focus, appear in the accessibility tree, or offer selectable text.
*
* @param ctx - The overlay 2D context.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxInspector( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
const colors = this.overlayColors;
const box = this.fauxPanelRect( width, height );
const x = box.x;
const y = box.y;
const panel = new Path2D();
panel.roundRect( x, y, box.width, box.height, 10 );
ctx.fillStyle = this.withAlpha( colors.surface, 0.95 );
ctx.fill( panel );
ctx.lineWidth = 1;
ctx.strokeStyle = this.withAlpha( colors.ink, 0.16 );
ctx.stroke( panel );
const selected = this.selectedPart;
ctx.fillStyle = colors.ink;
ctx.textBaseline = 'alphabetic';
ctx.font = `600 13px ${ colors.fontSans }`;
ctx.fillText( selected ? selected.label : 'Inspector', x + 12, y + 24 );
ctx.font = `11px ${ colors.fontMono }`;
ctx.fillStyle = this.withAlpha( colors.ink, 0.6 );
ctx.fillText( 'Shade color', x + 12, y + 46 );
const swatchY = y + 54;
let swatchX = x + 12;
for ( const swatch of SHADE_SWATCHES ) {
ctx.beginPath();
ctx.arc( swatchX + 8, swatchY + 8, 8, 0, Math.PI * 2 );
ctx.fillStyle = swatch.hex;
ctx.fill();
if ( swatch.id === this.config.shadeSwatchId ) {
ctx.lineWidth = 2;
ctx.strokeStyle = colors.accent;
ctx.stroke();
}
swatchX += 24;
}
}
/**
* Draws the real labels when the overlay canvas fires its `paint` event, so the experimental draw runs inside the event the way the canonical flow prescribes.
*
* The render loop and the sync passes never draw or clear the labels themselves; {@link paintFlatOverlay} requests a paint, and this handler clears the canvas and runs {@link drawLabelsIntoCanvas} when the event lands, so a platform-initiated paint, a focus ring appearing on a label, starts from its own clear instead of leaving residue over the last frame.
* The mode guard mirrors the experimental branch of the paint pass, since the listener stays wired in every mode, and the context guard covers an event that lands before the first paint pass has cached the 2D context.
*/
private onOverlayPaint = (): void => {
const ctx = this.overlayCtx;
const $canvas = this.$overlayCanvas;
if ( this.activeMode !== 'html-in-canvas' || ! ctx || ! $canvas ) {
return;
}
// Clear in device pixels under the identity transform, the same grid the label draw below runs on.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
ctx.clearRect( 0, 0, $canvas.width, $canvas.height );
this.drawLabelsIntoCanvas( ctx );
};
/**
* Draws every visible real label node into the overlay canvas at its own 1:1 size and projected center, then writes each returned alignment matrix back, the experimental in-canvas-label path.
*
* Each label's `style.transform` is a platform-owned slot here. The node is already a reachable element the browser exposes in the accessibility tree and the tab order, because the overlay canvas keeps it a real DOM child under `layoutsubtree`; writing the matrix back is only what keeps that element's focus ring, hit target, and accessibility bounds aligned under its drawn pixels. When `omitTransformSync` is set the write is skipped so the reader can watch the focus ring and click target drift off the pixels.
*
* A label whose part is facing away is hidden rather than skipped in place: `visibility: hidden` also removes it from the tab order and the accessibility tree while it is offscreen, the same contract the DOM-overlay labels keep, where a skipped node would otherwise stay tabbable at its stale position.
*
* The work runs in three passes, the transform resets and visibility first, then every box measurement, then every draw and write-back, so the style writes cost at most two layout flushes per paint instead of one per label.
*
* The draw runs on the bare device-pixel grid, not under the `dpr` scale the faux UI paints with: the label node already rasterizes at the display's pixel density, so drawing it under an extra `dpr` transform would double its size, which is what made the drawn pills balloon. The projected center and the label box are scaled into device pixels by hand instead, so the drawn pill matches the DOM-overlay pill exactly, then the transform is restored for the next paint.
*
* The explainer's canonical flow routes this draw through the canvas `paint` event, and that is where it runs: the render loop and the sync passes only call `requestPaint()`, and {@link onOverlayPaint} invokes this method when the event fires. Calling `drawElementImage` outside the event is still defined behavior, but it draws the previous frame's snapshot of the element and throws before a first snapshot exists, which is why the handler owns the draw.
*
* @param ctx - The overlay 2D context, on a supporting build where `drawElementImage` exists.
*/
private drawLabelsIntoCanvas( ctx: CanvasRenderingContext2D ): void {
const $host = this.$overlayCanvas;
if ( ! $host ) {
return;
}
const dpr = window.devicePixelRatio || 1;
// Drop to the identity transform so one drawing unit is one backing-store pixel, the space the element bitmap is already rasterized in; the faux-UI dpr scale is restored at the end.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
// First pass: reset any prior alignment before measuring, so each box read is the label's own size rather than a previously skewed one, and hide the labels whose part is facing away.
const drawable: Array<{ point: { x: number; y: number }; $node: HTMLElement }> = [];
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
const $node = $host.querySelector( `[data-part='${ part.id }']` );
if ( ! ( $node instanceof HTMLElement ) ) {
continue;
}
if ( ! point || ! point.visible ) {
$node.style.visibility = 'hidden';
continue;
}
$node.style.visibility = '';
if ( ! this.omitTransformSync ) {
$node.style.transform = '';
}
drawable.push( { point, $node } );
}
// Second pass: measure every box in one batch, so the writes above flush layout once rather than per label.
const boxes = drawable.map( ( entry ) => entry.$node.getBoundingClientRect() );
// Third pass: draw each label at its projected center and write the alignment matrix back.
for ( const [ index, entry ] of drawable.entries() ) {
const box = boxes[ index ];
const dx = ( entry.point.x - box.width / 2 ) * dpr;
const dy = ( entry.point.y - box.height / 2 ) * dpr;
const alignment = ctx.drawElementImage( entry.$node, dx, dy );
if ( ! this.omitTransformSync ) {
entry.$node.style.transform = alignment.toString();
}
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
}
/**
* Reads a CSS custom property off the host, falling back to a literal when the token sheet has not loaded.
*
* A canvas paints raw pixels and cannot inherit a CSS color, so every themed color the faux UI draws is read here at paint time, which is what keeps the canvas-only labels legible in light and dark.
*
* @param name - The custom property to read.
* @param fallback - The literal color to use when the property is empty.
* @returns The resolved color string.
*/
private token( name: string, fallback: string ): string {
const value = getComputedStyle( this ).getPropertyValue( name ).trim();
return value || fallback;
}
/**
* Resolves the selection accent the same way the stylesheet does, reading the private `--cfg-accent` so the 3D highlight and the painted UI match the DOM controls in either theme.
*/
private accentColor(): string {
return this.token( '--cfg-accent', '#2257e6' );
}
/**
* Re-reads the themed colors into the caches the render loop relies on: the accent as a Three.js color for the 3D highlight, and the token set the flat overlay paints with.
*
* It is called on connect and after a theme change, so every per-frame draw works from cached values rather than calling `getComputedStyle`.
* The flip side of that cache is staleness: a host that swaps `--configurator-accent` at runtime without a theme flip paints the old accent until the next theme change refreshes it.
*/
private refreshAccent(): void {
const accent = this.accentColor();
this.accent.set( accent );
this.overlayColors = {
ink: this.token( '--ink', '#17171a' ),
surface: this.token( '--surface', '#ffffff' ),
accent,
accentInk: this.token( '--accent-ink', '#ffffff' ),
fontSans: this.token( '--font-sans', 'system-ui, sans-serif' ),
fontMono: this.token( '--font-mono', 'monospace' ),
};
}
/**
* Returns a color string at a given alpha, parsing a `#rgb` or `#rrggbb` token into `rgba()` and mixing any other syntax toward transparent with `color-mix()`.
*
* The canvas needs an explicit alpha for the faux panel and pill borders, but the theme tokens are usually opaque hex, so the fast path parses those by hand; anything else goes through `color-mix()`, which any browser modern enough to hand us a non-hex token can paint.
*
* @param color - The base color, usually a hex token value.
* @param alpha - The alpha to apply, from 0 to 1.
* @returns An `rgba()` string for hex input, or a `color-mix()` expression for anything else.
*/
private withAlpha( color: string, alpha: number ): string {
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec( color.trim() );
if ( ! match ) {
return `color-mix( in srgb, ${ color } ${ Math.round( alpha * 100 ) }%, transparent )`;
}
const body = match[ 1 ];
const full = body.length === 3 ? body.split( '' ).map( ( c ) => c + c ).join( '' ) : body;
const r = parseInt( full.slice( 0, 2 ), 16 );
const g = parseInt( full.slice( 2, 4 ), 16 );
const b = parseInt( full.slice( 4, 6 ), 16 );
return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`;
}
/**
* Selects a part from a real overlay label, the keyboard-reachable path the raycast cannot provide.
*
* @param id - The id of the part the label represents.
*/
private onLabelClick( id: string ): void {
this.select( id, 'overlay' );
}
/**
* Updates the selection and dispatches the typed `object-select` event, tagged with the layer it came from.
*
* The event fires on every pick, even a repeat of the current selection, so a demo log can record each raycast; the internal `selectedId` only changes when the target actually changes.
* The event is `composed` so it crosses the shadow boundary and `bubbles` so a listener on the host hears it.
*
* @param id - The id to select, or `null` to clear.
* @param source - Which layer triggered the selection, the raycast or a real overlay control.
*/
private select( id: string | null, source: ConfiguratorSelectSource ): void {
if ( id !== this.selectedId ) {
this.selectedId = id;
}
this.dispatchEvent(
new CustomEvent<ConfiguratorSelectDetail>( 'object-select', {
detail: { objectId: id, source },
bubbles: true,
composed: true,
} ),
);
}
/**
* Recolors the shade to a swatch, applying it to the live material and reporting the new config.
*
* @param swatchId - The id of the chosen swatch.
*/
private onSelectSwatch( swatchId: string ): void {
this.config = { ...this.config, shadeSwatchId: swatchId };
if ( this.lamp ) {
setShadeColor( this.lamp, this.shadeHex() );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Switches the base finish, retuning the live base material and reporting the new config.
*
* @param finish - The finish to apply, matte or metal.
*/
private onSelectFinish( finish: BaseFinish ): void {
this.config = { ...this.config, baseFinish: finish };
if ( this.lamp ) {
setBaseFinish( this.lamp, finish );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Toggles the bulb, relighting the scene through the helper and reporting the new config.
*/
private onToggleBulb(): void {
const bulbOn = ! this.config.bulbOn;
this.config = { ...this.config, bulbOn };
if ( this.lamp ) {
setBulbState( this.lamp, bulbOn );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Dispatches the typed `config-change` event with the whole current config after an inspector edit.
*
* It is `composed` and `bubbles` for the same reason the selection event is, so a host listener hears every edit without a cast.
*/
private emitConfigChange(): void {
this.dispatchEvent(
new CustomEvent<ConfiguratorConfigChangeDetail>( 'config-change', {
detail: { config: this.config },
bubbles: true,
composed: true,
} ),
);
}
/**
* Types `addEventListener` for the configurator's own events so a listener receives the typed `CustomEvent` with no cast, and falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof ProductConfiguratorEventMap>(
type: K,
listener: ( this: ProductConfigurator, ev: ProductConfiguratorEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the WebGL viewport, the flat overlay canvas, the real DOM labels or the drawn-label nodes for the active mode, and the inspector and harness panel in the side column.
*
* The WebGL canvas always renders the lamp; what changes per mode is whether real HTML labels sit over it (`dom-overlay`), real label nodes are drawn into the overlay canvas (`html-in-canvas`), or the labels and a faux inspector are painted onto the overlay canvas with no DOM (`canvas-only`).
* The stage and the side column are always laid out by the same `.layout` grid: `stacked` keeps it one column so the side falls under the canvas, and `split` makes it two so the side becomes the right column, the glass-demo shape.
*/
render() {
const active = this.activeMode;
const parts = this.parts;
return html`
<div class=${ this.layout === 'split' ? 'layout split' : 'layout' }>
<div class="main">
<slot name="header"></slot>
<div class=${ this.isDragging ? 'stage dragging' : 'stage' }>
<canvas class="viewport" role="img" aria-label="A 3D desk lamp you can orbit by dragging. Click a part to select it."></canvas>
<canvas class="overlay-canvas" aria-hidden=${ active === 'html-in-canvas' ? 'false' : 'true' } ?layoutsubtree=${ active === 'html-in-canvas' }>
${ active === 'html-in-canvas'
? parts.map( ( part ) => html`
<button
type="button"
class="drawn-label"
data-part=${ part.id }
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
` )
: nothing }
</canvas>
${ active === 'dom-overlay'
? html`
<div class="overlay">
${ parts.map( ( part ) => {
const point = this.anchorPoints[ part.id ];
const shown = point && point.visible;
return html`
<button
type="button"
class="label"
style="left: ${ point ? point.x : 0 }px; top: ${ point ? point.y : 0 }px; visibility: ${ shown ? 'visible' : 'hidden' };"
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
`;
} ) }
</div>
`
: nothing }
</div>
</div>
${ this.renderSide( active ) }
</div>
`;
}
/**
* Renders the side column of the layout, the real inspector when the mode has one stacked above the harness `panel` slot, and in `split` a full-width `footer` slot under both columns.
*
* In `split` it is always rendered so it forms the right column even in `canvas-only`, where the inspector is painted into the canvas and the slot still carries the harness panel. In `stacked` it is rendered only when there is something to show, the inspector for a mode that has one, so a bare `canvas-only` embed does not leave an empty row under the canvas.
* The `footer` row spans both columns and only renders in `split`, so a harness can drop a tall report or log there instead of stacking it in the narrow side column, where it would run far below the stage and strand the canvas column; an empty `footer` slot produces no box, so a harness that slots nothing renders exactly as before.
*
* @param active - The active mode, which decides whether a real DOM inspector exists.
* @returns The side-column template, or `nothing` when stacked with no inspector to show.
*/
private renderSide( active: ConfiguratorMode ) {
const inspector = active === 'canvas-only' ? nothing : this.renderInspector();
if ( this.layout !== 'split' && active === 'canvas-only' ) {
return nothing;
}
return html`
<div class="side">
${ inspector }
<slot name="panel"></slot>
</div>
${ this.layout === 'split'
? html`<div class="footer"><slot name="footer"></slot></div>`
: nothing }
`;
}
/**
* Renders the real DOM inspector for the selected part: the shade swatches, the base finish, and the bulb toggle, each a real, keyboard-reachable control.
*
* It shows every control whatever the selected part, since the lamp is one product, and names the selected part in the heading so the selection still reads; with nothing selected the heading falls back to the whole lamp.
*/
private renderInspector() {
const selected = this.selectedPart;
return html`
<div class="inspector">
<div class="head">
<h3>${ selected ? selected.label : 'Lamp' }</h3>
${ selected ? html`<span class="part-tag">#${ selected.id }</span>` : nothing }
</div>
<div class="field">
<span class="field-label">Shade color</span>
<div class="swatches">
${ SHADE_SWATCHES.map( ( swatch ) => html`
<button
type="button"
class="swatch"
style="background: ${ swatch.hex };"
aria-label=${ swatch.label }
aria-pressed=${ swatch.id === this.config.shadeSwatchId }
@click=${ () => this.onSelectSwatch( swatch.id ) }
></button>
` ) }
</div>
</div>
<div class="field">
<span class="field-label">Base finish</span>
<div class="segmented">
<button type="button" aria-pressed=${ this.config.baseFinish === 'matte' } @click=${ () => this.onSelectFinish( 'matte' ) }>Matte</button>
<button type="button" aria-pressed=${ this.config.baseFinish === 'metal' } @click=${ () => this.onSelectFinish( 'metal' ) }>Metal</button>
</div>
</div>
<div class="field">
<span class="field-label">Bulb</span>
<button type="button" class="switch" aria-pressed=${ this.config.bulbOn } @click=${ () => this.onToggleBulb() }>
<span class=${ this.config.bulbOn ? 'track on' : 'track' }><span class="knob"></span></span>
<span>${ this.config.bulbOn ? 'On' : 'Off' }</span>
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'product-configurator': ProductConfigurator;
}
}The overlay ships, and it costs more than it looks. It keeps three coordinate systems lined up, CSS pixels for pointer events and getBoundingClientRect, the device-pixel-ratio scale, and the WebGL backing store, and unlike a flat scene the projection has to re-run on every frame of an orbit, because the part moves on screen the moment the lamp turns. That per-frame bookkeeping is what the next step stresses.
Step 4: stress the baseline with device pixel ratio
The overlay’s hardest job is staying crisp and aligned across resolutions, so make the WebGL scene own its device-pixel-ratio lifecycle out loud. A canvas rendered without it is blurry on a HiDPI display, and one that ignores a DPR change softens again the moment someone zooms or drags the window to a different-density screen. Real DOM text never has this problem, because the browser re-rasterizes and re-lays it out for free.
WebGL has its own knob for this. renderer.setPixelRatio tells Three.js how many backing-store pixels to render per CSS pixel, so the lamp draws at the display’s real density, capped at two so a very dense screen doesn’t balloon the buffer. You set it once when the renderer is created:
index.ts
// index.ts, inside initScene, sizing the renderer to the display density
const renderer = new THREE.WebGLRenderer( { canvas: this.$viewport, antialias: true, alpha: true } );
renderer.setSize( size.width, size.height, false );
renderer.setPixelRatio( Math.min( window.devicePixelRatio || 1, 2 ) );Setting it once isn’t enough, because the ratio changes at runtime. matchMedia( '(resolution: Ndppx)' ) fires when it does, but the query is pinned to a single resolution, so you re-arm it after every change and reapply the ratio to the renderer as you go:
index.ts
// index.ts, re-arming the device-pixel-ratio watcher
/**
* Sizes the WebGL backing store and the overlay backing store for the current device pixel ratio, and rebuilds the one-shot listener for the next change.
*
* `matchMedia( '(resolution: Ndppx)' )` only watches the current ratio, so a move to a different-density display needs the query rebuilt each time; the renderer caps the ratio at two so a very dense display does not balloon the backing store.
*/
private observeDevicePixelRatio(): void {
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
const dpr = window.devicePixelRatio || 1;
this.dprQuery = window.matchMedia( `(resolution: ${ dpr }dppx)` );
this.dprQuery.addEventListener( 'change', this.onDevicePixelRatioChange, { once: true } );
this.renderer?.setPixelRatio( Math.min( dpr, 2 ) );
}The projection from step 3 has to stay DPR-correct too, and it gets that for free as long as you measure against the canvas rect. getBoundingClientRect returns CSS pixels, while the WebGL backing store carries the device pixel ratio separately, so converting the projected anchor against the rect lands the label in the same CSS-pixel space the HTML controls live in, whatever the ratio.
This demo walks three states rather than a simple canvas-bad-versus-DOM-good split. A naive canvas is blurry and its hit targets drift. A corrected canvas is crisp, but only after you handle the ratio, the coordinate mapping, and a redraw on every change. setPixelRatio does the first part, resizing the backing store to the display’s density, while the redraw is your render loop’s job and the coordinate mapping is the projection you still own. The real WebGL lamp stays crisp and its overlay stays glued, while you still own the projection that keeps them aligned. Simulate a DPR change and watch each one react:
import { LitElement, html, css, nothing } from 'lit';
import { supportsHtmlInCanvas } from '@demos/supports-html-in-canvas';
import { customElement, property, state, query } from 'lit/decorators.js';
import * as THREE from 'three';
import { buildLamp, disposeLamp, setBaseFinish, setBulbState, setShadeColor, STUDIO_PALETTES } from './lamp';
import type { LampHandle } from './lamp';
import type {
BaseFinish,
ConfiguratorConfigChangeDetail,
ConfiguratorLayout,
ConfiguratorMode,
ConfiguratorSelectDetail,
ConfiguratorSelectSource,
ConfiguratorTheme,
LampConfig,
LampPartId,
ProductConfiguratorEventMap,
SceneObject,
ShadeSwatch,
} from './types';
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here.
* Writing the returned matrix into the element's `style.transform` is what keeps focus, hit testing, and accessibility geometry on the drawn pixels.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface OffscreenCanvasRenderingContext2D {
/**
* The offscreen mirror of {@link CanvasRenderingContext2D.drawElementImage}, declared for completeness so a worker path type-checks too.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface HTMLCanvasElement {
/**
* Opts the canvas in to the experimental model, so its direct children lay out and become drawable and hit-testable.
*
* Reflects the `layoutsubtree` HTML attribute, and is absent from `lib.dom.d.ts` because the feature is still flag-gated.
*/
layoutSubtree: boolean;
/**
* Forces a `paint` event on the next frame, the way `requestAnimationFrame` schedules a draw.
*/
requestPaint(): void;
}
}
/**
* The swatches the shade can be recolored to, a small neutral-to-bold range so the recolor reads in both themes.
*
* It is a module constant rather than a property because the swatch set is part of the lamp's design, not data a harness owns, and the first entry is the default the lamp opens on.
*/
const SHADE_SWATCHES: ReadonlyArray<ShadeSwatch> = [
{ id: 'porcelain', label: 'Porcelain', hex: '#e9e7e0' },
{ id: 'cobalt', label: 'Cobalt', hex: '#2257e6' },
{ id: 'moss', label: 'Moss', hex: '#3f7d5a' },
{ id: 'clay', label: 'Clay', hex: '#c2683f' },
];
/**
* The aspect ratio of the canvas, its height as a fraction of its width, so the stage reserves the right height before WebGL sizes itself.
*/
const CANVAS_ASPECT = 0.62;
/**
* The labelled parts the lamp draws, in the order the overlay stacks them, so the four ids the geometry and inspector know about have a single declared source.
*/
const PART_IDS: ReadonlyArray<LampPartId> = [ 'shade', 'arm', 'bulb', 'base' ];
/**
* A shared black color reused when a part's selection highlight is cleared, so the highlight reset allocates nothing per frame.
*/
const BLACK = new THREE.Color( 0x000000 );
/**
* A real-time 3D product configurator: a procedural desk lamp in a WebGL canvas, with a raycast selection, an orbit drag, and an inspector, that moves its UI between three ownership models.
*
* The lamp is graphics the DOM cannot draw, owned by the canvas, and the labels and inspector are interface, owned by the DOM, so `mode` decides which layer carries them.
* `canvas-only` paints the labels and a faux inspector onto a flat overlay canvas with no focusable DOM, the trap; `dom-overlay` is the version that ships, projecting each part's 3D anchor to a real HTML label kept glued as the lamp orbits, with the real inspector beside it; `html-in-canvas` feature-detects the experimental `drawElementImage` path, draws the real label nodes into the flat overlay canvas, and writes the returned alignment transform back, falling back to `dom-overlay` when the API is absent.
* Selection is a raycast from the camera, the 3D replacement for the old 2D hit test, and the inspector recolors the shade, switches the base finish, and toggles the bulb, which relights the scene.
*
* @element product-configurator
* @slot header - Sits above the stage in the left column, for a harness caption over the canvas.
* @slot panel - Stacks under the real inspector in the right column of the `split` layout, for a harness control panel beside the canvas.
* @slot footer - Spans both columns under the stage in the `split` layout, for a tall report or log that would otherwise strand the canvas column if stacked in the narrow side column.
* @fires object-select - Fired when the selected part changes, carrying a `ConfiguratorSelectDetail`.
* @fires config-change - Fired after an inspector edit, carrying the whole `LampConfig`.
* @cssprop [--configurator-accent=#2257e6] - The selection and focus color used by the labels and the inspector.
*/
@customElement( 'product-configurator' )
export class ProductConfigurator extends LitElement {
/**
* The selectable parts, the four named meshes of the lamp.
*
* It is `attribute: false` because an array binds as a property (`.objects=${parts}`) and never round-trips through a string attribute.
* It has no default, since inventing the parts inside the component would hide the contract that the parent names the scene; the component builds a mesh for each known id and skips any other.
*/
@property( { attribute: false } )
objects: SceneObject[] = [];
/**
* Which ownership model the configurator renders.
*
* `html-in-canvas` is a request, not a guarantee: when the experimental API is absent the component renders `dom-overlay` instead, so the selected mode and the active mode can differ.
*/
@property( { type: String } )
mode: ConfiguratorMode = 'dom-overlay';
/**
* How the canvas and the inspector are arranged.
*
* Defaults to `stacked` so a standalone embed keeps the inspector under the canvas; a harness sets `split` to get the glass-demo shape, the canvas in a left column and the inspector plus a `panel` slot in one right column, collapsing back to a single stacked column on a narrow width.
*/
@property( { type: String } )
layout: ConfiguratorLayout = 'stacked';
/**
* Whether to deliberately skip writing the alignment transform back to the drawn label.
*
* Teaching switch for the experimental path: with the sync omitted, the focus ring and click target drift off the drawn pixels, which is the failure the article makes visible.
* It does nothing outside `html-in-canvas` mode on a supporting build.
*/
@property( { type: Boolean, attribute: 'omit-transform-sync' } )
omitTransformSync = false;
/**
* The id of the currently selected part, or `null` when nothing is selected.
*
* It is a controllable property: leave it unset and the component manages selection itself from a raycast or a keyboard pick, or bind it (`.selectedId=${id}`) to drive several configurators from one shared selection, the way the review demo syncs three modes.
* Either way consumers also hear changes through the `object-select` event, and the component writes the property on every pick so the uncontrolled case stays in sync.
*/
@property( { attribute: 'selected-id' } )
selectedId: string | null = null;
/**
* The viewport canvas Three.js renders the lamp into, resolved by Lit after the first render.
*/
@query( '.viewport' )
private $viewport!: HTMLCanvasElement;
/**
* The flat 2D overlay canvas stacked over the WebGL canvas, used to paint the faux UI in `canvas-only` and to host the drawn labels in `html-in-canvas`.
*/
@query( '.overlay-canvas' )
private $overlayCanvas?: HTMLCanvasElement;
/**
* The selected page theme, resolved on connect and kept in sync at runtime, so the studio is lit to match the page in light and dark.
*
* It is `@state` so a runtime theme flip re-renders, and the scene recolors through {@link applyTheme} rather than a rebuild.
*/
@state()
private theme: ConfiguratorTheme = 'light';
/**
* The lamp's configurable state, the shade swatch, the base finish, and the bulb, edited by the inspector.
*
* It is `@state` so an edit re-renders the inspector and re-runs the overlay draw, and it opens on the first swatch with a matte base and a lit bulb so the lamp reads as switched on.
*/
@state()
private config: LampConfig = {
shadeSwatchId: SHADE_SWATCHES[ 0 ].id,
baseFinish: 'matte',
bulbOn: true,
};
/**
* Whether the live 2D context exposes the experimental `drawElementImage` method.
*
* Resolved on connect from a scratch canvas, so it gates the experimental path per render rather than trusting the requested `mode`.
* It is `@state` so flipping it triggers the re-render that mounts the drawn label nodes, since `activeMode` reads it during `render`.
*/
@state()
private supportsHtmlInCanvas = false;
/**
* The Three.js scene, camera, and renderer, created once the viewport exists.
*/
private scene: THREE.Scene | null = null;
private camera: THREE.PerspectiveCamera | null = null;
private renderer: THREE.WebGLRenderer | null = null;
/**
* The built lamp and the handles to its parts, materials, and light, or `null` before the scene is built.
*/
private lamp: LampHandle | null = null;
/**
* The studio floor mesh, kept so its geometry and material can be disposed on teardown.
*/
private floorMesh: THREE.Mesh | null = null;
/**
* The studio floor material, kept so a theme change can recolor it in place.
*/
private floorMaterial: THREE.MeshStandardMaterial | null = null;
/**
* The themed studio lights, kept so a theme change can dim or lift them to match the palette.
*/
private themedLights: {
ambient: THREE.AmbientLight | null;
key: THREE.DirectionalLight | null;
rim: THREE.DirectionalLight | null;
} = { ambient: null, key: null, rim: null };
/**
* The reusable raycaster for the part-selection pick, allocated once.
*/
private readonly raycaster = new THREE.Raycaster();
/**
* A reusable vector for projecting a part anchor, so the overlay sync allocates nothing per frame.
*/
private readonly projected = new THREE.Vector3();
/**
* The overlay canvas 2D context, cached after the first paint so the per-frame draw does not re-acquire it.
*/
private overlayCtx: CanvasRenderingContext2D | null = null;
/**
* The CSS-pixel size the overlay backing store was last sized for, so it is only resized when the box actually changes rather than every frame.
*/
private overlaySize = { width: 0, height: 0, dpr: 0 };
/**
* The resolved selection accent as a Three.js color, cached so the per-frame highlight does not call `getComputedStyle`, and refreshed on a theme change.
*/
private readonly accent = new THREE.Color( 0x2257e6 );
/**
* The themed colors the flat overlay paints with, cached and refreshed on a theme change so the canvas-only and drawn-label paints do not read the DOM every frame.
*
* The canvas cannot inherit a CSS color, so these mirror the tokens the rest of the page uses, resolved once per theme rather than once per frame.
*/
private overlayColors = {
ink: '#17171a',
surface: '#ffffff',
accent: '#2257e6',
accentInk: '#ffffff',
fontSans: 'system-ui, sans-serif',
fontMono: 'monospace',
};
/**
* The current screen position of each part's overlay label, in CSS pixels, recomputed on every rendered frame while the lamp is on screen.
*
* It is the bridge from the 3D scene to the 2D layers: the DOM-overlay labels read it for their `left` and `top`, and the canvas-only and drawn-label paints read it to place text, so all three layers stay glued to the orbiting model from one projection.
*/
@state()
private anchorPoints: Record<string, { x: number; y: number; visible: boolean }> = {};
/**
* The orbit yaw and pitch in radians, driven by the drag and applied to the lamp group each frame.
*
* The lamp rotates rather than the camera, so the studio lighting stays fixed and the part the reader turns toward stays lit; the pitch is bounded so the lamp never tips past a readable angle.
*/
private orbit = { yaw: 0.5, pitch: 0.05 };
/**
* Whether a drag-to-orbit gesture is in progress, and the last pointer position it was sampled at.
*/
private isDragging = false;
/**
* The id of the pointer that started the current gesture, so a second finger or pen mid-drag cannot steer, end, or cancel a gesture it does not own.
*
* `isPrimary` is not enough as a filter, since touch and pen each get their own primary pointer; the id is the one key that names this exact contact.
*/
private activePointerId: number | null = null;
private lastPointer = { x: 0, y: 0 };
/**
* Whether the pointer moved past a small threshold during the current gesture, so a press-and-release in place still counts as a select rather than being eaten as a drag.
*/
private dragMoved = false;
/**
* Whether the viewport is currently on screen, so the render loop only runs while the demo is visible.
*/
private isVisible = true;
/**
* Whether {@link setUp} has wired the scene, listeners, and observers, so its two entry points, the first update and a reconnect, can never double-wire them.
*/
private isSetUp = false;
/**
* The media query that fires when `devicePixelRatio` changes, so the renderer and the overlay can be re-corrected on a zoom or a move to a different-density display.
*
* It is recreated on each change because the query is pinned to a single resolution, which is the lifecycle the canvas makes the author own.
*/
private dprQuery: MediaQueryList | null = null;
/**
* The media query the studio watches so it follows the OS color scheme when the page has not pinned a theme, retained so its listener tears down on disconnect.
*/
private themeMedia: MediaQueryList | null = null;
/**
* The observer watching `data-theme` on the document element, so an in-page theme toggle re-lights the studio at runtime, retained so it disconnects cleanly.
*/
private themeObserver: MutationObserver | null = null;
/**
* The resize and intersection observers, tracked so they tear down cleanly on disconnect.
*/
private resizeObserver: ResizeObserver | null = null;
private intersectionObserver: IntersectionObserver | null = null;
/**
* The animation-frame handle, cleared on disconnect or when the demo scrolls offscreen so a hidden lamp stops its loop.
*/
private frame = 0;
/**
* Whether the next animation frame has work to do.
*
* Nothing in the scene self-animates, so the loop keeps its frame cadence but skips the render and the overlay sync until an orbit, a selection, a config edit, a theme flip, or a resize marks the scene dirty through {@link requestRender}; label-only rendering changes in the experimental mode, a focus ring appearing on a drawn label, arrive through the canvas `paint` event and never need the flag.
*/
private needsRender = false;
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
:host {
display: block;
/* The host is a size container, so the split layout collapses on the embed's own width rather than the viewport's. */
container-type: inline-size;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
/* Resolve the selection accent into a private name; reading --configurator-accent first, then the themed --accent, then the brand, avoids the self-reference cycle that redefining --accent here would cause when a host sets --configurator-accent to the page accent. */
--cfg-accent: var( --configurator-accent, var( --accent, #2257e6 ) );
}
/* The stacked default keeps the canvas and inspector in one column; the split layout puts the canvas on the left and the inspector plus the harness panel in one right column, the glass-demo shape. */
.layout {
display: grid;
gap: var( --space-4, 16px );
}
.layout.split {
grid-template-columns: minmax( 0, 1fr ) 17rem;
grid-template-areas:
'main side'
'footer footer';
align-items: start;
}
.layout.split .main {
grid-area: main;
}
.layout.split .side {
grid-area: side;
}
/* The tall trailing content spans both columns under the stage, so a long report no longer strands the canvas column. */
.layout.split .footer {
grid-area: footer;
}
/* The left column: anything slotted into the header sits above the stage, and the stage carries the canvas. */
.main {
display: flex;
flex-direction: column;
gap: var( --space-3, 12px );
min-width: 0;
}
/* The header slot collapses to nothing when the harness slots no caption, so a bare embed shows just the stage. */
slot[name='header']::slotted( * ) {
margin: 0;
}
/* The right column of the split layout: the real inspector stacked above whatever the harness slots in, so every control reads as one column beside the canvas. */
.side {
display: flex;
flex-direction: column;
gap: var( --space-4, 16px );
min-width: 0;
}
.layout.split .inspector {
margin-top: 0;
}
/* Collapse to a single stacked column when the embed itself is too narrow for a comfortable two-column split. */
@container ( width <= 38rem ) {
.layout.split {
grid-template-columns: minmax( 0, 1fr );
grid-template-areas:
'main'
'side'
'footer';
}
}
.stage {
position: relative;
width: 100%;
aspect-ratio: 100 / 62;
border-radius: var( --radius-md, 11px );
border: 1px solid var( --line, #e7e6e2 );
overflow: hidden;
background: var( --surface, #fff );
}
canvas.viewport {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.stage.dragging canvas.viewport {
cursor: grabbing;
}
/* The flat overlay canvas sits exactly over the WebGL canvas in CSS-pixel space, the painted faux UI in canvas-only and the drawn-label host in html-in-canvas. */
canvas.overlay-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* The DOM-overlay layer sits over the canvas in CSS-pixel space, the bookkeeping the experimental API folds into the platform. */
.overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.overlay .label {
position: absolute;
transform: translate( -50%, -50% );
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
.overlay .label:hover {
border-color: var( --cfg-accent );
}
.overlay .label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
.overlay .label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
/*
* The drawn labels are real children of the overlay canvas in the experimental path: laid out and hit-testable, never display:none, anchored at the canvas origin with style.transform reserved for the platform alignment matrix that places and keeps them on the drawn pixels.
* Their look is kept identical to the DOM-overlay label above, the small light pill with ink text and a hairline border, so the only difference a reader can tell between the two modes is the mechanism, not the size or color of the pill.
*/
canvas.overlay-canvas .drawn-label {
position: absolute;
top: 0;
left: 0;
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
canvas.overlay-canvas .drawn-label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
canvas.overlay-canvas .drawn-label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.inspector {
margin-top: var( --space-3, 12px );
padding: var( --space-4, 16px );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-md, 11px );
background: var( --surface, #fff );
}
.inspector .head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var( --space-3, 12px );
margin-bottom: var( --space-3, 12px );
}
.inspector h3 {
margin: 0;
font-size: 14px;
color: var( --ink, #17171a );
}
.inspector .part-tag {
font-family: var( --font-mono, monospace );
font-size: 11px;
color: var( --ink-faint, #71717b );
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var( --space-3, 12px );
}
.field:last-child {
margin-bottom: 0;
}
.field > .field-label {
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var( --ink-faint, #71717b );
}
.swatches {
display: flex;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
border: 2px solid var( --line, #e7e6e2 );
cursor: pointer;
}
.swatch[aria-pressed='true'] {
border-color: var( --cfg-accent );
box-shadow: 0 0 0 2px var( --surface, #fff ), 0 0 0 4px var( --cfg-accent );
}
.segmented {
display: inline-flex;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-sm, 7px );
overflow: hidden;
width: max-content;
}
.segmented button {
padding: 7px 14px;
font: inherit;
font-size: 13px;
border: 0;
background: var( --bg, #fcfcfb );
color: var( --ink, #17171a );
cursor: pointer;
}
.segmented button + button {
border-left: 1px solid var( --line, #e7e6e2 );
}
.segmented button[aria-pressed='true'] {
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 13px;
color: var( --ink, #17171a );
cursor: pointer;
}
.switch .track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: var( --line, #e7e6e2 );
transition: background 0.2s ease;
flex: none;
}
.switch .track.on {
background: var( --cfg-accent );
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s ease;
}
.switch .track.on .knob {
transform: translateX( 16px );
}
`;
/**
* Resolves the page theme and the experimental-API support before the first render, so the studio opens in the page's palette and the first render already knows the active mode.
*
* On a reconnect it also re-runs {@link setUp}: the shadow DOM survives a re-parenting, so `hasUpdated` stays true, `firstUpdated` never fires again, and the wiring {@link tearDown} removed has to come back here, with the `@query` fields still resolving into the kept shadow root.
*/
connectedCallback(): void {
super.connectedCallback();
this.theme = this.detectTheme();
this.resolveSupport();
if ( this.hasUpdated ) {
this.setUp();
}
}
/**
* Runs the shared {@link setUp} once the viewport canvas exists after the first render.
*/
protected firstUpdated(): void {
this.setUp();
}
/**
* Builds the scene, wires the controls, observers, and theme listeners, and kicks the loop.
*
* It is shared by `firstUpdated` and a reconnect, because `firstUpdated` only ever fires once: without `connectedCallback` re-running this, a configurator moved to a new parent would come back with everything {@link tearDown} released still gone.
* The `isSetUp` guard keeps the two entry points from wiring the scene twice.
* It also refuses to wire a detached element: an append and remove in the same task still runs the pending first update after disconnection, and window listeners wired there would never hear the disconnect that already happened.
*/
private setUp(): void {
if ( this.isSetUp || ! this.isConnected ) {
return;
}
this.isSetUp = true;
this.refreshAccent();
this.initScene();
this.applySelectionHighlight();
this.bindEvents();
this.bindThemeListeners();
this.observeDevicePixelRatio();
this.startLoop();
}
/**
* Re-runs the overlay paint whenever a property that changes the flat layers updates, so a mode switch, a selection, or a config edit repaints the canvas-only UI or the drawn labels.
*
* A selection, mode, or objects change also marks the scene dirty through {@link requestRender}, so the gated loop draws the change on its next frame.
* The 3D scene redraws itself in the loop, so this only touches the flat overlay, and only when the loop is paused does it need to force one; the guarded call keeps a visible, animating demo from double-painting.
* The `anchorPoints` guard is what keeps a paused offscreen demo from looping: {@link syncOverlay} writes the `anchorPoints` `@state`, which schedules another update, so re-running `syncOverlay` on that follow-up would project, write `anchorPoints` again, and never terminate; skipping the call when the only change is `anchorPoints` lets a real change like a theme, selection, mode, or config edit sync the overlay exactly once and then settle.
*/
protected updated( changed: Map<PropertyKey, unknown> ): void {
if ( changed.has( 'selectedId' ) ) {
this.applySelectionHighlight();
this.requestRender();
}
if ( changed.has( 'mode' ) || changed.has( 'objects' ) ) {
this.requestRender();
}
if ( this.frame === 0 && ! changed.has( 'anchorPoints' ) ) {
this.syncOverlay();
}
}
/**
* Tears the configurator down through the shared {@link tearDown} when it leaves the document.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.tearDown();
}
/**
* Stops the loop and tears down every observer, listener, and the WebGL context so a removed configurator leaks nothing.
*
* It is the mirror of {@link setUp} rather than the body of `disconnectedCallback`, so a reconnected configurator can rebuild exactly what this released; clearing `isSetUp` is what re-arms that rebuild.
*/
private tearDown(): void {
this.isSetUp = false;
this.stopLoop();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
this.dprQuery = null;
this.themeMedia?.removeEventListener( 'change', this.onThemeMediaChange );
this.themeMedia = null;
this.themeObserver?.disconnect();
this.themeObserver = null;
this.$viewport?.removeEventListener( 'pointerdown', this.onPointerDown );
window.removeEventListener( 'pointermove', this.onPointerMove );
window.removeEventListener( 'pointerup', this.onPointerUp );
window.removeEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.removeEventListener( 'paint', this.onOverlayPaint );
if ( this.lamp ) {
disposeLamp( this.lamp );
this.lamp = null;
}
this.floorMesh?.geometry.dispose();
this.floorMesh = null;
this.floorMaterial?.dispose();
this.floorMaterial = null;
this.renderer?.dispose();
this.renderer = null;
// The window listeners that would release a live gesture are gone now, so the flags reset here; a stale drag would otherwise let plain hover motion orbit the lamp after a reconnect, since a mouse keeps a stable pointerId.
this.isDragging = false;
this.dragMoved = false;
this.activePointerId = null;
}
/**
* The mode actually rendered, which is the requested mode unless `html-in-canvas` was asked for without support.
*
* This is the single place the requested-versus-active split is resolved, so the template and the overlay paint agree on which layer owns the labels.
*/
private get activeMode(): ConfiguratorMode {
if ( this.mode === 'html-in-canvas' && ! this.supportsHtmlInCanvas ) {
return 'dom-overlay';
}
return this.mode;
}
/**
* The studio palette for the current theme, the one object the scene materials and lights read.
*/
private get palette() {
return STUDIO_PALETTES[ this.theme ];
}
/**
* The four selectable parts that map onto a real lamp mesh, in the lamp's stacking order.
*
* The component only knows the four lamp ids, so it filters the bound scene to them and keeps them in a stable order, which keeps the overlay labels and the raycast list aligned with the geometry the helper built.
*/
private get parts(): SceneObject[] {
const byId = new Map( this.objects.map( ( object ) => [ object.id, object ] ) );
const ordered: SceneObject[] = [];
for ( const id of PART_IDS ) {
const object = byId.get( id );
if ( object ) {
ordered.push( object );
}
}
return ordered;
}
/**
* The selected part, or `undefined` when nothing is selected or the selection is not a known lamp part.
*/
private get selectedPart(): SceneObject | undefined {
return this.parts.find( ( part ) => part.id === this.selectedId );
}
/**
* Records whether the drawn-label path can run, through the shared {@link supportsHtmlInCanvas} predicate that feature-detects the full path, `drawElementImage` on a live 2D context and `requestPaint` on the canvas, rather than sniffing a UA string.
*
* It runs from `connectedCallback`, since the probe needs only `document`, so the first render already knows the answer instead of flipping the mode an update cycle later.
*/
private resolveSupport(): void {
this.supportsHtmlInCanvas = supportsHtmlInCanvas();
}
/**
* Resolves the page theme the same way the demo page decides it: an explicit `data-theme` on the document element wins, and the OS color scheme is the fallback when nothing is pinned.
*
* @returns The theme the studio should light for right now.
*/
private detectTheme(): ConfiguratorTheme {
const pinned = document.documentElement.dataset.theme;
if ( pinned === 'dark' || pinned === 'light' ) {
return pinned;
}
return window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light';
}
/**
* The current size of the viewport canvas in CSS pixels, read from the element so it is right in both standalone and embedded contexts.
*/
private viewportSize(): { width: number; height: number } {
return {
width: this.$viewport.clientWidth || 600,
height: this.$viewport.clientHeight || Math.round( 600 * CANVAS_ASPECT ),
};
}
/**
* Creates the scene, camera, renderer, the studio floor and lighting, and the lamp, then frames the camera on the lamp.
*/
private initScene(): void {
const palette = this.palette;
const scene = new THREE.Scene();
scene.background = new THREE.Color( palette.background );
this.scene = scene;
const size = this.viewportSize();
const camera = new THREE.PerspectiveCamera( 38, size.width / size.height, 0.1, 100 );
camera.position.set( 0, 0.55, 5.6 );
camera.lookAt( 0, 0.26, 0 );
this.camera = camera;
const renderer = new THREE.WebGLRenderer( { canvas: this.$viewport, antialias: true, alpha: true } );
renderer.setSize( size.width, size.height, false );
renderer.setPixelRatio( Math.min( window.devicePixelRatio || 1, 2 ) );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
this.renderer = renderer;
this.buildStudio( scene );
this.buildLampInto( scene );
}
/**
* Builds the studio floor and the three-point lighting from the current palette, keeping each so a theme change can recolor or re-level it.
*
* @param scene - The scene to add the studio to.
*/
private buildStudio( scene: THREE.Scene ): void {
const palette = this.palette;
const floorMaterial = new THREE.MeshStandardMaterial( { color: palette.floor, roughness: 0.95, metalness: 0.05 } );
const floor = new THREE.Mesh( new THREE.CircleGeometry( 6, 64 ), floorMaterial );
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.95;
scene.add( floor );
this.floorMesh = floor;
this.floorMaterial = floorMaterial;
const ambient = new THREE.AmbientLight( 0xffffff, palette.ambient );
scene.add( ambient );
this.themedLights.ambient = ambient;
const key = new THREE.DirectionalLight( 0xffffff, palette.key );
key.position.set( 2.6, 4, 3.2 );
scene.add( key );
this.themedLights.key = key;
const rim = new THREE.DirectionalLight( 0xbcd0ff, palette.rim );
rim.position.set( -3, 2.2, -2.6 );
scene.add( rim );
this.themedLights.rim = rim;
}
/**
* Builds the lamp from the helper in the lamp's current config and adds it to the scene, applying the current orbit so it opens at a readable three-quarter angle.
*
* @param scene - The scene to add the lamp to.
*/
private buildLampInto( scene: THREE.Scene ): void {
const lamp = buildLamp( this.palette, this.shadeHex(), this.config.baseFinish, this.config.bulbOn );
lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
scene.add( lamp.group );
this.lamp = lamp;
}
/**
* The hex color of the currently selected shade swatch, falling back to the first swatch when the stored id is unknown.
*/
private shadeHex(): string {
const swatch = SHADE_SWATCHES.find( ( entry ) => entry.id === this.config.shadeSwatchId ) ?? SHADE_SWATCHES[ 0 ];
return swatch.hex;
}
/**
* Wires the orbit pointer listeners, the overlay's `paint` listener, the resize observer that keeps the renderer matched to the embedded stage, and the intersection observer that pauses the loop offscreen.
*
* The orbit starts on a `pointerdown` on the viewport and tracks on the window so a drag that leaves the canvas keeps rotating, and a release without a meaningful move is treated as a select on the same press.
* The `paint` listener registers once, because the overlay canvas is rendered in every mode and so survives a mode switch; on a build without the experimental API the event never fires, which leaves the handler inert.
*/
private bindEvents(): void {
this.$viewport.addEventListener( 'pointerdown', this.onPointerDown );
window.addEventListener( 'pointermove', this.onPointerMove );
window.addEventListener( 'pointerup', this.onPointerUp );
window.addEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.addEventListener( 'paint', this.onOverlayPaint );
this.resizeObserver = new ResizeObserver( () => this.onResize() );
this.resizeObserver.observe( this.$viewport );
this.intersectionObserver = new IntersectionObserver(
( entries ) => this.onIntersection( entries ),
{ threshold: 0.01 },
);
this.intersectionObserver.observe( this.$viewport );
}
/**
* Wires the two ways the page theme can change at runtime: the OS color scheme and an in-page `data-theme` toggle, both funnelled through {@link refreshTheme}.
*/
private bindThemeListeners(): void {
this.themeMedia = window.matchMedia( '(prefers-color-scheme: dark)' );
this.themeMedia.addEventListener( 'change', this.onThemeMediaChange );
this.themeObserver = new MutationObserver( () => this.refreshTheme() );
this.themeObserver.observe( document.documentElement, { attributes: true, attributeFilter: [ 'data-theme' ] } );
}
/**
* Re-resolves the OS color scheme into the studio when the system theme flips.
*/
private onThemeMediaChange = (): void => {
this.refreshTheme();
};
/**
* Re-resolves the theme and, when it actually changed, recolors the studio and the shared metal so the lamp follows the page.
*/
private refreshTheme(): void {
const next = this.detectTheme();
if ( next === this.theme ) {
return;
}
this.theme = next;
this.applyTheme();
}
/**
* Pushes the current palette onto the live scene: the background, the floor, the lights, and the shared metal tone of the arm and base, so a runtime theme change lands without rebuilding the scene.
*/
private applyTheme(): void {
const palette = this.palette;
if ( this.scene ) {
this.scene.background = new THREE.Color( palette.background );
}
this.floorMaterial?.color.setHex( palette.floor );
if ( this.themedLights.ambient ) {
this.themedLights.ambient.intensity = palette.ambient;
}
if ( this.themedLights.key ) {
this.themedLights.key.intensity = palette.key;
}
if ( this.themedLights.rim ) {
this.themedLights.rim.intensity = palette.rim;
}
if ( this.lamp ) {
this.lamp.baseMaterial.color.setHex( palette.metal );
this.lamp.armMaterial.color.setHex( palette.metal );
}
this.refreshAccent();
this.applySelectionHighlight();
this.requestRender();
}
/**
* Sizes the WebGL backing store and the overlay backing store for the current device pixel ratio, and rebuilds the one-shot listener for the next change.
*
* `matchMedia( '(resolution: Ndppx)' )` only watches the current ratio, so a move to a different-density display needs the query rebuilt each time; the renderer caps the ratio at two so a very dense display does not balloon the backing store.
*/
private observeDevicePixelRatio(): void {
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
const dpr = window.devicePixelRatio || 1;
this.dprQuery = window.matchMedia( `(resolution: ${ dpr }dppx)` );
this.dprQuery.addEventListener( 'change', this.onDevicePixelRatioChange, { once: true } );
this.renderer?.setPixelRatio( Math.min( dpr, 2 ) );
}
/**
* Re-corrects the backing stores after the device pixel ratio changes, then re-arms the watcher and repaints.
*/
private onDevicePixelRatioChange = (): void => {
this.observeDevicePixelRatio();
this.onResize();
};
/**
* Resizes the renderer and updates the camera aspect when the viewport's CSS box changes, then repaints the overlay.
*/
private onResize(): void {
if ( ! this.camera || ! this.renderer ) {
return;
}
const size = this.viewportSize();
this.camera.aspect = size.width / size.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize( size.width, size.height, false );
this.requestRender();
this.syncOverlay();
}
/**
* Pauses or resumes the render loop as the demo scrolls in and out of view, so an offscreen lamp costs no frames.
*
* @param entries - The intersection records for the observed viewport.
*/
private onIntersection( entries: IntersectionObserverEntry[] ): void {
for ( const entry of entries ) {
this.isVisible = entry.isIntersecting;
if ( this.isVisible ) {
this.startLoop();
} else {
this.stopLoop();
}
}
}
/**
* Begins a drag-to-orbit gesture, recording the start pointer so motion is measured as a delta and a press in place can still resolve to a select.
*
* Only the primary pointer's primary button starts a gesture: a right or middle click falls through to the browser, since a context menu can swallow the matching release and leave a phantom drag armed, and a gesture already in progress owns the viewport, so a new contact of any type is ignored until the owning pointer releases, because `isPrimary` is per pointer type and a first touch mid-mouse-drag would otherwise pass the guard and steal the gesture.
*
* @param event - The pointerdown on the viewport.
*/
private onPointerDown = ( event: PointerEvent ): void => {
if ( this.activePointerId !== null || ! event.isPrimary || event.button !== 0 ) {
return;
}
this.isDragging = true;
this.dragMoved = false;
this.activePointerId = event.pointerId;
this.lastPointer = { x: event.clientX, y: event.clientY };
};
/**
* Applies a drag delta to the orbit yaw and pitch while a gesture is active, bounding the pitch so the lamp stays framed.
*
* Once the pointer has moved past a few pixels the gesture is marked as a drag, so the matching release orbits rather than selecting.
*
* @param event - The pointermove on the window.
*/
private onPointerMove = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
const dx = event.clientX - this.lastPointer.x;
const dy = event.clientY - this.lastPointer.y;
this.lastPointer = { x: event.clientX, y: event.clientY };
if ( Math.abs( dx ) + Math.abs( dy ) > 3 ) {
this.dragMoved = true;
}
this.orbit.yaw += dx * 0.01;
this.orbit.pitch += dy * 0.01;
this.orbit.pitch = Math.max( -0.5, Math.min( 0.6, this.orbit.pitch ) );
this.requestRender();
};
/**
* Ends the gesture, and when the pointer never moved past the drag threshold, treats the release as a raycast select on the part under it.
*
* @param event - The pointerup on the window.
*/
private onPointerUp = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
if ( ! this.dragMoved ) {
this.pickAt( event );
}
};
/**
* Abandons the gesture when the browser cancels the owning pointer, a touch scroll takeover or a system sheet, without treating the interruption as a select.
*
* Only the gesture's own pointer can cancel it, so an unrelated contact being cancelled leaves a live drag alone.
*/
private onPointerCancel = ( event: PointerEvent ): void => {
if ( event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
};
/**
* Raycasts from the camera through a pointer position and selects the lamp part it hits, or clears the selection on a miss.
*
* This is the article's 3D hit-testing lesson: there is still no built-in canvas hit test, so the pointer is converted into normalized device coordinates, `raycaster.setFromCamera` builds the ray, and `intersectObjects` against the whole lamp group resolves the nearest hit to a part id, the raycast that replaces the old `isPointInPath`.
* Every visible mesh carries the id of the part it belongs to, so a click on a cosmetic piece like the shade cap or the neck still selects; a hit whose chain carries no id, the additive glow halo, falls through to the next hit rather than eating the click, and the selection only clears when nothing in the list resolves.
* In `canvas-only` the painted UI sits on top of the scene, so its pills, swatches, and panel are hit-tested first, more controls whose geometry has to be re-derived by hand because no DOM exists to do it.
*
* @param event - The pointer event whose position to pick at.
*/
private pickAt( event: PointerEvent ): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
if ( this.activeMode === 'canvas-only' && this.pickFauxUiAt( event ) ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const ndc = new THREE.Vector2(
( ( event.clientX - rect.left ) / rect.width ) * 2 - 1,
-( ( event.clientY - rect.top ) / rect.height ) * 2 + 1,
);
this.raycaster.setFromCamera( ndc, this.camera );
const hits = this.raycaster.intersectObjects( this.lamp.group.children, true );
for ( const hit of hits ) {
const id = this.partIdForObject( hit.object );
if ( id ) {
this.select( id, 'canvas' );
return;
}
}
this.select( null, 'canvas' );
}
/**
* Hit-tests the painted faux UI by hand, the part label pills first, then the inspector's swatch row, then the panel body, and applies whatever the matching real control would do.
*
* This mirrors the exact geometry {@link paintPill} and {@link paintFauxInspector} paint (the pill boxes around the visible anchors, the swatch centers and radius, the {@link fauxPanelRect} box), because painted pixels carry no hit regions of their own.
* That duplication is the point the trap teaches: the mouse path can be made to feel complete, but every painted control costs a hand-derived hit region like this, the demo now pays that cost for all of them, and focus, the accessibility tree, and text selection still get nothing.
* A hit on the panel body that lands on no control still returns `true`, so a click on the painted inspector never raycasts the scene through it.
* The swatch hit radius is a couple of pixels wider than the painted circle so the click is comfortable, the kind of forgiveness a real `<button>` gives for free.
*
* @param event - The pointer event whose position to test.
* @returns Whether the point landed on the painted UI, with the matching action applied.
*/
private pickFauxUiAt( event: PointerEvent ): boolean {
const rect = this.$viewport.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
// Test in reverse paint order, so the topmost painted thing wins: the swatches sit on the panel, the panel covers any pill it overlaps, and the pills sit over the scene.
const panel = this.fauxPanelRect( rect.width, rect.height );
const swatchY = panel.y + 54 + 8;
let swatchX = panel.x + 12 + 8;
for ( const swatch of SHADE_SWATCHES ) {
if ( Math.hypot( px - swatchX, py - swatchY ) <= 10 ) {
this.onSelectSwatch( swatch.id );
return true;
}
swatchX += 24;
}
if ( px >= panel.x && px <= panel.x + panel.width && py >= panel.y && py <= panel.y + panel.height ) {
return true;
}
const ctx = this.overlayCtx;
if ( ctx ) {
ctx.font = `600 12px ${ this.overlayColors.fontSans }`;
// Within the pills the test also runs in reverse paint order, since a later-painted pill covers an earlier one where they overlap: one more stacking decision the DOM normally owns and painted UI has to re-derive.
for ( const part of [ ...this.parts ].reverse() ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
const padX = 9;
const boxWidth = ctx.measureText( part.label ).width + padX * 2;
const boxHeight = 22;
const x = point.x - boxWidth / 2;
const y = point.y - boxHeight / 2;
if ( px >= x && px <= x + boxWidth && py >= y && py <= y + boxHeight ) {
this.select( part.id, 'canvas' );
return true;
}
}
}
return false;
}
/**
* Resolves the part a hit object belongs to by walking its ancestry to the first stamped `userData.partId`.
*
* The geometry helper stamps every visible mesh, cosmetic pieces included, so a hit anywhere on the lamp maps to the part a user believes they clicked; the glow sprite is left unstamped on purpose, so this returns `null` for it and the caller moves on to the next hit.
*
* @param object - The object the raycaster returned.
* @returns The part id stamped on the object or an ancestor, or `null` when the chain carries none.
*/
private partIdForObject( object: THREE.Object3D ): LampPartId | null {
let current: THREE.Object3D | null = object;
while ( current ) {
const stamped = current.userData.partId;
const id = PART_IDS.find( ( partId ) => partId === stamped );
if ( id ) {
return id;
}
current = current.parent;
}
return null;
}
/**
* Starts the render loop if it is not already running and the demo is on screen, and marks the scene dirty so the first frame back repaints.
*/
private startLoop(): void {
if ( this.frame === 0 && this.isVisible ) {
this.frame = requestAnimationFrame( this.renderLoop );
}
this.requestRender();
}
/**
* Stops the render loop and clears the frame handle so it can be restarted cleanly.
*/
private stopLoop(): void {
if ( this.frame !== 0 ) {
cancelAnimationFrame( this.frame );
this.frame = 0;
}
}
/**
* Marks the scene dirty so the next animation frame renders the WebGL view and re-syncs the overlay.
*/
private requestRender(): void {
this.needsRender = true;
}
/**
* The render loop: applies the orbit to the lamp, draws the WebGL frame, and re-syncs the overlay so the labels stay glued as the model turns.
*
* The loop stays hot at the frame cadence while the demo is on screen, but the work is gated on {@link needsRender}: an idle frame re-arms itself and returns, so a lamp nobody is touching renders nothing and writes no fresh `anchorPoints`, and the first frame after a change does the full pass.
*/
private renderLoop = (): void => {
this.frame = requestAnimationFrame( this.renderLoop );
if ( ! this.needsRender ) {
return;
}
if ( ! this.renderer || ! this.scene || ! this.camera || ! this.lamp ) {
return;
}
this.needsRender = false;
this.lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
this.renderer.render( this.scene, this.camera );
this.syncOverlay();
};
/**
* Lifts the emissive of the selected part and rests every other part, so the selection reads in 3D as a glow on the chosen mesh in either theme.
*
* It runs on a selection or theme change rather than every frame, since the emissive persists in the material between renders, and the cached accent keeps it off the `getComputedStyle` path.
* The bulb is left to its own lit glow, so selecting it does not fight the bulb toggle; its overlay label and inspector heading still mark the selection.
*/
private applySelectionHighlight(): void {
if ( ! this.lamp ) {
return;
}
for ( const id of PART_IDS ) {
if ( id === 'bulb' ) {
continue;
}
const material = this.lamp.parts[ id ].material;
if ( Array.isArray( material ) || ! ( material instanceof THREE.MeshStandardMaterial ) ) {
continue;
}
const selected = id === this.selectedId;
material.emissive.copy( selected ? this.accent : BLACK );
material.emissiveIntensity = selected ? 0.32 : 0;
}
}
/**
* Projects each part's 3D anchor to a CSS-pixel screen position and stores it, then repaints the flat overlay for the active mode.
*
* This is the alignment bookkeeping the article says the overlay owns: the anchor is taken into world space through the lamp's matrix, projected with the camera, and converted from normalized device coordinates to CSS pixels against the canvas rect, which keeps the projection device-pixel-ratio correct because the rect is in CSS pixels while the WebGL backing store carries the ratio separately.
*/
private syncOverlay(): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
this.lamp.group.updateMatrixWorld();
const next: Record<string, { x: number; y: number; visible: boolean }> = {};
for ( const id of PART_IDS ) {
this.projected.copy( this.lamp.anchors[ id ] );
this.lamp.group.localToWorld( this.projected );
this.projected.project( this.camera );
next[ id ] = {
x: ( this.projected.x * 0.5 + 0.5 ) * width,
y: ( -this.projected.y * 0.5 + 0.5 ) * height,
visible: this.projected.z < 1,
};
}
this.anchorPoints = next;
this.paintFlatOverlay();
}
/**
* Repaints the flat overlay canvas for the active mode: the faux UI in `canvas-only`, a requested `paint` event for the drawn labels in `html-in-canvas`, and a clear in `dom-overlay` where real HTML owns the labels.
*
* The experimental branch neither draws nor clears here; it calls `requestPaint()` and {@link onOverlayPaint} clears and draws inside the event, so a platform-initiated paint, a focus ring appearing on a drawn label, repaints the whole canvas atomically instead of compositing over pixels this pass left behind. The shared {@link supportsHtmlInCanvas} predicate gates the mode on both methods, so the branch only runs where the request is real.
*/
private paintFlatOverlay(): void {
const $canvas = this.$overlayCanvas;
if ( ! $canvas ) {
return;
}
if ( ! this.overlayCtx ) {
this.overlayCtx = $canvas.getContext( '2d' );
}
const ctx = this.overlayCtx;
if ( ! ctx ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = rect.width || 1;
const cssHeight = rect.height || 1;
// Resize the backing store when the box or the device pixel ratio changes, since assigning width or height clears and reallocates the canvas, which would flicker if done every frame; keying on dpr too reallocates when only the ratio shifts (a display move or browser zoom that leaves the CSS box bit-identical), matching the WebGL side that already re-sizes for the new ratio.
if ( this.overlaySize.width !== cssWidth || this.overlaySize.height !== cssHeight || this.overlaySize.dpr !== dpr ) {
$canvas.width = Math.round( cssWidth * dpr );
$canvas.height = Math.round( cssHeight * dpr );
this.overlaySize = { width: cssWidth, height: cssHeight, dpr };
}
if ( this.activeMode === 'html-in-canvas' ) {
$canvas.requestPaint();
return;
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
ctx.clearRect( 0, 0, cssWidth, cssHeight );
if ( this.activeMode === 'canvas-only' ) {
this.paintFauxUi( ctx, cssWidth, cssHeight );
}
}
/**
* Paints the canvas-only trap: every part label and a faux inspector panel, all as raw pixels with no DOM behind them.
*
* It looks like a working configurator, but the text is pixels that cannot be selected, found in page, or reached with a Tab key or a screen reader, which is the trap a real 3D scene invites because it is exactly where someone reaches for drawing the UI onto the canvas too.
*
* @param ctx - The overlay 2D context, scaled so one unit is one CSS pixel.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxUi( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
this.paintPill( ctx, part.label, point.x, point.y, part.id === this.selectedId );
}
this.paintFauxInspector( ctx, width, height );
}
/**
* Paints one rounded label pill centered on a point, the faux equivalent of an overlay label, from the cached themed colors.
*
* @param ctx - The overlay 2D context.
* @param text - The label text.
* @param cx - The pill center x in CSS pixels.
* @param cy - The pill center y in CSS pixels.
* @param selected - Whether to fill the pill with the accent.
*/
private paintPill( ctx: CanvasRenderingContext2D, text: string, cx: number, cy: number, selected: boolean ): void {
const colors = this.overlayColors;
ctx.font = `600 12px ${ colors.fontSans }`;
const padX = 9;
const boxWidth = ctx.measureText( text ).width + padX * 2;
const boxHeight = 22;
const x = cx - boxWidth / 2;
const y = cy - boxHeight / 2;
const pill = new Path2D();
pill.roundRect( x, y, boxWidth, boxHeight, 11 );
ctx.fillStyle = selected ? colors.accent : colors.surface;
ctx.fill( pill );
ctx.lineWidth = 1;
ctx.strokeStyle = selected ? colors.accent : this.withAlpha( colors.ink, 0.18 );
ctx.stroke( pill );
ctx.fillStyle = selected ? colors.accentInk : colors.ink;
ctx.textBaseline = 'middle';
ctx.fillText( text, x + padX, y + boxHeight / 2 );
}
/**
* The faux inspector panel's box in CSS pixels, one derivation shared by the painter and the hand-built hit test so the drawn panel and its hit region cannot drift apart.
*
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
* @returns The panel's position and size.
*/
private fauxPanelRect( width: number, height: number ): { x: number; y: number; width: number; height: number } {
const panelWidth = Math.min( 190, width - 24 );
const panelHeight = 96;
return {
x: width - panelWidth - 12,
y: height - panelHeight - 12,
width: panelWidth,
height: panelHeight,
};
}
/**
* Paints the faux inspector panel into the corner of the canvas, a picture of the real inspector with no DOM behind it, from the cached themed colors.
*
* The panel responds to the mouse only because {@link pickFauxUiAt} re-derives this exact geometry by hand, from the same {@link fauxPanelRect} box this painter draws; there is still no element, so nothing here can take focus, appear in the accessibility tree, or offer selectable text.
*
* @param ctx - The overlay 2D context.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxInspector( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
const colors = this.overlayColors;
const box = this.fauxPanelRect( width, height );
const x = box.x;
const y = box.y;
const panel = new Path2D();
panel.roundRect( x, y, box.width, box.height, 10 );
ctx.fillStyle = this.withAlpha( colors.surface, 0.95 );
ctx.fill( panel );
ctx.lineWidth = 1;
ctx.strokeStyle = this.withAlpha( colors.ink, 0.16 );
ctx.stroke( panel );
const selected = this.selectedPart;
ctx.fillStyle = colors.ink;
ctx.textBaseline = 'alphabetic';
ctx.font = `600 13px ${ colors.fontSans }`;
ctx.fillText( selected ? selected.label : 'Inspector', x + 12, y + 24 );
ctx.font = `11px ${ colors.fontMono }`;
ctx.fillStyle = this.withAlpha( colors.ink, 0.6 );
ctx.fillText( 'Shade color', x + 12, y + 46 );
const swatchY = y + 54;
let swatchX = x + 12;
for ( const swatch of SHADE_SWATCHES ) {
ctx.beginPath();
ctx.arc( swatchX + 8, swatchY + 8, 8, 0, Math.PI * 2 );
ctx.fillStyle = swatch.hex;
ctx.fill();
if ( swatch.id === this.config.shadeSwatchId ) {
ctx.lineWidth = 2;
ctx.strokeStyle = colors.accent;
ctx.stroke();
}
swatchX += 24;
}
}
/**
* Draws the real labels when the overlay canvas fires its `paint` event, so the experimental draw runs inside the event the way the canonical flow prescribes.
*
* The render loop and the sync passes never draw or clear the labels themselves; {@link paintFlatOverlay} requests a paint, and this handler clears the canvas and runs {@link drawLabelsIntoCanvas} when the event lands, so a platform-initiated paint, a focus ring appearing on a label, starts from its own clear instead of leaving residue over the last frame.
* The mode guard mirrors the experimental branch of the paint pass, since the listener stays wired in every mode, and the context guard covers an event that lands before the first paint pass has cached the 2D context.
*/
private onOverlayPaint = (): void => {
const ctx = this.overlayCtx;
const $canvas = this.$overlayCanvas;
if ( this.activeMode !== 'html-in-canvas' || ! ctx || ! $canvas ) {
return;
}
// Clear in device pixels under the identity transform, the same grid the label draw below runs on.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
ctx.clearRect( 0, 0, $canvas.width, $canvas.height );
this.drawLabelsIntoCanvas( ctx );
};
/**
* Draws every visible real label node into the overlay canvas at its own 1:1 size and projected center, then writes each returned alignment matrix back, the experimental in-canvas-label path.
*
* Each label's `style.transform` is a platform-owned slot here. The node is already a reachable element the browser exposes in the accessibility tree and the tab order, because the overlay canvas keeps it a real DOM child under `layoutsubtree`; writing the matrix back is only what keeps that element's focus ring, hit target, and accessibility bounds aligned under its drawn pixels. When `omitTransformSync` is set the write is skipped so the reader can watch the focus ring and click target drift off the pixels.
*
* A label whose part is facing away is hidden rather than skipped in place: `visibility: hidden` also removes it from the tab order and the accessibility tree while it is offscreen, the same contract the DOM-overlay labels keep, where a skipped node would otherwise stay tabbable at its stale position.
*
* The work runs in three passes, the transform resets and visibility first, then every box measurement, then every draw and write-back, so the style writes cost at most two layout flushes per paint instead of one per label.
*
* The draw runs on the bare device-pixel grid, not under the `dpr` scale the faux UI paints with: the label node already rasterizes at the display's pixel density, so drawing it under an extra `dpr` transform would double its size, which is what made the drawn pills balloon. The projected center and the label box are scaled into device pixels by hand instead, so the drawn pill matches the DOM-overlay pill exactly, then the transform is restored for the next paint.
*
* The explainer's canonical flow routes this draw through the canvas `paint` event, and that is where it runs: the render loop and the sync passes only call `requestPaint()`, and {@link onOverlayPaint} invokes this method when the event fires. Calling `drawElementImage` outside the event is still defined behavior, but it draws the previous frame's snapshot of the element and throws before a first snapshot exists, which is why the handler owns the draw.
*
* @param ctx - The overlay 2D context, on a supporting build where `drawElementImage` exists.
*/
private drawLabelsIntoCanvas( ctx: CanvasRenderingContext2D ): void {
const $host = this.$overlayCanvas;
if ( ! $host ) {
return;
}
const dpr = window.devicePixelRatio || 1;
// Drop to the identity transform so one drawing unit is one backing-store pixel, the space the element bitmap is already rasterized in; the faux-UI dpr scale is restored at the end.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
// First pass: reset any prior alignment before measuring, so each box read is the label's own size rather than a previously skewed one, and hide the labels whose part is facing away.
const drawable: Array<{ point: { x: number; y: number }; $node: HTMLElement }> = [];
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
const $node = $host.querySelector( `[data-part='${ part.id }']` );
if ( ! ( $node instanceof HTMLElement ) ) {
continue;
}
if ( ! point || ! point.visible ) {
$node.style.visibility = 'hidden';
continue;
}
$node.style.visibility = '';
if ( ! this.omitTransformSync ) {
$node.style.transform = '';
}
drawable.push( { point, $node } );
}
// Second pass: measure every box in one batch, so the writes above flush layout once rather than per label.
const boxes = drawable.map( ( entry ) => entry.$node.getBoundingClientRect() );
// Third pass: draw each label at its projected center and write the alignment matrix back.
for ( const [ index, entry ] of drawable.entries() ) {
const box = boxes[ index ];
const dx = ( entry.point.x - box.width / 2 ) * dpr;
const dy = ( entry.point.y - box.height / 2 ) * dpr;
const alignment = ctx.drawElementImage( entry.$node, dx, dy );
if ( ! this.omitTransformSync ) {
entry.$node.style.transform = alignment.toString();
}
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
}
/**
* Reads a CSS custom property off the host, falling back to a literal when the token sheet has not loaded.
*
* A canvas paints raw pixels and cannot inherit a CSS color, so every themed color the faux UI draws is read here at paint time, which is what keeps the canvas-only labels legible in light and dark.
*
* @param name - The custom property to read.
* @param fallback - The literal color to use when the property is empty.
* @returns The resolved color string.
*/
private token( name: string, fallback: string ): string {
const value = getComputedStyle( this ).getPropertyValue( name ).trim();
return value || fallback;
}
/**
* Resolves the selection accent the same way the stylesheet does, reading the private `--cfg-accent` so the 3D highlight and the painted UI match the DOM controls in either theme.
*/
private accentColor(): string {
return this.token( '--cfg-accent', '#2257e6' );
}
/**
* Re-reads the themed colors into the caches the render loop relies on: the accent as a Three.js color for the 3D highlight, and the token set the flat overlay paints with.
*
* It is called on connect and after a theme change, so every per-frame draw works from cached values rather than calling `getComputedStyle`.
* The flip side of that cache is staleness: a host that swaps `--configurator-accent` at runtime without a theme flip paints the old accent until the next theme change refreshes it.
*/
private refreshAccent(): void {
const accent = this.accentColor();
this.accent.set( accent );
this.overlayColors = {
ink: this.token( '--ink', '#17171a' ),
surface: this.token( '--surface', '#ffffff' ),
accent,
accentInk: this.token( '--accent-ink', '#ffffff' ),
fontSans: this.token( '--font-sans', 'system-ui, sans-serif' ),
fontMono: this.token( '--font-mono', 'monospace' ),
};
}
/**
* Returns a color string at a given alpha, parsing a `#rgb` or `#rrggbb` token into `rgba()` and mixing any other syntax toward transparent with `color-mix()`.
*
* The canvas needs an explicit alpha for the faux panel and pill borders, but the theme tokens are usually opaque hex, so the fast path parses those by hand; anything else goes through `color-mix()`, which any browser modern enough to hand us a non-hex token can paint.
*
* @param color - The base color, usually a hex token value.
* @param alpha - The alpha to apply, from 0 to 1.
* @returns An `rgba()` string for hex input, or a `color-mix()` expression for anything else.
*/
private withAlpha( color: string, alpha: number ): string {
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec( color.trim() );
if ( ! match ) {
return `color-mix( in srgb, ${ color } ${ Math.round( alpha * 100 ) }%, transparent )`;
}
const body = match[ 1 ];
const full = body.length === 3 ? body.split( '' ).map( ( c ) => c + c ).join( '' ) : body;
const r = parseInt( full.slice( 0, 2 ), 16 );
const g = parseInt( full.slice( 2, 4 ), 16 );
const b = parseInt( full.slice( 4, 6 ), 16 );
return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`;
}
/**
* Selects a part from a real overlay label, the keyboard-reachable path the raycast cannot provide.
*
* @param id - The id of the part the label represents.
*/
private onLabelClick( id: string ): void {
this.select( id, 'overlay' );
}
/**
* Updates the selection and dispatches the typed `object-select` event, tagged with the layer it came from.
*
* The event fires on every pick, even a repeat of the current selection, so a demo log can record each raycast; the internal `selectedId` only changes when the target actually changes.
* The event is `composed` so it crosses the shadow boundary and `bubbles` so a listener on the host hears it.
*
* @param id - The id to select, or `null` to clear.
* @param source - Which layer triggered the selection, the raycast or a real overlay control.
*/
private select( id: string | null, source: ConfiguratorSelectSource ): void {
if ( id !== this.selectedId ) {
this.selectedId = id;
}
this.dispatchEvent(
new CustomEvent<ConfiguratorSelectDetail>( 'object-select', {
detail: { objectId: id, source },
bubbles: true,
composed: true,
} ),
);
}
/**
* Recolors the shade to a swatch, applying it to the live material and reporting the new config.
*
* @param swatchId - The id of the chosen swatch.
*/
private onSelectSwatch( swatchId: string ): void {
this.config = { ...this.config, shadeSwatchId: swatchId };
if ( this.lamp ) {
setShadeColor( this.lamp, this.shadeHex() );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Switches the base finish, retuning the live base material and reporting the new config.
*
* @param finish - The finish to apply, matte or metal.
*/
private onSelectFinish( finish: BaseFinish ): void {
this.config = { ...this.config, baseFinish: finish };
if ( this.lamp ) {
setBaseFinish( this.lamp, finish );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Toggles the bulb, relighting the scene through the helper and reporting the new config.
*/
private onToggleBulb(): void {
const bulbOn = ! this.config.bulbOn;
this.config = { ...this.config, bulbOn };
if ( this.lamp ) {
setBulbState( this.lamp, bulbOn );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Dispatches the typed `config-change` event with the whole current config after an inspector edit.
*
* It is `composed` and `bubbles` for the same reason the selection event is, so a host listener hears every edit without a cast.
*/
private emitConfigChange(): void {
this.dispatchEvent(
new CustomEvent<ConfiguratorConfigChangeDetail>( 'config-change', {
detail: { config: this.config },
bubbles: true,
composed: true,
} ),
);
}
/**
* Types `addEventListener` for the configurator's own events so a listener receives the typed `CustomEvent` with no cast, and falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof ProductConfiguratorEventMap>(
type: K,
listener: ( this: ProductConfigurator, ev: ProductConfiguratorEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the WebGL viewport, the flat overlay canvas, the real DOM labels or the drawn-label nodes for the active mode, and the inspector and harness panel in the side column.
*
* The WebGL canvas always renders the lamp; what changes per mode is whether real HTML labels sit over it (`dom-overlay`), real label nodes are drawn into the overlay canvas (`html-in-canvas`), or the labels and a faux inspector are painted onto the overlay canvas with no DOM (`canvas-only`).
* The stage and the side column are always laid out by the same `.layout` grid: `stacked` keeps it one column so the side falls under the canvas, and `split` makes it two so the side becomes the right column, the glass-demo shape.
*/
render() {
const active = this.activeMode;
const parts = this.parts;
return html`
<div class=${ this.layout === 'split' ? 'layout split' : 'layout' }>
<div class="main">
<slot name="header"></slot>
<div class=${ this.isDragging ? 'stage dragging' : 'stage' }>
<canvas class="viewport" role="img" aria-label="A 3D desk lamp you can orbit by dragging. Click a part to select it."></canvas>
<canvas class="overlay-canvas" aria-hidden=${ active === 'html-in-canvas' ? 'false' : 'true' } ?layoutsubtree=${ active === 'html-in-canvas' }>
${ active === 'html-in-canvas'
? parts.map( ( part ) => html`
<button
type="button"
class="drawn-label"
data-part=${ part.id }
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
` )
: nothing }
</canvas>
${ active === 'dom-overlay'
? html`
<div class="overlay">
${ parts.map( ( part ) => {
const point = this.anchorPoints[ part.id ];
const shown = point && point.visible;
return html`
<button
type="button"
class="label"
style="left: ${ point ? point.x : 0 }px; top: ${ point ? point.y : 0 }px; visibility: ${ shown ? 'visible' : 'hidden' };"
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
`;
} ) }
</div>
`
: nothing }
</div>
</div>
${ this.renderSide( active ) }
</div>
`;
}
/**
* Renders the side column of the layout, the real inspector when the mode has one stacked above the harness `panel` slot, and in `split` a full-width `footer` slot under both columns.
*
* In `split` it is always rendered so it forms the right column even in `canvas-only`, where the inspector is painted into the canvas and the slot still carries the harness panel. In `stacked` it is rendered only when there is something to show, the inspector for a mode that has one, so a bare `canvas-only` embed does not leave an empty row under the canvas.
* The `footer` row spans both columns and only renders in `split`, so a harness can drop a tall report or log there instead of stacking it in the narrow side column, where it would run far below the stage and strand the canvas column; an empty `footer` slot produces no box, so a harness that slots nothing renders exactly as before.
*
* @param active - The active mode, which decides whether a real DOM inspector exists.
* @returns The side-column template, or `nothing` when stacked with no inspector to show.
*/
private renderSide( active: ConfiguratorMode ) {
const inspector = active === 'canvas-only' ? nothing : this.renderInspector();
if ( this.layout !== 'split' && active === 'canvas-only' ) {
return nothing;
}
return html`
<div class="side">
${ inspector }
<slot name="panel"></slot>
</div>
${ this.layout === 'split'
? html`<div class="footer"><slot name="footer"></slot></div>`
: nothing }
`;
}
/**
* Renders the real DOM inspector for the selected part: the shade swatches, the base finish, and the bulb toggle, each a real, keyboard-reachable control.
*
* It shows every control whatever the selected part, since the lamp is one product, and names the selected part in the heading so the selection still reads; with nothing selected the heading falls back to the whole lamp.
*/
private renderInspector() {
const selected = this.selectedPart;
return html`
<div class="inspector">
<div class="head">
<h3>${ selected ? selected.label : 'Lamp' }</h3>
${ selected ? html`<span class="part-tag">#${ selected.id }</span>` : nothing }
</div>
<div class="field">
<span class="field-label">Shade color</span>
<div class="swatches">
${ SHADE_SWATCHES.map( ( swatch ) => html`
<button
type="button"
class="swatch"
style="background: ${ swatch.hex };"
aria-label=${ swatch.label }
aria-pressed=${ swatch.id === this.config.shadeSwatchId }
@click=${ () => this.onSelectSwatch( swatch.id ) }
></button>
` ) }
</div>
</div>
<div class="field">
<span class="field-label">Base finish</span>
<div class="segmented">
<button type="button" aria-pressed=${ this.config.baseFinish === 'matte' } @click=${ () => this.onSelectFinish( 'matte' ) }>Matte</button>
<button type="button" aria-pressed=${ this.config.baseFinish === 'metal' } @click=${ () => this.onSelectFinish( 'metal' ) }>Metal</button>
</div>
</div>
<div class="field">
<span class="field-label">Bulb</span>
<button type="button" class="switch" aria-pressed=${ this.config.bulbOn } @click=${ () => this.onToggleBulb() }>
<span class=${ this.config.bulbOn ? 'track on' : 'track' }><span class="knob"></span></span>
<span>${ this.config.bulbOn ? 'On' : 'Off' }</span>
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'product-configurator': ProductConfigurator;
}
}A corrected WebGL scene can be sharp and its overlay aligned; the cost is owning the whole resolution lifecycle the browser handles for DOM text. That cost is the backdrop for the experimental API, because what it returns is the same alignment bookkeeping you just did by hand, handed back as a matrix.
Step 5: wrap it in a documented component
Fold what we have into a reusable Lit component before adding the frontier. I’m reaching for Lit because its decorators keep the element’s API readable and the boilerplate small. Two files, the public types in types.ts and the component in index.ts, built one concern at a time.
The mode union is the heart of the public surface, so it joins the scene type in types.ts:
types.ts
// types.ts
/**
* Which layer owns the configurator UI.
*
* `canvas-only` paints the whole interface, the part labels and a faux inspector, onto a 2D overlay stacked over the WebGL canvas, so there is no fallback DOM to focus or read.
* `dom-overlay` keeps the lamp on the WebGL canvas and the labels and inspector as real HTML, the labels projected from each part's 3D anchor to 2D screen pixels and kept glued as the lamp orbits, which is the version that ships today.
* `html-in-canvas` opts into the experimental API, drawing the real HTML labels into a flat 2D overlay canvas with `drawElementImage` and syncing the returned transform back so the label stays reachable, falling back to `dom-overlay` when the API is absent.
*/
export type ConfiguratorMode = 'canvas-only' | 'dom-overlay' | 'html-in-canvas';Then the element shell, its TSDoc with the web-component tags, and the reactive properties. The import pulls in buildLamp and a few helpers from lamp.ts, so the component body stays on the boundary work while the geometry lives next door. The shell here omits the split-layout slot machinery, its @slot tags and layout type, and one import, the shared feature-detection predicate from step 6; the step 8 assembly is the complete file. The parts bind as a property because an array never round-trips through a string attribute, and there’s no default, since inventing the parts inside the component would hide the contract that the parent names the scene:
index.ts
// index.ts
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import * as THREE from 'three';
import { buildLamp, disposeLamp, setBaseFinish, setBulbState, setShadeColor, STUDIO_PALETTES } from './lamp';
import type { LampHandle } from './lamp';
import type {
BaseFinish,
ConfiguratorConfigChangeDetail,
ConfiguratorMode,
ConfiguratorSelectDetail,
ConfiguratorSelectSource,
ConfiguratorTheme,
LampConfig,
LampPartId,
ProductConfiguratorEventMap,
SceneObject,
ShadeSwatch,
} from './types';
/**
* A real-time 3D product configurator: a procedural desk lamp in a WebGL canvas, with a raycast selection, an orbit drag, and an inspector, that moves its UI between three ownership models.
*
* The lamp is graphics the DOM cannot draw, owned by the canvas, and the labels and inspector are interface, owned by the DOM, so `mode` decides which layer carries them.
* `canvas-only` paints the labels and a faux inspector onto a flat overlay canvas with no focusable DOM, the trap; `dom-overlay` is the version that ships, projecting each part's 3D anchor to a real HTML label kept glued as the lamp orbits, with the real inspector beside it; `html-in-canvas` feature-detects the experimental `drawElementImage` path, draws the real label nodes into the flat overlay canvas, and writes the returned alignment transform back, falling back to `dom-overlay` when the API is absent.
* Selection is a raycast from the camera, the 3D replacement for the old 2D hit test, and the inspector recolors the shade, switches the base finish, and toggles the bulb, which relights the scene.
*
* @element product-configurator
* @fires object-select - Fired when the selected part changes, carrying a `ConfiguratorSelectDetail`.
* @fires config-change - Fired after an inspector edit, carrying the whole `LampConfig`.
* @cssprop [--configurator-accent=#2257e6] - The selection and focus color used by the labels and the inspector.
*/
@customElement( 'product-configurator' )
export class ProductConfigurator extends LitElement {
/**
* The selectable parts, the four named meshes of the lamp.
*
* It is `attribute: false` because an array binds as a property (`.objects=${parts}`) and never round-trips through a string attribute.
* It has no default, since inventing the parts inside the component would hide the contract that the parent names the scene; the component builds a mesh for each known id and skips any other.
*/
@property( { attribute: false } )
objects: SceneObject[] = [];
/**
* Which ownership model the configurator renders.
*
* `html-in-canvas` is a request, not a guarantee: when the experimental API is absent the component renders `dom-overlay` instead, so the selected mode and the active mode can differ.
*/
@property( { type: String } )
mode: ConfiguratorMode = 'dom-overlay';
}The mode switch is one place, the activeMode getter, so the template and the overlay paint never disagree about which layer owns the labels. This is the line that turns “render HTML-in-Canvas” into “render the overlay when the API isn’t there”:
index.ts
// index.ts, the requested-versus-active mode split
/**
* The mode actually rendered, which is the requested mode unless `html-in-canvas` was asked for without support.
*
* This is the single place the requested-versus-active split is resolved, so the template and the overlay paint agree on which layer owns the labels.
*/
private get activeMode(): ConfiguratorMode {
if ( this.mode === 'html-in-canvas' && ! this.supportsHtmlInCanvas ) {
return 'dom-overlay';
}
return this.mode;
}The inspector is the other half of the public surface. The lamp’s editable state, the shade swatch, the base finish, and the bulb, lives in one LampConfig object so the component can report it whole through a config-change event, and buildLamp takes those three as its starting values. The config type and the finish and swatch shapes go in types.ts:
types.ts
// types.ts
/**
* Which finish the base mesh wears, switched from the inspector.
*
* `matte` is a soft, low-reflectance surface and `metal` a polished, reflective one, applied by retuning the base material's roughness and metalness rather than swapping the mesh.
*/
export type BaseFinish = 'matte' | 'metal';
/**
* The configurable state of the lamp the inspector edits, held additively so the public selection and mode surface is unchanged.
*
* It is one object so the component can expose it as a single controllable property and report it whole, and so a future harness can drive every control from outside without learning a separate property per knob.
*/
export interface LampConfig {
/**
* The id of the selected shade swatch, which recolors the shade material.
*/
shadeSwatchId: string;
/**
* The base finish, matte or metal, which retunes the base material.
*/
baseFinish: BaseFinish;
/**
* Whether the bulb is lit, which turns the in-shade light and the bulb's emissive glow on or off and so relights the scene.
*/
bulbOn: boolean;
}Step 6: the frontier, drawElementImage
Now the experimental path. Read it as the platform exploring a better boundary, something to prototype behind feature detection rather than ship. The experimental members aren’t in lib.dom.d.ts, so the file declares an ambient augmentation for the surface it touches, the same no-cast strategy the earlier articles on this site use on HTMLElementTagNameMap. It doubles as documentation, the exact shape of the API you’re betting on, written where the compiler checks it. The slice below shows the two interfaces this component calls; the full file in step 8 also mirrors drawElementImage onto OffscreenCanvasRenderingContext2D, so a worker path type-checks too.
index.ts
// index.ts, the experimental surface, declared so the code type-checks without a cast
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here.
* Writing the returned matrix into the element's `style.transform` is what keeps focus, hit testing, and accessibility geometry on the drawn pixels.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
// The full file also declares the same method on OffscreenCanvasRenderingContext2D for a worker path; see step 8.
interface HTMLCanvasElement {
/**
* Opts the canvas in to the experimental model, so its direct children lay out and become drawable and hit-testable.
*
* Reflects the `layoutsubtree` HTML attribute, and is absent from `lib.dom.d.ts` because the feature is still flag-gated.
*/
layoutSubtree: boolean;
/**
* Forces a `paint` event on the next frame, the way `requestAnimationFrame` schedules a draw.
*/
requestPaint(): void;
}
}Feature-detect on a live context, after a null check, and never UA-sniff. The probe lives in one small shared predicate rather than inside the component, because the glass card and the 3D room on this page need the same answer. A throwaway <canvas> gives it a context to probe without touching the live one, getContext() returns null when 2D is unsupported, so the null check comes first, and the probe tests the real method name on the instance:
ts
/**
* Whether this build can run the full experimental HTML-in-Canvas path: `drawElementImage` on a live 2D context and `requestPaint` on the canvas.
*
* The article's rule is to feature-detect the exact methods you call, not a user-agent string, so this builds a throwaway canvas and tests both real names on the instances that expose them: `getContext( '2d' )` returns `null` only when 2D is unsupported, so the null check comes first, then `drawElementImage` is tested on the context and `requestPaint` on the canvas itself.
* The draw is only half the contract, because the components route it through the canvas `paint` event and request that event with `requestPaint`, so a build exposing the draw without the request could select the experimental mode and then never render a label; requiring both keeps "supported" meaning the path actually runs.
* It guards on `document` so it is safe to call during server rendering, where there is no DOM; it returns `false` there, which is the right default for a gate that should stay shown until the client confirms support.
* There is no cast anywhere in the probe: the components' `declare global` blocks already merge both experimental members into the DOM types once for the whole project, so the `typeof` checks read declared members and stay honest at runtime.
*
* @returns Whether the full `drawElementImage` plus `requestPaint` path can run on this build.
*/
export function supportsHtmlInCanvas(): boolean {
if ( typeof document === 'undefined' ) {
return false;
}
const $canvas = document.createElement( 'canvas' );
const ctx = $canvas.getContext( '2d' );
return !! ctx && typeof ctx.drawElementImage === 'function' && typeof $canvas.requestPaint === 'function';
}The component imports it from @demos/supports-html-in-canvas and records the answer into the supportsHtmlInCanvas state that activeMode reads:
ts
/**
* Records whether the drawn-label path can run, through the shared {@link supportsHtmlInCanvas} predicate that feature-detects the full path, `drawElementImage` on a live 2D context and `requestPaint` on the canvas, rather than sniffing a UA string.
*
* It runs from `connectedCallback`, since the probe needs only `document`, so the first render already knows the answer instead of flipping the mode an update cycle later.
*/
private resolveSupport(): void {
this.supportsHtmlInCanvas = supportsHtmlInCanvas();
}The labels here ride a flat 2D overlay canvas stacked over the WebGL one, which keeps the experimental flourish to the headline API and off the 3D path. That overlay canvas opts in with layoutsubtree, and the real label nodes are its direct children, laid out but not painted to the screen on their own, since under layoutsubtree the canvas owns the pixels and you reveal each label by drawing it with drawElementImage (never display: none, or they wouldn’t lay out). The template renders them only in the experimental mode:
html
<!-- the overlay canvas opts in, and each label is a real, laid-out button child of it -->
<canvas class="overlay-canvas" layoutsubtree>
<button type="button" class="drawn-label" data-part="shade">Shade</button>
<button type="button" class="drawn-label" data-part="arm">Arm</button>
<!-- one button per part -->
</canvas>And the draw. It resets any prior alignment so each box read is the label’s own size, hides the labels whose part is facing away, then measures every node in one batch and draws each one centered on its part’s projected anchor. drawElementImage() returns the transform that lines the live DOM node up with its drawn pixels. That node is already reachable in the accessibility tree as a real child under layoutsubtree, and writing the transform back to style.transform keeps its focus ring, hit target, and accessibility bounds on those pixels, so the write is load-bearing rather than polish:
ts
/**
* Draws every visible real label node into the overlay canvas at its own 1:1 size and projected center, then writes each returned alignment matrix back, the experimental in-canvas-label path.
*
* Each label's `style.transform` is a platform-owned slot here. The node is already a reachable element the browser exposes in the accessibility tree and the tab order, because the overlay canvas keeps it a real DOM child under `layoutsubtree`; writing the matrix back is only what keeps that element's focus ring, hit target, and accessibility bounds aligned under its drawn pixels. When `omitTransformSync` is set the write is skipped so the reader can watch the focus ring and click target drift off the pixels.
*
* A label whose part is facing away is hidden rather than skipped in place: `visibility: hidden` also removes it from the tab order and the accessibility tree while it is offscreen, the same contract the DOM-overlay labels keep, where a skipped node would otherwise stay tabbable at its stale position.
*
* The work runs in three passes, the transform resets and visibility first, then every box measurement, then every draw and write-back, so the style writes cost at most two layout flushes per paint instead of one per label.
*
* The draw runs on the bare device-pixel grid, not under the `dpr` scale the faux UI paints with: the label node already rasterizes at the display's pixel density, so drawing it under an extra `dpr` transform would double its size, which is what made the drawn pills balloon. The projected center and the label box are scaled into device pixels by hand instead, so the drawn pill matches the DOM-overlay pill exactly, then the transform is restored for the next paint.
*
* The explainer's canonical flow routes this draw through the canvas `paint` event, and that is where it runs: the render loop and the sync passes only call `requestPaint()`, and {@link onOverlayPaint} invokes this method when the event fires. Calling `drawElementImage` outside the event is still defined behavior, but it draws the previous frame's snapshot of the element and throws before a first snapshot exists, which is why the handler owns the draw.
*
* @param ctx - The overlay 2D context, on a supporting build where `drawElementImage` exists.
*/
private drawLabelsIntoCanvas( ctx: CanvasRenderingContext2D ): void {
const $host = this.$overlayCanvas;
if ( ! $host ) {
return;
}
const dpr = window.devicePixelRatio || 1;
// Drop to the identity transform so one drawing unit is one backing-store pixel, the space the element bitmap is already rasterized in; the faux-UI dpr scale is restored at the end.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
// First pass: reset any prior alignment before measuring, so each box read is the label's own size rather than a previously skewed one, and hide the labels whose part is facing away.
const drawable: Array<{ point: { x: number; y: number }; $node: HTMLElement }> = [];
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
const $node = $host.querySelector( `[data-part='${ part.id }']` );
if ( ! ( $node instanceof HTMLElement ) ) {
continue;
}
if ( ! point || ! point.visible ) {
$node.style.visibility = 'hidden';
continue;
}
$node.style.visibility = '';
if ( ! this.omitTransformSync ) {
$node.style.transform = '';
}
drawable.push( { point, $node } );
}
// Second pass: measure every box in one batch, so the writes above flush layout once rather than per label.
const boxes = drawable.map( ( entry ) => entry.$node.getBoundingClientRect() );
// Third pass: draw each label at its projected center and write the alignment matrix back.
for ( const [ index, entry ] of drawable.entries() ) {
const box = boxes[ index ];
const dx = ( entry.point.x - box.width / 2 ) * dpr;
const dy = ( entry.point.y - box.height / 2 ) * dpr;
const alignment = ctx.drawElementImage( entry.$node, dx, dy );
if ( ! this.omitTransformSync ) {
entry.$node.style.transform = alignment.toString();
}
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
}Look at what that sync replaces. It’s the same alignment math the overlay did by hand in step 3, projecting each anchor and placing the label, except the platform computes the final transform and hands it back as a DOMMatrix. That’s the real value on offer, and it’s a narrow, honest win, well short of “now you can draw your UI.” When the capability is missing, supportsHtmlInCanvas is false, activeMode resolves to dom-overlay, and none of this runs, so the overlay from step 3 is the literal fallback.
One more piece of the lifecycle deserves its own beat, because the method you just read never runs from the render loop directly. The explainer routes the draw through a paint event on the canvas, which fires when a drawn child’s rendering changes, a text selection, an input state, a hover, and carries the list of changed elements; requestPaint() forces one for every-frame patterns, the way requestAnimationFrame schedules a frame. So the component wires a handler once, and its per-frame passes end with a request instead of a draw, leaving the handler to clear and repaint the canvas whole:
index.ts
// index.ts, in bindEvents, wired once since the overlay canvas is rendered in every mode
this.$overlayCanvas?.addEventListener( 'paint', this.onOverlayPaint );
// the experimental branch of paintFlatOverlay, which the render loop and every sync pass reach
if ( this.activeMode === 'html-in-canvas' ) {
$canvas.requestPaint();
return;
}
/**
* Draws the real labels when the overlay canvas fires its `paint` event, so the experimental draw runs inside the event the way the canonical flow prescribes.
*
* The render loop and the sync passes never draw or clear the labels themselves; {@link paintFlatOverlay} requests a paint, and this handler clears the canvas and runs {@link drawLabelsIntoCanvas} when the event lands, so a platform-initiated paint, a focus ring appearing on a label, starts from its own clear instead of leaving residue over the last frame.
* The mode guard mirrors the experimental branch of the paint pass, since the listener stays wired in every mode, and the context guard covers an event that lands before the first paint pass has cached the 2D context.
*/
private onOverlayPaint = (): void => {
const ctx = this.overlayCtx;
const $canvas = this.$overlayCanvas;
if ( this.activeMode !== 'html-in-canvas' || ! ctx || ! $canvas ) {
return;
}
// Clear in device pixels under the identity transform, the same grid the label draw below runs on.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
ctx.clearRect( 0, 0, $canvas.width, $canvas.height );
this.drawLabelsIntoCanvas( ctx );
};Calling drawElementImage outside that event is still defined behavior, and its semantics are why the handler owns the draw here. An outside call draws the previous frame’s snapshot of the element, and it throws when no snapshot exists yet, which is exactly where the first loop-driven call would land on a flagged build. Routing every draw through the handler keeps the snapshot current, and the event supplies the invalidation signal a render loop doesn’t have, so a selection sweeping across a label or an input updating inside it redraws even while the scene sits idle.
This demo shows the feature-detection result, the trial and flag status, which behavior is preserved, and what still needs a real-browser test. Drag to orbit the lamp and click a part either way, and selection still works because the DOM stays authoritative. Most browsers report the API as unavailable and render the overlay, so the boundary and the fallback are visible everywhere while a reader on Chromium-with-the-flag sees the real label drawn over the orbiting lamp.
import { LitElement, html, css, nothing } from 'lit';
import { supportsHtmlInCanvas } from '@demos/supports-html-in-canvas';
import { customElement, property, state, query } from 'lit/decorators.js';
import * as THREE from 'three';
import { buildLamp, disposeLamp, setBaseFinish, setBulbState, setShadeColor, STUDIO_PALETTES } from './lamp';
import type { LampHandle } from './lamp';
import type {
BaseFinish,
ConfiguratorConfigChangeDetail,
ConfiguratorLayout,
ConfiguratorMode,
ConfiguratorSelectDetail,
ConfiguratorSelectSource,
ConfiguratorTheme,
LampConfig,
LampPartId,
ProductConfiguratorEventMap,
SceneObject,
ShadeSwatch,
} from './types';
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here.
* Writing the returned matrix into the element's `style.transform` is what keeps focus, hit testing, and accessibility geometry on the drawn pixels.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface OffscreenCanvasRenderingContext2D {
/**
* The offscreen mirror of {@link CanvasRenderingContext2D.drawElementImage}, declared for completeness so a worker path type-checks too.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface HTMLCanvasElement {
/**
* Opts the canvas in to the experimental model, so its direct children lay out and become drawable and hit-testable.
*
* Reflects the `layoutsubtree` HTML attribute, and is absent from `lib.dom.d.ts` because the feature is still flag-gated.
*/
layoutSubtree: boolean;
/**
* Forces a `paint` event on the next frame, the way `requestAnimationFrame` schedules a draw.
*/
requestPaint(): void;
}
}
/**
* The swatches the shade can be recolored to, a small neutral-to-bold range so the recolor reads in both themes.
*
* It is a module constant rather than a property because the swatch set is part of the lamp's design, not data a harness owns, and the first entry is the default the lamp opens on.
*/
const SHADE_SWATCHES: ReadonlyArray<ShadeSwatch> = [
{ id: 'porcelain', label: 'Porcelain', hex: '#e9e7e0' },
{ id: 'cobalt', label: 'Cobalt', hex: '#2257e6' },
{ id: 'moss', label: 'Moss', hex: '#3f7d5a' },
{ id: 'clay', label: 'Clay', hex: '#c2683f' },
];
/**
* The aspect ratio of the canvas, its height as a fraction of its width, so the stage reserves the right height before WebGL sizes itself.
*/
const CANVAS_ASPECT = 0.62;
/**
* The labelled parts the lamp draws, in the order the overlay stacks them, so the four ids the geometry and inspector know about have a single declared source.
*/
const PART_IDS: ReadonlyArray<LampPartId> = [ 'shade', 'arm', 'bulb', 'base' ];
/**
* A shared black color reused when a part's selection highlight is cleared, so the highlight reset allocates nothing per frame.
*/
const BLACK = new THREE.Color( 0x000000 );
/**
* A real-time 3D product configurator: a procedural desk lamp in a WebGL canvas, with a raycast selection, an orbit drag, and an inspector, that moves its UI between three ownership models.
*
* The lamp is graphics the DOM cannot draw, owned by the canvas, and the labels and inspector are interface, owned by the DOM, so `mode` decides which layer carries them.
* `canvas-only` paints the labels and a faux inspector onto a flat overlay canvas with no focusable DOM, the trap; `dom-overlay` is the version that ships, projecting each part's 3D anchor to a real HTML label kept glued as the lamp orbits, with the real inspector beside it; `html-in-canvas` feature-detects the experimental `drawElementImage` path, draws the real label nodes into the flat overlay canvas, and writes the returned alignment transform back, falling back to `dom-overlay` when the API is absent.
* Selection is a raycast from the camera, the 3D replacement for the old 2D hit test, and the inspector recolors the shade, switches the base finish, and toggles the bulb, which relights the scene.
*
* @element product-configurator
* @slot header - Sits above the stage in the left column, for a harness caption over the canvas.
* @slot panel - Stacks under the real inspector in the right column of the `split` layout, for a harness control panel beside the canvas.
* @slot footer - Spans both columns under the stage in the `split` layout, for a tall report or log that would otherwise strand the canvas column if stacked in the narrow side column.
* @fires object-select - Fired when the selected part changes, carrying a `ConfiguratorSelectDetail`.
* @fires config-change - Fired after an inspector edit, carrying the whole `LampConfig`.
* @cssprop [--configurator-accent=#2257e6] - The selection and focus color used by the labels and the inspector.
*/
@customElement( 'product-configurator' )
export class ProductConfigurator extends LitElement {
/**
* The selectable parts, the four named meshes of the lamp.
*
* It is `attribute: false` because an array binds as a property (`.objects=${parts}`) and never round-trips through a string attribute.
* It has no default, since inventing the parts inside the component would hide the contract that the parent names the scene; the component builds a mesh for each known id and skips any other.
*/
@property( { attribute: false } )
objects: SceneObject[] = [];
/**
* Which ownership model the configurator renders.
*
* `html-in-canvas` is a request, not a guarantee: when the experimental API is absent the component renders `dom-overlay` instead, so the selected mode and the active mode can differ.
*/
@property( { type: String } )
mode: ConfiguratorMode = 'dom-overlay';
/**
* How the canvas and the inspector are arranged.
*
* Defaults to `stacked` so a standalone embed keeps the inspector under the canvas; a harness sets `split` to get the glass-demo shape, the canvas in a left column and the inspector plus a `panel` slot in one right column, collapsing back to a single stacked column on a narrow width.
*/
@property( { type: String } )
layout: ConfiguratorLayout = 'stacked';
/**
* Whether to deliberately skip writing the alignment transform back to the drawn label.
*
* Teaching switch for the experimental path: with the sync omitted, the focus ring and click target drift off the drawn pixels, which is the failure the article makes visible.
* It does nothing outside `html-in-canvas` mode on a supporting build.
*/
@property( { type: Boolean, attribute: 'omit-transform-sync' } )
omitTransformSync = false;
/**
* The id of the currently selected part, or `null` when nothing is selected.
*
* It is a controllable property: leave it unset and the component manages selection itself from a raycast or a keyboard pick, or bind it (`.selectedId=${id}`) to drive several configurators from one shared selection, the way the review demo syncs three modes.
* Either way consumers also hear changes through the `object-select` event, and the component writes the property on every pick so the uncontrolled case stays in sync.
*/
@property( { attribute: 'selected-id' } )
selectedId: string | null = null;
/**
* The viewport canvas Three.js renders the lamp into, resolved by Lit after the first render.
*/
@query( '.viewport' )
private $viewport!: HTMLCanvasElement;
/**
* The flat 2D overlay canvas stacked over the WebGL canvas, used to paint the faux UI in `canvas-only` and to host the drawn labels in `html-in-canvas`.
*/
@query( '.overlay-canvas' )
private $overlayCanvas?: HTMLCanvasElement;
/**
* The selected page theme, resolved on connect and kept in sync at runtime, so the studio is lit to match the page in light and dark.
*
* It is `@state` so a runtime theme flip re-renders, and the scene recolors through {@link applyTheme} rather than a rebuild.
*/
@state()
private theme: ConfiguratorTheme = 'light';
/**
* The lamp's configurable state, the shade swatch, the base finish, and the bulb, edited by the inspector.
*
* It is `@state` so an edit re-renders the inspector and re-runs the overlay draw, and it opens on the first swatch with a matte base and a lit bulb so the lamp reads as switched on.
*/
@state()
private config: LampConfig = {
shadeSwatchId: SHADE_SWATCHES[ 0 ].id,
baseFinish: 'matte',
bulbOn: true,
};
/**
* Whether the live 2D context exposes the experimental `drawElementImage` method.
*
* Resolved on connect from a scratch canvas, so it gates the experimental path per render rather than trusting the requested `mode`.
* It is `@state` so flipping it triggers the re-render that mounts the drawn label nodes, since `activeMode` reads it during `render`.
*/
@state()
private supportsHtmlInCanvas = false;
/**
* The Three.js scene, camera, and renderer, created once the viewport exists.
*/
private scene: THREE.Scene | null = null;
private camera: THREE.PerspectiveCamera | null = null;
private renderer: THREE.WebGLRenderer | null = null;
/**
* The built lamp and the handles to its parts, materials, and light, or `null` before the scene is built.
*/
private lamp: LampHandle | null = null;
/**
* The studio floor mesh, kept so its geometry and material can be disposed on teardown.
*/
private floorMesh: THREE.Mesh | null = null;
/**
* The studio floor material, kept so a theme change can recolor it in place.
*/
private floorMaterial: THREE.MeshStandardMaterial | null = null;
/**
* The themed studio lights, kept so a theme change can dim or lift them to match the palette.
*/
private themedLights: {
ambient: THREE.AmbientLight | null;
key: THREE.DirectionalLight | null;
rim: THREE.DirectionalLight | null;
} = { ambient: null, key: null, rim: null };
/**
* The reusable raycaster for the part-selection pick, allocated once.
*/
private readonly raycaster = new THREE.Raycaster();
/**
* A reusable vector for projecting a part anchor, so the overlay sync allocates nothing per frame.
*/
private readonly projected = new THREE.Vector3();
/**
* The overlay canvas 2D context, cached after the first paint so the per-frame draw does not re-acquire it.
*/
private overlayCtx: CanvasRenderingContext2D | null = null;
/**
* The CSS-pixel size the overlay backing store was last sized for, so it is only resized when the box actually changes rather than every frame.
*/
private overlaySize = { width: 0, height: 0, dpr: 0 };
/**
* The resolved selection accent as a Three.js color, cached so the per-frame highlight does not call `getComputedStyle`, and refreshed on a theme change.
*/
private readonly accent = new THREE.Color( 0x2257e6 );
/**
* The themed colors the flat overlay paints with, cached and refreshed on a theme change so the canvas-only and drawn-label paints do not read the DOM every frame.
*
* The canvas cannot inherit a CSS color, so these mirror the tokens the rest of the page uses, resolved once per theme rather than once per frame.
*/
private overlayColors = {
ink: '#17171a',
surface: '#ffffff',
accent: '#2257e6',
accentInk: '#ffffff',
fontSans: 'system-ui, sans-serif',
fontMono: 'monospace',
};
/**
* The current screen position of each part's overlay label, in CSS pixels, recomputed on every rendered frame while the lamp is on screen.
*
* It is the bridge from the 3D scene to the 2D layers: the DOM-overlay labels read it for their `left` and `top`, and the canvas-only and drawn-label paints read it to place text, so all three layers stay glued to the orbiting model from one projection.
*/
@state()
private anchorPoints: Record<string, { x: number; y: number; visible: boolean }> = {};
/**
* The orbit yaw and pitch in radians, driven by the drag and applied to the lamp group each frame.
*
* The lamp rotates rather than the camera, so the studio lighting stays fixed and the part the reader turns toward stays lit; the pitch is bounded so the lamp never tips past a readable angle.
*/
private orbit = { yaw: 0.5, pitch: 0.05 };
/**
* Whether a drag-to-orbit gesture is in progress, and the last pointer position it was sampled at.
*/
private isDragging = false;
/**
* The id of the pointer that started the current gesture, so a second finger or pen mid-drag cannot steer, end, or cancel a gesture it does not own.
*
* `isPrimary` is not enough as a filter, since touch and pen each get their own primary pointer; the id is the one key that names this exact contact.
*/
private activePointerId: number | null = null;
private lastPointer = { x: 0, y: 0 };
/**
* Whether the pointer moved past a small threshold during the current gesture, so a press-and-release in place still counts as a select rather than being eaten as a drag.
*/
private dragMoved = false;
/**
* Whether the viewport is currently on screen, so the render loop only runs while the demo is visible.
*/
private isVisible = true;
/**
* Whether {@link setUp} has wired the scene, listeners, and observers, so its two entry points, the first update and a reconnect, can never double-wire them.
*/
private isSetUp = false;
/**
* The media query that fires when `devicePixelRatio` changes, so the renderer and the overlay can be re-corrected on a zoom or a move to a different-density display.
*
* It is recreated on each change because the query is pinned to a single resolution, which is the lifecycle the canvas makes the author own.
*/
private dprQuery: MediaQueryList | null = null;
/**
* The media query the studio watches so it follows the OS color scheme when the page has not pinned a theme, retained so its listener tears down on disconnect.
*/
private themeMedia: MediaQueryList | null = null;
/**
* The observer watching `data-theme` on the document element, so an in-page theme toggle re-lights the studio at runtime, retained so it disconnects cleanly.
*/
private themeObserver: MutationObserver | null = null;
/**
* The resize and intersection observers, tracked so they tear down cleanly on disconnect.
*/
private resizeObserver: ResizeObserver | null = null;
private intersectionObserver: IntersectionObserver | null = null;
/**
* The animation-frame handle, cleared on disconnect or when the demo scrolls offscreen so a hidden lamp stops its loop.
*/
private frame = 0;
/**
* Whether the next animation frame has work to do.
*
* Nothing in the scene self-animates, so the loop keeps its frame cadence but skips the render and the overlay sync until an orbit, a selection, a config edit, a theme flip, or a resize marks the scene dirty through {@link requestRender}; label-only rendering changes in the experimental mode, a focus ring appearing on a drawn label, arrive through the canvas `paint` event and never need the flag.
*/
private needsRender = false;
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
:host {
display: block;
/* The host is a size container, so the split layout collapses on the embed's own width rather than the viewport's. */
container-type: inline-size;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
/* Resolve the selection accent into a private name; reading --configurator-accent first, then the themed --accent, then the brand, avoids the self-reference cycle that redefining --accent here would cause when a host sets --configurator-accent to the page accent. */
--cfg-accent: var( --configurator-accent, var( --accent, #2257e6 ) );
}
/* The stacked default keeps the canvas and inspector in one column; the split layout puts the canvas on the left and the inspector plus the harness panel in one right column, the glass-demo shape. */
.layout {
display: grid;
gap: var( --space-4, 16px );
}
.layout.split {
grid-template-columns: minmax( 0, 1fr ) 17rem;
grid-template-areas:
'main side'
'footer footer';
align-items: start;
}
.layout.split .main {
grid-area: main;
}
.layout.split .side {
grid-area: side;
}
/* The tall trailing content spans both columns under the stage, so a long report no longer strands the canvas column. */
.layout.split .footer {
grid-area: footer;
}
/* The left column: anything slotted into the header sits above the stage, and the stage carries the canvas. */
.main {
display: flex;
flex-direction: column;
gap: var( --space-3, 12px );
min-width: 0;
}
/* The header slot collapses to nothing when the harness slots no caption, so a bare embed shows just the stage. */
slot[name='header']::slotted( * ) {
margin: 0;
}
/* The right column of the split layout: the real inspector stacked above whatever the harness slots in, so every control reads as one column beside the canvas. */
.side {
display: flex;
flex-direction: column;
gap: var( --space-4, 16px );
min-width: 0;
}
.layout.split .inspector {
margin-top: 0;
}
/* Collapse to a single stacked column when the embed itself is too narrow for a comfortable two-column split. */
@container ( width <= 38rem ) {
.layout.split {
grid-template-columns: minmax( 0, 1fr );
grid-template-areas:
'main'
'side'
'footer';
}
}
.stage {
position: relative;
width: 100%;
aspect-ratio: 100 / 62;
border-radius: var( --radius-md, 11px );
border: 1px solid var( --line, #e7e6e2 );
overflow: hidden;
background: var( --surface, #fff );
}
canvas.viewport {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.stage.dragging canvas.viewport {
cursor: grabbing;
}
/* The flat overlay canvas sits exactly over the WebGL canvas in CSS-pixel space, the painted faux UI in canvas-only and the drawn-label host in html-in-canvas. */
canvas.overlay-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* The DOM-overlay layer sits over the canvas in CSS-pixel space, the bookkeeping the experimental API folds into the platform. */
.overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.overlay .label {
position: absolute;
transform: translate( -50%, -50% );
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
.overlay .label:hover {
border-color: var( --cfg-accent );
}
.overlay .label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
.overlay .label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
/*
* The drawn labels are real children of the overlay canvas in the experimental path: laid out and hit-testable, never display:none, anchored at the canvas origin with style.transform reserved for the platform alignment matrix that places and keeps them on the drawn pixels.
* Their look is kept identical to the DOM-overlay label above, the small light pill with ink text and a hairline border, so the only difference a reader can tell between the two modes is the mechanism, not the size or color of the pill.
*/
canvas.overlay-canvas .drawn-label {
position: absolute;
top: 0;
left: 0;
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
canvas.overlay-canvas .drawn-label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
canvas.overlay-canvas .drawn-label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.inspector {
margin-top: var( --space-3, 12px );
padding: var( --space-4, 16px );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-md, 11px );
background: var( --surface, #fff );
}
.inspector .head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var( --space-3, 12px );
margin-bottom: var( --space-3, 12px );
}
.inspector h3 {
margin: 0;
font-size: 14px;
color: var( --ink, #17171a );
}
.inspector .part-tag {
font-family: var( --font-mono, monospace );
font-size: 11px;
color: var( --ink-faint, #71717b );
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var( --space-3, 12px );
}
.field:last-child {
margin-bottom: 0;
}
.field > .field-label {
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var( --ink-faint, #71717b );
}
.swatches {
display: flex;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
border: 2px solid var( --line, #e7e6e2 );
cursor: pointer;
}
.swatch[aria-pressed='true'] {
border-color: var( --cfg-accent );
box-shadow: 0 0 0 2px var( --surface, #fff ), 0 0 0 4px var( --cfg-accent );
}
.segmented {
display: inline-flex;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-sm, 7px );
overflow: hidden;
width: max-content;
}
.segmented button {
padding: 7px 14px;
font: inherit;
font-size: 13px;
border: 0;
background: var( --bg, #fcfcfb );
color: var( --ink, #17171a );
cursor: pointer;
}
.segmented button + button {
border-left: 1px solid var( --line, #e7e6e2 );
}
.segmented button[aria-pressed='true'] {
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 13px;
color: var( --ink, #17171a );
cursor: pointer;
}
.switch .track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: var( --line, #e7e6e2 );
transition: background 0.2s ease;
flex: none;
}
.switch .track.on {
background: var( --cfg-accent );
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s ease;
}
.switch .track.on .knob {
transform: translateX( 16px );
}
`;
/**
* Resolves the page theme and the experimental-API support before the first render, so the studio opens in the page's palette and the first render already knows the active mode.
*
* On a reconnect it also re-runs {@link setUp}: the shadow DOM survives a re-parenting, so `hasUpdated` stays true, `firstUpdated` never fires again, and the wiring {@link tearDown} removed has to come back here, with the `@query` fields still resolving into the kept shadow root.
*/
connectedCallback(): void {
super.connectedCallback();
this.theme = this.detectTheme();
this.resolveSupport();
if ( this.hasUpdated ) {
this.setUp();
}
}
/**
* Runs the shared {@link setUp} once the viewport canvas exists after the first render.
*/
protected firstUpdated(): void {
this.setUp();
}
/**
* Builds the scene, wires the controls, observers, and theme listeners, and kicks the loop.
*
* It is shared by `firstUpdated` and a reconnect, because `firstUpdated` only ever fires once: without `connectedCallback` re-running this, a configurator moved to a new parent would come back with everything {@link tearDown} released still gone.
* The `isSetUp` guard keeps the two entry points from wiring the scene twice.
* It also refuses to wire a detached element: an append and remove in the same task still runs the pending first update after disconnection, and window listeners wired there would never hear the disconnect that already happened.
*/
private setUp(): void {
if ( this.isSetUp || ! this.isConnected ) {
return;
}
this.isSetUp = true;
this.refreshAccent();
this.initScene();
this.applySelectionHighlight();
this.bindEvents();
this.bindThemeListeners();
this.observeDevicePixelRatio();
this.startLoop();
}
/**
* Re-runs the overlay paint whenever a property that changes the flat layers updates, so a mode switch, a selection, or a config edit repaints the canvas-only UI or the drawn labels.
*
* A selection, mode, or objects change also marks the scene dirty through {@link requestRender}, so the gated loop draws the change on its next frame.
* The 3D scene redraws itself in the loop, so this only touches the flat overlay, and only when the loop is paused does it need to force one; the guarded call keeps a visible, animating demo from double-painting.
* The `anchorPoints` guard is what keeps a paused offscreen demo from looping: {@link syncOverlay} writes the `anchorPoints` `@state`, which schedules another update, so re-running `syncOverlay` on that follow-up would project, write `anchorPoints` again, and never terminate; skipping the call when the only change is `anchorPoints` lets a real change like a theme, selection, mode, or config edit sync the overlay exactly once and then settle.
*/
protected updated( changed: Map<PropertyKey, unknown> ): void {
if ( changed.has( 'selectedId' ) ) {
this.applySelectionHighlight();
this.requestRender();
}
if ( changed.has( 'mode' ) || changed.has( 'objects' ) ) {
this.requestRender();
}
if ( this.frame === 0 && ! changed.has( 'anchorPoints' ) ) {
this.syncOverlay();
}
}
/**
* Tears the configurator down through the shared {@link tearDown} when it leaves the document.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.tearDown();
}
/**
* Stops the loop and tears down every observer, listener, and the WebGL context so a removed configurator leaks nothing.
*
* It is the mirror of {@link setUp} rather than the body of `disconnectedCallback`, so a reconnected configurator can rebuild exactly what this released; clearing `isSetUp` is what re-arms that rebuild.
*/
private tearDown(): void {
this.isSetUp = false;
this.stopLoop();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
this.dprQuery = null;
this.themeMedia?.removeEventListener( 'change', this.onThemeMediaChange );
this.themeMedia = null;
this.themeObserver?.disconnect();
this.themeObserver = null;
this.$viewport?.removeEventListener( 'pointerdown', this.onPointerDown );
window.removeEventListener( 'pointermove', this.onPointerMove );
window.removeEventListener( 'pointerup', this.onPointerUp );
window.removeEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.removeEventListener( 'paint', this.onOverlayPaint );
if ( this.lamp ) {
disposeLamp( this.lamp );
this.lamp = null;
}
this.floorMesh?.geometry.dispose();
this.floorMesh = null;
this.floorMaterial?.dispose();
this.floorMaterial = null;
this.renderer?.dispose();
this.renderer = null;
// The window listeners that would release a live gesture are gone now, so the flags reset here; a stale drag would otherwise let plain hover motion orbit the lamp after a reconnect, since a mouse keeps a stable pointerId.
this.isDragging = false;
this.dragMoved = false;
this.activePointerId = null;
}
/**
* The mode actually rendered, which is the requested mode unless `html-in-canvas` was asked for without support.
*
* This is the single place the requested-versus-active split is resolved, so the template and the overlay paint agree on which layer owns the labels.
*/
private get activeMode(): ConfiguratorMode {
if ( this.mode === 'html-in-canvas' && ! this.supportsHtmlInCanvas ) {
return 'dom-overlay';
}
return this.mode;
}
/**
* The studio palette for the current theme, the one object the scene materials and lights read.
*/
private get palette() {
return STUDIO_PALETTES[ this.theme ];
}
/**
* The four selectable parts that map onto a real lamp mesh, in the lamp's stacking order.
*
* The component only knows the four lamp ids, so it filters the bound scene to them and keeps them in a stable order, which keeps the overlay labels and the raycast list aligned with the geometry the helper built.
*/
private get parts(): SceneObject[] {
const byId = new Map( this.objects.map( ( object ) => [ object.id, object ] ) );
const ordered: SceneObject[] = [];
for ( const id of PART_IDS ) {
const object = byId.get( id );
if ( object ) {
ordered.push( object );
}
}
return ordered;
}
/**
* The selected part, or `undefined` when nothing is selected or the selection is not a known lamp part.
*/
private get selectedPart(): SceneObject | undefined {
return this.parts.find( ( part ) => part.id === this.selectedId );
}
/**
* Records whether the drawn-label path can run, through the shared {@link supportsHtmlInCanvas} predicate that feature-detects the full path, `drawElementImage` on a live 2D context and `requestPaint` on the canvas, rather than sniffing a UA string.
*
* It runs from `connectedCallback`, since the probe needs only `document`, so the first render already knows the answer instead of flipping the mode an update cycle later.
*/
private resolveSupport(): void {
this.supportsHtmlInCanvas = supportsHtmlInCanvas();
}
/**
* Resolves the page theme the same way the demo page decides it: an explicit `data-theme` on the document element wins, and the OS color scheme is the fallback when nothing is pinned.
*
* @returns The theme the studio should light for right now.
*/
private detectTheme(): ConfiguratorTheme {
const pinned = document.documentElement.dataset.theme;
if ( pinned === 'dark' || pinned === 'light' ) {
return pinned;
}
return window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light';
}
/**
* The current size of the viewport canvas in CSS pixels, read from the element so it is right in both standalone and embedded contexts.
*/
private viewportSize(): { width: number; height: number } {
return {
width: this.$viewport.clientWidth || 600,
height: this.$viewport.clientHeight || Math.round( 600 * CANVAS_ASPECT ),
};
}
/**
* Creates the scene, camera, renderer, the studio floor and lighting, and the lamp, then frames the camera on the lamp.
*/
private initScene(): void {
const palette = this.palette;
const scene = new THREE.Scene();
scene.background = new THREE.Color( palette.background );
this.scene = scene;
const size = this.viewportSize();
const camera = new THREE.PerspectiveCamera( 38, size.width / size.height, 0.1, 100 );
camera.position.set( 0, 0.55, 5.6 );
camera.lookAt( 0, 0.26, 0 );
this.camera = camera;
const renderer = new THREE.WebGLRenderer( { canvas: this.$viewport, antialias: true, alpha: true } );
renderer.setSize( size.width, size.height, false );
renderer.setPixelRatio( Math.min( window.devicePixelRatio || 1, 2 ) );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
this.renderer = renderer;
this.buildStudio( scene );
this.buildLampInto( scene );
}
/**
* Builds the studio floor and the three-point lighting from the current palette, keeping each so a theme change can recolor or re-level it.
*
* @param scene - The scene to add the studio to.
*/
private buildStudio( scene: THREE.Scene ): void {
const palette = this.palette;
const floorMaterial = new THREE.MeshStandardMaterial( { color: palette.floor, roughness: 0.95, metalness: 0.05 } );
const floor = new THREE.Mesh( new THREE.CircleGeometry( 6, 64 ), floorMaterial );
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.95;
scene.add( floor );
this.floorMesh = floor;
this.floorMaterial = floorMaterial;
const ambient = new THREE.AmbientLight( 0xffffff, palette.ambient );
scene.add( ambient );
this.themedLights.ambient = ambient;
const key = new THREE.DirectionalLight( 0xffffff, palette.key );
key.position.set( 2.6, 4, 3.2 );
scene.add( key );
this.themedLights.key = key;
const rim = new THREE.DirectionalLight( 0xbcd0ff, palette.rim );
rim.position.set( -3, 2.2, -2.6 );
scene.add( rim );
this.themedLights.rim = rim;
}
/**
* Builds the lamp from the helper in the lamp's current config and adds it to the scene, applying the current orbit so it opens at a readable three-quarter angle.
*
* @param scene - The scene to add the lamp to.
*/
private buildLampInto( scene: THREE.Scene ): void {
const lamp = buildLamp( this.palette, this.shadeHex(), this.config.baseFinish, this.config.bulbOn );
lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
scene.add( lamp.group );
this.lamp = lamp;
}
/**
* The hex color of the currently selected shade swatch, falling back to the first swatch when the stored id is unknown.
*/
private shadeHex(): string {
const swatch = SHADE_SWATCHES.find( ( entry ) => entry.id === this.config.shadeSwatchId ) ?? SHADE_SWATCHES[ 0 ];
return swatch.hex;
}
/**
* Wires the orbit pointer listeners, the overlay's `paint` listener, the resize observer that keeps the renderer matched to the embedded stage, and the intersection observer that pauses the loop offscreen.
*
* The orbit starts on a `pointerdown` on the viewport and tracks on the window so a drag that leaves the canvas keeps rotating, and a release without a meaningful move is treated as a select on the same press.
* The `paint` listener registers once, because the overlay canvas is rendered in every mode and so survives a mode switch; on a build without the experimental API the event never fires, which leaves the handler inert.
*/
private bindEvents(): void {
this.$viewport.addEventListener( 'pointerdown', this.onPointerDown );
window.addEventListener( 'pointermove', this.onPointerMove );
window.addEventListener( 'pointerup', this.onPointerUp );
window.addEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.addEventListener( 'paint', this.onOverlayPaint );
this.resizeObserver = new ResizeObserver( () => this.onResize() );
this.resizeObserver.observe( this.$viewport );
this.intersectionObserver = new IntersectionObserver(
( entries ) => this.onIntersection( entries ),
{ threshold: 0.01 },
);
this.intersectionObserver.observe( this.$viewport );
}
/**
* Wires the two ways the page theme can change at runtime: the OS color scheme and an in-page `data-theme` toggle, both funnelled through {@link refreshTheme}.
*/
private bindThemeListeners(): void {
this.themeMedia = window.matchMedia( '(prefers-color-scheme: dark)' );
this.themeMedia.addEventListener( 'change', this.onThemeMediaChange );
this.themeObserver = new MutationObserver( () => this.refreshTheme() );
this.themeObserver.observe( document.documentElement, { attributes: true, attributeFilter: [ 'data-theme' ] } );
}
/**
* Re-resolves the OS color scheme into the studio when the system theme flips.
*/
private onThemeMediaChange = (): void => {
this.refreshTheme();
};
/**
* Re-resolves the theme and, when it actually changed, recolors the studio and the shared metal so the lamp follows the page.
*/
private refreshTheme(): void {
const next = this.detectTheme();
if ( next === this.theme ) {
return;
}
this.theme = next;
this.applyTheme();
}
/**
* Pushes the current palette onto the live scene: the background, the floor, the lights, and the shared metal tone of the arm and base, so a runtime theme change lands without rebuilding the scene.
*/
private applyTheme(): void {
const palette = this.palette;
if ( this.scene ) {
this.scene.background = new THREE.Color( palette.background );
}
this.floorMaterial?.color.setHex( palette.floor );
if ( this.themedLights.ambient ) {
this.themedLights.ambient.intensity = palette.ambient;
}
if ( this.themedLights.key ) {
this.themedLights.key.intensity = palette.key;
}
if ( this.themedLights.rim ) {
this.themedLights.rim.intensity = palette.rim;
}
if ( this.lamp ) {
this.lamp.baseMaterial.color.setHex( palette.metal );
this.lamp.armMaterial.color.setHex( palette.metal );
}
this.refreshAccent();
this.applySelectionHighlight();
this.requestRender();
}
/**
* Sizes the WebGL backing store and the overlay backing store for the current device pixel ratio, and rebuilds the one-shot listener for the next change.
*
* `matchMedia( '(resolution: Ndppx)' )` only watches the current ratio, so a move to a different-density display needs the query rebuilt each time; the renderer caps the ratio at two so a very dense display does not balloon the backing store.
*/
private observeDevicePixelRatio(): void {
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
const dpr = window.devicePixelRatio || 1;
this.dprQuery = window.matchMedia( `(resolution: ${ dpr }dppx)` );
this.dprQuery.addEventListener( 'change', this.onDevicePixelRatioChange, { once: true } );
this.renderer?.setPixelRatio( Math.min( dpr, 2 ) );
}
/**
* Re-corrects the backing stores after the device pixel ratio changes, then re-arms the watcher and repaints.
*/
private onDevicePixelRatioChange = (): void => {
this.observeDevicePixelRatio();
this.onResize();
};
/**
* Resizes the renderer and updates the camera aspect when the viewport's CSS box changes, then repaints the overlay.
*/
private onResize(): void {
if ( ! this.camera || ! this.renderer ) {
return;
}
const size = this.viewportSize();
this.camera.aspect = size.width / size.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize( size.width, size.height, false );
this.requestRender();
this.syncOverlay();
}
/**
* Pauses or resumes the render loop as the demo scrolls in and out of view, so an offscreen lamp costs no frames.
*
* @param entries - The intersection records for the observed viewport.
*/
private onIntersection( entries: IntersectionObserverEntry[] ): void {
for ( const entry of entries ) {
this.isVisible = entry.isIntersecting;
if ( this.isVisible ) {
this.startLoop();
} else {
this.stopLoop();
}
}
}
/**
* Begins a drag-to-orbit gesture, recording the start pointer so motion is measured as a delta and a press in place can still resolve to a select.
*
* Only the primary pointer's primary button starts a gesture: a right or middle click falls through to the browser, since a context menu can swallow the matching release and leave a phantom drag armed, and a gesture already in progress owns the viewport, so a new contact of any type is ignored until the owning pointer releases, because `isPrimary` is per pointer type and a first touch mid-mouse-drag would otherwise pass the guard and steal the gesture.
*
* @param event - The pointerdown on the viewport.
*/
private onPointerDown = ( event: PointerEvent ): void => {
if ( this.activePointerId !== null || ! event.isPrimary || event.button !== 0 ) {
return;
}
this.isDragging = true;
this.dragMoved = false;
this.activePointerId = event.pointerId;
this.lastPointer = { x: event.clientX, y: event.clientY };
};
/**
* Applies a drag delta to the orbit yaw and pitch while a gesture is active, bounding the pitch so the lamp stays framed.
*
* Once the pointer has moved past a few pixels the gesture is marked as a drag, so the matching release orbits rather than selecting.
*
* @param event - The pointermove on the window.
*/
private onPointerMove = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
const dx = event.clientX - this.lastPointer.x;
const dy = event.clientY - this.lastPointer.y;
this.lastPointer = { x: event.clientX, y: event.clientY };
if ( Math.abs( dx ) + Math.abs( dy ) > 3 ) {
this.dragMoved = true;
}
this.orbit.yaw += dx * 0.01;
this.orbit.pitch += dy * 0.01;
this.orbit.pitch = Math.max( -0.5, Math.min( 0.6, this.orbit.pitch ) );
this.requestRender();
};
/**
* Ends the gesture, and when the pointer never moved past the drag threshold, treats the release as a raycast select on the part under it.
*
* @param event - The pointerup on the window.
*/
private onPointerUp = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
if ( ! this.dragMoved ) {
this.pickAt( event );
}
};
/**
* Abandons the gesture when the browser cancels the owning pointer, a touch scroll takeover or a system sheet, without treating the interruption as a select.
*
* Only the gesture's own pointer can cancel it, so an unrelated contact being cancelled leaves a live drag alone.
*/
private onPointerCancel = ( event: PointerEvent ): void => {
if ( event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
};
/**
* Raycasts from the camera through a pointer position and selects the lamp part it hits, or clears the selection on a miss.
*
* This is the article's 3D hit-testing lesson: there is still no built-in canvas hit test, so the pointer is converted into normalized device coordinates, `raycaster.setFromCamera` builds the ray, and `intersectObjects` against the whole lamp group resolves the nearest hit to a part id, the raycast that replaces the old `isPointInPath`.
* Every visible mesh carries the id of the part it belongs to, so a click on a cosmetic piece like the shade cap or the neck still selects; a hit whose chain carries no id, the additive glow halo, falls through to the next hit rather than eating the click, and the selection only clears when nothing in the list resolves.
* In `canvas-only` the painted UI sits on top of the scene, so its pills, swatches, and panel are hit-tested first, more controls whose geometry has to be re-derived by hand because no DOM exists to do it.
*
* @param event - The pointer event whose position to pick at.
*/
private pickAt( event: PointerEvent ): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
if ( this.activeMode === 'canvas-only' && this.pickFauxUiAt( event ) ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const ndc = new THREE.Vector2(
( ( event.clientX - rect.left ) / rect.width ) * 2 - 1,
-( ( event.clientY - rect.top ) / rect.height ) * 2 + 1,
);
this.raycaster.setFromCamera( ndc, this.camera );
const hits = this.raycaster.intersectObjects( this.lamp.group.children, true );
for ( const hit of hits ) {
const id = this.partIdForObject( hit.object );
if ( id ) {
this.select( id, 'canvas' );
return;
}
}
this.select( null, 'canvas' );
}
/**
* Hit-tests the painted faux UI by hand, the part label pills first, then the inspector's swatch row, then the panel body, and applies whatever the matching real control would do.
*
* This mirrors the exact geometry {@link paintPill} and {@link paintFauxInspector} paint (the pill boxes around the visible anchors, the swatch centers and radius, the {@link fauxPanelRect} box), because painted pixels carry no hit regions of their own.
* That duplication is the point the trap teaches: the mouse path can be made to feel complete, but every painted control costs a hand-derived hit region like this, the demo now pays that cost for all of them, and focus, the accessibility tree, and text selection still get nothing.
* A hit on the panel body that lands on no control still returns `true`, so a click on the painted inspector never raycasts the scene through it.
* The swatch hit radius is a couple of pixels wider than the painted circle so the click is comfortable, the kind of forgiveness a real `<button>` gives for free.
*
* @param event - The pointer event whose position to test.
* @returns Whether the point landed on the painted UI, with the matching action applied.
*/
private pickFauxUiAt( event: PointerEvent ): boolean {
const rect = this.$viewport.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
// Test in reverse paint order, so the topmost painted thing wins: the swatches sit on the panel, the panel covers any pill it overlaps, and the pills sit over the scene.
const panel = this.fauxPanelRect( rect.width, rect.height );
const swatchY = panel.y + 54 + 8;
let swatchX = panel.x + 12 + 8;
for ( const swatch of SHADE_SWATCHES ) {
if ( Math.hypot( px - swatchX, py - swatchY ) <= 10 ) {
this.onSelectSwatch( swatch.id );
return true;
}
swatchX += 24;
}
if ( px >= panel.x && px <= panel.x + panel.width && py >= panel.y && py <= panel.y + panel.height ) {
return true;
}
const ctx = this.overlayCtx;
if ( ctx ) {
ctx.font = `600 12px ${ this.overlayColors.fontSans }`;
// Within the pills the test also runs in reverse paint order, since a later-painted pill covers an earlier one where they overlap: one more stacking decision the DOM normally owns and painted UI has to re-derive.
for ( const part of [ ...this.parts ].reverse() ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
const padX = 9;
const boxWidth = ctx.measureText( part.label ).width + padX * 2;
const boxHeight = 22;
const x = point.x - boxWidth / 2;
const y = point.y - boxHeight / 2;
if ( px >= x && px <= x + boxWidth && py >= y && py <= y + boxHeight ) {
this.select( part.id, 'canvas' );
return true;
}
}
}
return false;
}
/**
* Resolves the part a hit object belongs to by walking its ancestry to the first stamped `userData.partId`.
*
* The geometry helper stamps every visible mesh, cosmetic pieces included, so a hit anywhere on the lamp maps to the part a user believes they clicked; the glow sprite is left unstamped on purpose, so this returns `null` for it and the caller moves on to the next hit.
*
* @param object - The object the raycaster returned.
* @returns The part id stamped on the object or an ancestor, or `null` when the chain carries none.
*/
private partIdForObject( object: THREE.Object3D ): LampPartId | null {
let current: THREE.Object3D | null = object;
while ( current ) {
const stamped = current.userData.partId;
const id = PART_IDS.find( ( partId ) => partId === stamped );
if ( id ) {
return id;
}
current = current.parent;
}
return null;
}
/**
* Starts the render loop if it is not already running and the demo is on screen, and marks the scene dirty so the first frame back repaints.
*/
private startLoop(): void {
if ( this.frame === 0 && this.isVisible ) {
this.frame = requestAnimationFrame( this.renderLoop );
}
this.requestRender();
}
/**
* Stops the render loop and clears the frame handle so it can be restarted cleanly.
*/
private stopLoop(): void {
if ( this.frame !== 0 ) {
cancelAnimationFrame( this.frame );
this.frame = 0;
}
}
/**
* Marks the scene dirty so the next animation frame renders the WebGL view and re-syncs the overlay.
*/
private requestRender(): void {
this.needsRender = true;
}
/**
* The render loop: applies the orbit to the lamp, draws the WebGL frame, and re-syncs the overlay so the labels stay glued as the model turns.
*
* The loop stays hot at the frame cadence while the demo is on screen, but the work is gated on {@link needsRender}: an idle frame re-arms itself and returns, so a lamp nobody is touching renders nothing and writes no fresh `anchorPoints`, and the first frame after a change does the full pass.
*/
private renderLoop = (): void => {
this.frame = requestAnimationFrame( this.renderLoop );
if ( ! this.needsRender ) {
return;
}
if ( ! this.renderer || ! this.scene || ! this.camera || ! this.lamp ) {
return;
}
this.needsRender = false;
this.lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
this.renderer.render( this.scene, this.camera );
this.syncOverlay();
};
/**
* Lifts the emissive of the selected part and rests every other part, so the selection reads in 3D as a glow on the chosen mesh in either theme.
*
* It runs on a selection or theme change rather than every frame, since the emissive persists in the material between renders, and the cached accent keeps it off the `getComputedStyle` path.
* The bulb is left to its own lit glow, so selecting it does not fight the bulb toggle; its overlay label and inspector heading still mark the selection.
*/
private applySelectionHighlight(): void {
if ( ! this.lamp ) {
return;
}
for ( const id of PART_IDS ) {
if ( id === 'bulb' ) {
continue;
}
const material = this.lamp.parts[ id ].material;
if ( Array.isArray( material ) || ! ( material instanceof THREE.MeshStandardMaterial ) ) {
continue;
}
const selected = id === this.selectedId;
material.emissive.copy( selected ? this.accent : BLACK );
material.emissiveIntensity = selected ? 0.32 : 0;
}
}
/**
* Projects each part's 3D anchor to a CSS-pixel screen position and stores it, then repaints the flat overlay for the active mode.
*
* This is the alignment bookkeeping the article says the overlay owns: the anchor is taken into world space through the lamp's matrix, projected with the camera, and converted from normalized device coordinates to CSS pixels against the canvas rect, which keeps the projection device-pixel-ratio correct because the rect is in CSS pixels while the WebGL backing store carries the ratio separately.
*/
private syncOverlay(): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
this.lamp.group.updateMatrixWorld();
const next: Record<string, { x: number; y: number; visible: boolean }> = {};
for ( const id of PART_IDS ) {
this.projected.copy( this.lamp.anchors[ id ] );
this.lamp.group.localToWorld( this.projected );
this.projected.project( this.camera );
next[ id ] = {
x: ( this.projected.x * 0.5 + 0.5 ) * width,
y: ( -this.projected.y * 0.5 + 0.5 ) * height,
visible: this.projected.z < 1,
};
}
this.anchorPoints = next;
this.paintFlatOverlay();
}
/**
* Repaints the flat overlay canvas for the active mode: the faux UI in `canvas-only`, a requested `paint` event for the drawn labels in `html-in-canvas`, and a clear in `dom-overlay` where real HTML owns the labels.
*
* The experimental branch neither draws nor clears here; it calls `requestPaint()` and {@link onOverlayPaint} clears and draws inside the event, so a platform-initiated paint, a focus ring appearing on a drawn label, repaints the whole canvas atomically instead of compositing over pixels this pass left behind. The shared {@link supportsHtmlInCanvas} predicate gates the mode on both methods, so the branch only runs where the request is real.
*/
private paintFlatOverlay(): void {
const $canvas = this.$overlayCanvas;
if ( ! $canvas ) {
return;
}
if ( ! this.overlayCtx ) {
this.overlayCtx = $canvas.getContext( '2d' );
}
const ctx = this.overlayCtx;
if ( ! ctx ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = rect.width || 1;
const cssHeight = rect.height || 1;
// Resize the backing store when the box or the device pixel ratio changes, since assigning width or height clears and reallocates the canvas, which would flicker if done every frame; keying on dpr too reallocates when only the ratio shifts (a display move or browser zoom that leaves the CSS box bit-identical), matching the WebGL side that already re-sizes for the new ratio.
if ( this.overlaySize.width !== cssWidth || this.overlaySize.height !== cssHeight || this.overlaySize.dpr !== dpr ) {
$canvas.width = Math.round( cssWidth * dpr );
$canvas.height = Math.round( cssHeight * dpr );
this.overlaySize = { width: cssWidth, height: cssHeight, dpr };
}
if ( this.activeMode === 'html-in-canvas' ) {
$canvas.requestPaint();
return;
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
ctx.clearRect( 0, 0, cssWidth, cssHeight );
if ( this.activeMode === 'canvas-only' ) {
this.paintFauxUi( ctx, cssWidth, cssHeight );
}
}
/**
* Paints the canvas-only trap: every part label and a faux inspector panel, all as raw pixels with no DOM behind them.
*
* It looks like a working configurator, but the text is pixels that cannot be selected, found in page, or reached with a Tab key or a screen reader, which is the trap a real 3D scene invites because it is exactly where someone reaches for drawing the UI onto the canvas too.
*
* @param ctx - The overlay 2D context, scaled so one unit is one CSS pixel.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxUi( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
this.paintPill( ctx, part.label, point.x, point.y, part.id === this.selectedId );
}
this.paintFauxInspector( ctx, width, height );
}
/**
* Paints one rounded label pill centered on a point, the faux equivalent of an overlay label, from the cached themed colors.
*
* @param ctx - The overlay 2D context.
* @param text - The label text.
* @param cx - The pill center x in CSS pixels.
* @param cy - The pill center y in CSS pixels.
* @param selected - Whether to fill the pill with the accent.
*/
private paintPill( ctx: CanvasRenderingContext2D, text: string, cx: number, cy: number, selected: boolean ): void {
const colors = this.overlayColors;
ctx.font = `600 12px ${ colors.fontSans }`;
const padX = 9;
const boxWidth = ctx.measureText( text ).width + padX * 2;
const boxHeight = 22;
const x = cx - boxWidth / 2;
const y = cy - boxHeight / 2;
const pill = new Path2D();
pill.roundRect( x, y, boxWidth, boxHeight, 11 );
ctx.fillStyle = selected ? colors.accent : colors.surface;
ctx.fill( pill );
ctx.lineWidth = 1;
ctx.strokeStyle = selected ? colors.accent : this.withAlpha( colors.ink, 0.18 );
ctx.stroke( pill );
ctx.fillStyle = selected ? colors.accentInk : colors.ink;
ctx.textBaseline = 'middle';
ctx.fillText( text, x + padX, y + boxHeight / 2 );
}
/**
* The faux inspector panel's box in CSS pixels, one derivation shared by the painter and the hand-built hit test so the drawn panel and its hit region cannot drift apart.
*
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
* @returns The panel's position and size.
*/
private fauxPanelRect( width: number, height: number ): { x: number; y: number; width: number; height: number } {
const panelWidth = Math.min( 190, width - 24 );
const panelHeight = 96;
return {
x: width - panelWidth - 12,
y: height - panelHeight - 12,
width: panelWidth,
height: panelHeight,
};
}
/**
* Paints the faux inspector panel into the corner of the canvas, a picture of the real inspector with no DOM behind it, from the cached themed colors.
*
* The panel responds to the mouse only because {@link pickFauxUiAt} re-derives this exact geometry by hand, from the same {@link fauxPanelRect} box this painter draws; there is still no element, so nothing here can take focus, appear in the accessibility tree, or offer selectable text.
*
* @param ctx - The overlay 2D context.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxInspector( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
const colors = this.overlayColors;
const box = this.fauxPanelRect( width, height );
const x = box.x;
const y = box.y;
const panel = new Path2D();
panel.roundRect( x, y, box.width, box.height, 10 );
ctx.fillStyle = this.withAlpha( colors.surface, 0.95 );
ctx.fill( panel );
ctx.lineWidth = 1;
ctx.strokeStyle = this.withAlpha( colors.ink, 0.16 );
ctx.stroke( panel );
const selected = this.selectedPart;
ctx.fillStyle = colors.ink;
ctx.textBaseline = 'alphabetic';
ctx.font = `600 13px ${ colors.fontSans }`;
ctx.fillText( selected ? selected.label : 'Inspector', x + 12, y + 24 );
ctx.font = `11px ${ colors.fontMono }`;
ctx.fillStyle = this.withAlpha( colors.ink, 0.6 );
ctx.fillText( 'Shade color', x + 12, y + 46 );
const swatchY = y + 54;
let swatchX = x + 12;
for ( const swatch of SHADE_SWATCHES ) {
ctx.beginPath();
ctx.arc( swatchX + 8, swatchY + 8, 8, 0, Math.PI * 2 );
ctx.fillStyle = swatch.hex;
ctx.fill();
if ( swatch.id === this.config.shadeSwatchId ) {
ctx.lineWidth = 2;
ctx.strokeStyle = colors.accent;
ctx.stroke();
}
swatchX += 24;
}
}
/**
* Draws the real labels when the overlay canvas fires its `paint` event, so the experimental draw runs inside the event the way the canonical flow prescribes.
*
* The render loop and the sync passes never draw or clear the labels themselves; {@link paintFlatOverlay} requests a paint, and this handler clears the canvas and runs {@link drawLabelsIntoCanvas} when the event lands, so a platform-initiated paint, a focus ring appearing on a label, starts from its own clear instead of leaving residue over the last frame.
* The mode guard mirrors the experimental branch of the paint pass, since the listener stays wired in every mode, and the context guard covers an event that lands before the first paint pass has cached the 2D context.
*/
private onOverlayPaint = (): void => {
const ctx = this.overlayCtx;
const $canvas = this.$overlayCanvas;
if ( this.activeMode !== 'html-in-canvas' || ! ctx || ! $canvas ) {
return;
}
// Clear in device pixels under the identity transform, the same grid the label draw below runs on.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
ctx.clearRect( 0, 0, $canvas.width, $canvas.height );
this.drawLabelsIntoCanvas( ctx );
};
/**
* Draws every visible real label node into the overlay canvas at its own 1:1 size and projected center, then writes each returned alignment matrix back, the experimental in-canvas-label path.
*
* Each label's `style.transform` is a platform-owned slot here. The node is already a reachable element the browser exposes in the accessibility tree and the tab order, because the overlay canvas keeps it a real DOM child under `layoutsubtree`; writing the matrix back is only what keeps that element's focus ring, hit target, and accessibility bounds aligned under its drawn pixels. When `omitTransformSync` is set the write is skipped so the reader can watch the focus ring and click target drift off the pixels.
*
* A label whose part is facing away is hidden rather than skipped in place: `visibility: hidden` also removes it from the tab order and the accessibility tree while it is offscreen, the same contract the DOM-overlay labels keep, where a skipped node would otherwise stay tabbable at its stale position.
*
* The work runs in three passes, the transform resets and visibility first, then every box measurement, then every draw and write-back, so the style writes cost at most two layout flushes per paint instead of one per label.
*
* The draw runs on the bare device-pixel grid, not under the `dpr` scale the faux UI paints with: the label node already rasterizes at the display's pixel density, so drawing it under an extra `dpr` transform would double its size, which is what made the drawn pills balloon. The projected center and the label box are scaled into device pixels by hand instead, so the drawn pill matches the DOM-overlay pill exactly, then the transform is restored for the next paint.
*
* The explainer's canonical flow routes this draw through the canvas `paint` event, and that is where it runs: the render loop and the sync passes only call `requestPaint()`, and {@link onOverlayPaint} invokes this method when the event fires. Calling `drawElementImage` outside the event is still defined behavior, but it draws the previous frame's snapshot of the element and throws before a first snapshot exists, which is why the handler owns the draw.
*
* @param ctx - The overlay 2D context, on a supporting build where `drawElementImage` exists.
*/
private drawLabelsIntoCanvas( ctx: CanvasRenderingContext2D ): void {
const $host = this.$overlayCanvas;
if ( ! $host ) {
return;
}
const dpr = window.devicePixelRatio || 1;
// Drop to the identity transform so one drawing unit is one backing-store pixel, the space the element bitmap is already rasterized in; the faux-UI dpr scale is restored at the end.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
// First pass: reset any prior alignment before measuring, so each box read is the label's own size rather than a previously skewed one, and hide the labels whose part is facing away.
const drawable: Array<{ point: { x: number; y: number }; $node: HTMLElement }> = [];
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
const $node = $host.querySelector( `[data-part='${ part.id }']` );
if ( ! ( $node instanceof HTMLElement ) ) {
continue;
}
if ( ! point || ! point.visible ) {
$node.style.visibility = 'hidden';
continue;
}
$node.style.visibility = '';
if ( ! this.omitTransformSync ) {
$node.style.transform = '';
}
drawable.push( { point, $node } );
}
// Second pass: measure every box in one batch, so the writes above flush layout once rather than per label.
const boxes = drawable.map( ( entry ) => entry.$node.getBoundingClientRect() );
// Third pass: draw each label at its projected center and write the alignment matrix back.
for ( const [ index, entry ] of drawable.entries() ) {
const box = boxes[ index ];
const dx = ( entry.point.x - box.width / 2 ) * dpr;
const dy = ( entry.point.y - box.height / 2 ) * dpr;
const alignment = ctx.drawElementImage( entry.$node, dx, dy );
if ( ! this.omitTransformSync ) {
entry.$node.style.transform = alignment.toString();
}
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
}
/**
* Reads a CSS custom property off the host, falling back to a literal when the token sheet has not loaded.
*
* A canvas paints raw pixels and cannot inherit a CSS color, so every themed color the faux UI draws is read here at paint time, which is what keeps the canvas-only labels legible in light and dark.
*
* @param name - The custom property to read.
* @param fallback - The literal color to use when the property is empty.
* @returns The resolved color string.
*/
private token( name: string, fallback: string ): string {
const value = getComputedStyle( this ).getPropertyValue( name ).trim();
return value || fallback;
}
/**
* Resolves the selection accent the same way the stylesheet does, reading the private `--cfg-accent` so the 3D highlight and the painted UI match the DOM controls in either theme.
*/
private accentColor(): string {
return this.token( '--cfg-accent', '#2257e6' );
}
/**
* Re-reads the themed colors into the caches the render loop relies on: the accent as a Three.js color for the 3D highlight, and the token set the flat overlay paints with.
*
* It is called on connect and after a theme change, so every per-frame draw works from cached values rather than calling `getComputedStyle`.
* The flip side of that cache is staleness: a host that swaps `--configurator-accent` at runtime without a theme flip paints the old accent until the next theme change refreshes it.
*/
private refreshAccent(): void {
const accent = this.accentColor();
this.accent.set( accent );
this.overlayColors = {
ink: this.token( '--ink', '#17171a' ),
surface: this.token( '--surface', '#ffffff' ),
accent,
accentInk: this.token( '--accent-ink', '#ffffff' ),
fontSans: this.token( '--font-sans', 'system-ui, sans-serif' ),
fontMono: this.token( '--font-mono', 'monospace' ),
};
}
/**
* Returns a color string at a given alpha, parsing a `#rgb` or `#rrggbb` token into `rgba()` and mixing any other syntax toward transparent with `color-mix()`.
*
* The canvas needs an explicit alpha for the faux panel and pill borders, but the theme tokens are usually opaque hex, so the fast path parses those by hand; anything else goes through `color-mix()`, which any browser modern enough to hand us a non-hex token can paint.
*
* @param color - The base color, usually a hex token value.
* @param alpha - The alpha to apply, from 0 to 1.
* @returns An `rgba()` string for hex input, or a `color-mix()` expression for anything else.
*/
private withAlpha( color: string, alpha: number ): string {
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec( color.trim() );
if ( ! match ) {
return `color-mix( in srgb, ${ color } ${ Math.round( alpha * 100 ) }%, transparent )`;
}
const body = match[ 1 ];
const full = body.length === 3 ? body.split( '' ).map( ( c ) => c + c ).join( '' ) : body;
const r = parseInt( full.slice( 0, 2 ), 16 );
const g = parseInt( full.slice( 2, 4 ), 16 );
const b = parseInt( full.slice( 4, 6 ), 16 );
return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`;
}
/**
* Selects a part from a real overlay label, the keyboard-reachable path the raycast cannot provide.
*
* @param id - The id of the part the label represents.
*/
private onLabelClick( id: string ): void {
this.select( id, 'overlay' );
}
/**
* Updates the selection and dispatches the typed `object-select` event, tagged with the layer it came from.
*
* The event fires on every pick, even a repeat of the current selection, so a demo log can record each raycast; the internal `selectedId` only changes when the target actually changes.
* The event is `composed` so it crosses the shadow boundary and `bubbles` so a listener on the host hears it.
*
* @param id - The id to select, or `null` to clear.
* @param source - Which layer triggered the selection, the raycast or a real overlay control.
*/
private select( id: string | null, source: ConfiguratorSelectSource ): void {
if ( id !== this.selectedId ) {
this.selectedId = id;
}
this.dispatchEvent(
new CustomEvent<ConfiguratorSelectDetail>( 'object-select', {
detail: { objectId: id, source },
bubbles: true,
composed: true,
} ),
);
}
/**
* Recolors the shade to a swatch, applying it to the live material and reporting the new config.
*
* @param swatchId - The id of the chosen swatch.
*/
private onSelectSwatch( swatchId: string ): void {
this.config = { ...this.config, shadeSwatchId: swatchId };
if ( this.lamp ) {
setShadeColor( this.lamp, this.shadeHex() );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Switches the base finish, retuning the live base material and reporting the new config.
*
* @param finish - The finish to apply, matte or metal.
*/
private onSelectFinish( finish: BaseFinish ): void {
this.config = { ...this.config, baseFinish: finish };
if ( this.lamp ) {
setBaseFinish( this.lamp, finish );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Toggles the bulb, relighting the scene through the helper and reporting the new config.
*/
private onToggleBulb(): void {
const bulbOn = ! this.config.bulbOn;
this.config = { ...this.config, bulbOn };
if ( this.lamp ) {
setBulbState( this.lamp, bulbOn );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Dispatches the typed `config-change` event with the whole current config after an inspector edit.
*
* It is `composed` and `bubbles` for the same reason the selection event is, so a host listener hears every edit without a cast.
*/
private emitConfigChange(): void {
this.dispatchEvent(
new CustomEvent<ConfiguratorConfigChangeDetail>( 'config-change', {
detail: { config: this.config },
bubbles: true,
composed: true,
} ),
);
}
/**
* Types `addEventListener` for the configurator's own events so a listener receives the typed `CustomEvent` with no cast, and falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof ProductConfiguratorEventMap>(
type: K,
listener: ( this: ProductConfigurator, ev: ProductConfiguratorEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the WebGL viewport, the flat overlay canvas, the real DOM labels or the drawn-label nodes for the active mode, and the inspector and harness panel in the side column.
*
* The WebGL canvas always renders the lamp; what changes per mode is whether real HTML labels sit over it (`dom-overlay`), real label nodes are drawn into the overlay canvas (`html-in-canvas`), or the labels and a faux inspector are painted onto the overlay canvas with no DOM (`canvas-only`).
* The stage and the side column are always laid out by the same `.layout` grid: `stacked` keeps it one column so the side falls under the canvas, and `split` makes it two so the side becomes the right column, the glass-demo shape.
*/
render() {
const active = this.activeMode;
const parts = this.parts;
return html`
<div class=${ this.layout === 'split' ? 'layout split' : 'layout' }>
<div class="main">
<slot name="header"></slot>
<div class=${ this.isDragging ? 'stage dragging' : 'stage' }>
<canvas class="viewport" role="img" aria-label="A 3D desk lamp you can orbit by dragging. Click a part to select it."></canvas>
<canvas class="overlay-canvas" aria-hidden=${ active === 'html-in-canvas' ? 'false' : 'true' } ?layoutsubtree=${ active === 'html-in-canvas' }>
${ active === 'html-in-canvas'
? parts.map( ( part ) => html`
<button
type="button"
class="drawn-label"
data-part=${ part.id }
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
` )
: nothing }
</canvas>
${ active === 'dom-overlay'
? html`
<div class="overlay">
${ parts.map( ( part ) => {
const point = this.anchorPoints[ part.id ];
const shown = point && point.visible;
return html`
<button
type="button"
class="label"
style="left: ${ point ? point.x : 0 }px; top: ${ point ? point.y : 0 }px; visibility: ${ shown ? 'visible' : 'hidden' };"
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
`;
} ) }
</div>
`
: nothing }
</div>
</div>
${ this.renderSide( active ) }
</div>
`;
}
/**
* Renders the side column of the layout, the real inspector when the mode has one stacked above the harness `panel` slot, and in `split` a full-width `footer` slot under both columns.
*
* In `split` it is always rendered so it forms the right column even in `canvas-only`, where the inspector is painted into the canvas and the slot still carries the harness panel. In `stacked` it is rendered only when there is something to show, the inspector for a mode that has one, so a bare `canvas-only` embed does not leave an empty row under the canvas.
* The `footer` row spans both columns and only renders in `split`, so a harness can drop a tall report or log there instead of stacking it in the narrow side column, where it would run far below the stage and strand the canvas column; an empty `footer` slot produces no box, so a harness that slots nothing renders exactly as before.
*
* @param active - The active mode, which decides whether a real DOM inspector exists.
* @returns The side-column template, or `nothing` when stacked with no inspector to show.
*/
private renderSide( active: ConfiguratorMode ) {
const inspector = active === 'canvas-only' ? nothing : this.renderInspector();
if ( this.layout !== 'split' && active === 'canvas-only' ) {
return nothing;
}
return html`
<div class="side">
${ inspector }
<slot name="panel"></slot>
</div>
${ this.layout === 'split'
? html`<div class="footer"><slot name="footer"></slot></div>`
: nothing }
`;
}
/**
* Renders the real DOM inspector for the selected part: the shade swatches, the base finish, and the bulb toggle, each a real, keyboard-reachable control.
*
* It shows every control whatever the selected part, since the lamp is one product, and names the selected part in the heading so the selection still reads; with nothing selected the heading falls back to the whole lamp.
*/
private renderInspector() {
const selected = this.selectedPart;
return html`
<div class="inspector">
<div class="head">
<h3>${ selected ? selected.label : 'Lamp' }</h3>
${ selected ? html`<span class="part-tag">#${ selected.id }</span>` : nothing }
</div>
<div class="field">
<span class="field-label">Shade color</span>
<div class="swatches">
${ SHADE_SWATCHES.map( ( swatch ) => html`
<button
type="button"
class="swatch"
style="background: ${ swatch.hex };"
aria-label=${ swatch.label }
aria-pressed=${ swatch.id === this.config.shadeSwatchId }
@click=${ () => this.onSelectSwatch( swatch.id ) }
></button>
` ) }
</div>
</div>
<div class="field">
<span class="field-label">Base finish</span>
<div class="segmented">
<button type="button" aria-pressed=${ this.config.baseFinish === 'matte' } @click=${ () => this.onSelectFinish( 'matte' ) }>Matte</button>
<button type="button" aria-pressed=${ this.config.baseFinish === 'metal' } @click=${ () => this.onSelectFinish( 'metal' ) }>Metal</button>
</div>
</div>
<div class="field">
<span class="field-label">Bulb</span>
<button type="button" class="switch" aria-pressed=${ this.config.bulbOn } @click=${ () => this.onToggleBulb() }>
<span class=${ this.config.bulbOn ? 'track on' : 'track' }><span class="knob"></span></span>
<span>${ this.config.bulbOn ? 'On' : 'Off' }</span>
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'product-configurator': ProductConfigurator;
}
}Step 7: the failure that teaches the lesson
The most important line in the experimental path is the one that’s easy to skip, the transform sync. Drawing the element is the easy part. Keeping the real element under its drawn pixels is the hard requirement, and the explainer is blunt: hit testing, intersection observer, and accessibility all rely on the element’s DOM location, so its transform has to be updated so the DOM location matches the drawn location.
Omit it and the pixels appear in the right place, but the live element is somewhere else. The focus ring draws where the element sits, not where you painted it, the click target lands wrong, and the accessibility geometry is off the pixels. The same omit-transform-sync switch from drawLabelsIntoCanvas drives this, so on a supporting build you can toggle it in the demo above and watch the focus ring slide off the label as the lamp turns.
Keeping it a real DOM node under layoutsubtree gives the browser a reachable element to expose, in the accessibility tree and the tab order, and syncing the returned transform every paint keeps that element’s focus rectangle, hit target, and accessibility bounds aligned with the pixels. There’s one limit worth being honest about. Whether a screen reader actually announces canvas fallback content has been uneven across browsers, so “reachable” is better evidenced than “announced.” Verify it in a real browser with the flag and a screen reader before calling it accessible, which is why the feature-detect demo lists what still needs a hand test rather than asserting it.
One more sharp edge. drawElementImage() expects the alignment matrix in style.transform every paint, which makes style.transform platform-owned. Each label here is a plain button with nothing of its own to transform, so it just takes the matrix. A drawn element that also needs a visual transform of its own is a conflict, because the draw ignores CSS transforms on the source element while hit testing still honors them, so writing that transform yourself either clobbers the alignment or slides the hit target and the accessibility bounds off the pixels. The way out is to wrap it, keeping the alignment matrix on the drawn child and letting an inner element own the visual transform. When a platform API hands you a transform, ask whether it owns the whole stack or only the alignment layer.
Step 8: review the API fit, and assemble the whole thing
Step back and score the API against a rubric you can reuse on the next frontier primitive. Early gates are near-vetoes that decide whether a thing can ship at all; later gates size the cost.
| Gate | The question | Weight | HTML-in-Canvas |
|---|---|---|---|
| Baseline / cross-engine | Does it run on more than one engine, unflagged? | Veto | Fail. Chromium-only, behind a flag, not Baseline. |
| Standards consensus | Is the spec mature, and are other vendors positive, silent, or opposed? | High | Weak. WICG incubation; Mozilla raised fingerprinting and compatibility concerns with no filed position, and WebKit has no position on the current proposal, only complexity and API-design concerns on the withdrawn precursor. |
| Fallback exists | If you delete the API, does the product still work? | Veto | Pass, by design. The content stays real DOM and renders normally. |
| Failure observable and safe | Can you feature-detect cleanly, and does absence no-op rather than corrupt? | High | Pass. Detectable on the live context; branch before calling. |
| Degrade vs break | On unsupported engines, graceful or broken? | High | Pass if architected right: keep the DOM authoritative. |
| Reversible | Can you rip it out cheaply, and is the name stable? | Med | Mixed. The call site is thin, but the method has renamed before, so wrap it. |
| Testing burden | How many engines and paths must you test? | Med | High. The real path is flagged Chromium only, so two code paths. |
The verdict scale is three words. Ship when it’s Baseline, has consensus, and a low test burden. Enhance when it fails Baseline but degrades safely, is observable and reversible, and a real fallback carries the experience, so it’s production-allowed only as a progressive enhancement. Research when it fails Baseline and is unstable or has no fallback, so it stays a prototype. HTML-in-Canvas lands at Research, graduating to Enhance behind a flag for non-critical visual polish: it clears the safety, reversibility, and fallback gates, so you may layer it as pure enhancement behind feature detection, but it fails Baseline and is still renaming, so it’s never load-bearing, and the fallback is the real product.
The assembled types.ts:
types.ts
/**
* Which layer owns the configurator UI.
*
* `canvas-only` paints the whole interface, the part labels and a faux inspector, onto a 2D overlay stacked over the WebGL canvas, so there is no fallback DOM to focus or read.
* `dom-overlay` keeps the lamp on the WebGL canvas and the labels and inspector as real HTML, the labels projected from each part's 3D anchor to 2D screen pixels and kept glued as the lamp orbits, which is the version that ships today.
* `html-in-canvas` opts into the experimental API, drawing the real HTML labels into a flat 2D overlay canvas with `drawElementImage` and syncing the returned transform back so the label stays reachable, falling back to `dom-overlay` when the API is absent.
*/
export type ConfiguratorMode = 'canvas-only' | 'dom-overlay' | 'html-in-canvas';
/**
* How the configurator arranges its 3D stage and its inspector.
*
* `stacked` keeps the inspector directly under the canvas, the standalone default a single embed wants.
* `split` lays the canvas in a left column and the inspector in a right column beside it, with a `panel` slot under the inspector for a harness's own controls, so a demo can put the canvas on the left and every control in one right column the way the glass demo does; it collapses back to one stacked column on a narrow width.
*/
export type ConfiguratorLayout = 'stacked' | 'split';
/**
* The id of a selectable lamp part, one of the four named meshes the procedural lamp is built from.
*
* The ids are a closed set because the lamp geometry, the part anchors the overlay projects, and the inspector controls are all keyed by them; a `SceneObject` whose id is one of these binds to its real mesh, and any other id is ignored by the scene.
*/
export type LampPartId = 'shade' | 'arm' | 'bulb' | 'base';
/**
* One selectable part of the product, named by the parent and bound to a real 3D mesh by the component.
*
* The configurator no longer draws this rectangle: the part is a Three.js mesh built procedurally from {@link LampPartId}, and the canvas owns the pixels while a raycast owns hit testing.
* The box coordinates are retained because the teaching harnesses still read them, the overlay demo to annotate where the projected labels sit and the device-pixel-ratio demo to draw its own side-by-side canvases, so they describe the part's place in the harness's flat 600 by 360 design space rather than anything the 3D scene reads.
*/
export type SceneObject = {
/**
* A stable id used to bind the part to its 3D mesh, mark the selection, and key its overlay label and inspector controls.
*
* It is typed as the open `string` rather than {@link LampPartId} so a harness can pass any scene, and the component maps the four known ids onto lamp meshes and skips the rest.
*/
id: string;
/**
* The human-readable name shown on the projected label and in the inspector heading.
*/
label: string;
/**
* The part's left edge in the harness's flat design space, in design units.
*
* Retained for the teaching harnesses that still draw in 2D; the 3D scene does not read it.
*/
x: number;
/**
* The part's top edge in the harness's flat design space, in design units.
*
* Retained for the teaching harnesses that still draw in 2D; the 3D scene does not read it.
*/
y: number;
/**
* The part's width in the harness's flat design space, in design units.
*
* Retained for the teaching harnesses that still draw in 2D; the 3D scene does not read it.
*/
width: number;
/**
* The part's height in the harness's flat design space, in design units.
*
* Retained for the teaching harnesses that still draw in 2D; the 3D scene does not read it.
*/
height: number;
};
/**
* A swatch the shade can be recolored to from the inspector.
*
* The id is the value written into the inspector's selected state and reported, and the hex is the color applied to the shade material at runtime, kept together so the swatch row renders and the material recolor read from one source.
*/
export interface ShadeSwatch {
/**
* A stable id for the swatch, used as the selected value and the option key.
*/
id: string;
/**
* The human-readable swatch name, used as the control's accessible label.
*/
label: string;
/**
* The swatch color as a `#rrggbb` hex string, applied to the shade material.
*/
hex: string;
}
/**
* Which finish the base mesh wears, switched from the inspector.
*
* `matte` is a soft, low-reflectance surface and `metal` a polished, reflective one, applied by retuning the base material's roughness and metalness rather than swapping the mesh.
*/
export type BaseFinish = 'matte' | 'metal';
/**
* The configurable state of the lamp the inspector edits, held additively so the public selection and mode surface is unchanged.
*
* It is one object so the component can expose it as a single controllable property and report it whole, and so a future harness can drive every control from outside without learning a separate property per knob.
*/
export interface LampConfig {
/**
* The id of the selected shade swatch, which recolors the shade material.
*/
shadeSwatchId: string;
/**
* The base finish, matte or metal, which retunes the base material.
*/
baseFinish: BaseFinish;
/**
* Whether the bulb is lit, which turns the in-shade light and the bulb's emissive glow on or off and so relights the scene.
*/
bulbOn: boolean;
}
/**
* Where a configurator selection came from, so a listener can show the canvas-owns-selection boundary firing.
*
* `canvas` is a raycast pick on the 3D scene, `overlay` is a click or keyboard activation on a real HTML control, and `null` is the initial cleared state.
*/
export type ConfiguratorSelectSource = 'canvas' | 'overlay' | null;
/**
* The detail payload carried by the `object-select` event of `product-configurator`.
*/
export interface ConfiguratorSelectDetail {
/**
* The id of the part the user selected, or `null` when the selection was cleared by clicking empty space.
*/
objectId: string | null;
/**
* Which layer the selection came from, so a demo can log a canvas raycast distinctly from an overlay control.
*/
source: ConfiguratorSelectSource;
}
/**
* The detail payload carried by the `config-change` event of `product-configurator`.
*
* It carries the whole {@link LampConfig} after any inspector edit, so a listener reads the new shade color, base finish, and bulb state without inspecting individual controls.
*/
export interface ConfiguratorConfigChangeDetail {
/**
* The lamp configuration after the edit that triggered the event.
*/
config: LampConfig;
}
/**
* Which page theme the studio is lit for, resolved the same way the rest of the demos decide it.
*
* `light` is a bright neutral studio that reads against the page's light surface, and `dark` is a dimmer, warmer studio that keeps the lamp legible on the page's dark surface.
*/
export type ConfiguratorTheme = 'light' | 'dark';
/**
* Event map for `product-configurator`, so a listener on the element is typed without a cast.
*/
export interface ProductConfiguratorEventMap extends HTMLElementEventMap {
'object-select': CustomEvent<ConfiguratorSelectDetail>;
'config-change': CustomEvent<ConfiguratorConfigChangeDetail>;
}The assembled index.ts is long, because the configurator carries the WebGL scene, the device-pixel-ratio lifecycle, the raycast, the projection, and the experimental draw together. It’s folded below in full, the same file the live demos on this page run, with the lamp geometry staying behind buildLamp in lamp.ts next door.
Full component
import { LitElement, html, css, nothing } from 'lit';
import { supportsHtmlInCanvas } from '@demos/supports-html-in-canvas';
import { customElement, property, state, query } from 'lit/decorators.js';
import * as THREE from 'three';
import { buildLamp, disposeLamp, setBaseFinish, setBulbState, setShadeColor, STUDIO_PALETTES } from './lamp';
import type { LampHandle } from './lamp';
import type {
BaseFinish,
ConfiguratorConfigChangeDetail,
ConfiguratorLayout,
ConfiguratorMode,
ConfiguratorSelectDetail,
ConfiguratorSelectSource,
ConfiguratorTheme,
LampConfig,
LampPartId,
ProductConfiguratorEventMap,
SceneObject,
ShadeSwatch,
} from './types';
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here.
* Writing the returned matrix into the element's `style.transform` is what keeps focus, hit testing, and accessibility geometry on the drawn pixels.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface OffscreenCanvasRenderingContext2D {
/**
* The offscreen mirror of {@link CanvasRenderingContext2D.drawElementImage}, declared for completeness so a worker path type-checks too.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface HTMLCanvasElement {
/**
* Opts the canvas in to the experimental model, so its direct children lay out and become drawable and hit-testable.
*
* Reflects the `layoutsubtree` HTML attribute, and is absent from `lib.dom.d.ts` because the feature is still flag-gated.
*/
layoutSubtree: boolean;
/**
* Forces a `paint` event on the next frame, the way `requestAnimationFrame` schedules a draw.
*/
requestPaint(): void;
}
}
/**
* The swatches the shade can be recolored to, a small neutral-to-bold range so the recolor reads in both themes.
*
* It is a module constant rather than a property because the swatch set is part of the lamp's design, not data a harness owns, and the first entry is the default the lamp opens on.
*/
const SHADE_SWATCHES: ReadonlyArray<ShadeSwatch> = [
{ id: 'porcelain', label: 'Porcelain', hex: '#e9e7e0' },
{ id: 'cobalt', label: 'Cobalt', hex: '#2257e6' },
{ id: 'moss', label: 'Moss', hex: '#3f7d5a' },
{ id: 'clay', label: 'Clay', hex: '#c2683f' },
];
/**
* The aspect ratio of the canvas, its height as a fraction of its width, so the stage reserves the right height before WebGL sizes itself.
*/
const CANVAS_ASPECT = 0.62;
/**
* The labelled parts the lamp draws, in the order the overlay stacks them, so the four ids the geometry and inspector know about have a single declared source.
*/
const PART_IDS: ReadonlyArray<LampPartId> = [ 'shade', 'arm', 'bulb', 'base' ];
/**
* A shared black color reused when a part's selection highlight is cleared, so the highlight reset allocates nothing per frame.
*/
const BLACK = new THREE.Color( 0x000000 );
/**
* A real-time 3D product configurator: a procedural desk lamp in a WebGL canvas, with a raycast selection, an orbit drag, and an inspector, that moves its UI between three ownership models.
*
* The lamp is graphics the DOM cannot draw, owned by the canvas, and the labels and inspector are interface, owned by the DOM, so `mode` decides which layer carries them.
* `canvas-only` paints the labels and a faux inspector onto a flat overlay canvas with no focusable DOM, the trap; `dom-overlay` is the version that ships, projecting each part's 3D anchor to a real HTML label kept glued as the lamp orbits, with the real inspector beside it; `html-in-canvas` feature-detects the experimental `drawElementImage` path, draws the real label nodes into the flat overlay canvas, and writes the returned alignment transform back, falling back to `dom-overlay` when the API is absent.
* Selection is a raycast from the camera, the 3D replacement for the old 2D hit test, and the inspector recolors the shade, switches the base finish, and toggles the bulb, which relights the scene.
*
* @element product-configurator
* @slot header - Sits above the stage in the left column, for a harness caption over the canvas.
* @slot panel - Stacks under the real inspector in the right column of the `split` layout, for a harness control panel beside the canvas.
* @slot footer - Spans both columns under the stage in the `split` layout, for a tall report or log that would otherwise strand the canvas column if stacked in the narrow side column.
* @fires object-select - Fired when the selected part changes, carrying a `ConfiguratorSelectDetail`.
* @fires config-change - Fired after an inspector edit, carrying the whole `LampConfig`.
* @cssprop [--configurator-accent=#2257e6] - The selection and focus color used by the labels and the inspector.
*/
@customElement( 'product-configurator' )
export class ProductConfigurator extends LitElement {
/**
* The selectable parts, the four named meshes of the lamp.
*
* It is `attribute: false` because an array binds as a property (`.objects=${parts}`) and never round-trips through a string attribute.
* It has no default, since inventing the parts inside the component would hide the contract that the parent names the scene; the component builds a mesh for each known id and skips any other.
*/
@property( { attribute: false } )
objects: SceneObject[] = [];
/**
* Which ownership model the configurator renders.
*
* `html-in-canvas` is a request, not a guarantee: when the experimental API is absent the component renders `dom-overlay` instead, so the selected mode and the active mode can differ.
*/
@property( { type: String } )
mode: ConfiguratorMode = 'dom-overlay';
/**
* How the canvas and the inspector are arranged.
*
* Defaults to `stacked` so a standalone embed keeps the inspector under the canvas; a harness sets `split` to get the glass-demo shape, the canvas in a left column and the inspector plus a `panel` slot in one right column, collapsing back to a single stacked column on a narrow width.
*/
@property( { type: String } )
layout: ConfiguratorLayout = 'stacked';
/**
* Whether to deliberately skip writing the alignment transform back to the drawn label.
*
* Teaching switch for the experimental path: with the sync omitted, the focus ring and click target drift off the drawn pixels, which is the failure the article makes visible.
* It does nothing outside `html-in-canvas` mode on a supporting build.
*/
@property( { type: Boolean, attribute: 'omit-transform-sync' } )
omitTransformSync = false;
/**
* The id of the currently selected part, or `null` when nothing is selected.
*
* It is a controllable property: leave it unset and the component manages selection itself from a raycast or a keyboard pick, or bind it (`.selectedId=${id}`) to drive several configurators from one shared selection, the way the review demo syncs three modes.
* Either way consumers also hear changes through the `object-select` event, and the component writes the property on every pick so the uncontrolled case stays in sync.
*/
@property( { attribute: 'selected-id' } )
selectedId: string | null = null;
/**
* The viewport canvas Three.js renders the lamp into, resolved by Lit after the first render.
*/
@query( '.viewport' )
private $viewport!: HTMLCanvasElement;
/**
* The flat 2D overlay canvas stacked over the WebGL canvas, used to paint the faux UI in `canvas-only` and to host the drawn labels in `html-in-canvas`.
*/
@query( '.overlay-canvas' )
private $overlayCanvas?: HTMLCanvasElement;
/**
* The selected page theme, resolved on connect and kept in sync at runtime, so the studio is lit to match the page in light and dark.
*
* It is `@state` so a runtime theme flip re-renders, and the scene recolors through {@link applyTheme} rather than a rebuild.
*/
@state()
private theme: ConfiguratorTheme = 'light';
/**
* The lamp's configurable state, the shade swatch, the base finish, and the bulb, edited by the inspector.
*
* It is `@state` so an edit re-renders the inspector and re-runs the overlay draw, and it opens on the first swatch with a matte base and a lit bulb so the lamp reads as switched on.
*/
@state()
private config: LampConfig = {
shadeSwatchId: SHADE_SWATCHES[ 0 ].id,
baseFinish: 'matte',
bulbOn: true,
};
/**
* Whether the live 2D context exposes the experimental `drawElementImage` method.
*
* Resolved on connect from a scratch canvas, so it gates the experimental path per render rather than trusting the requested `mode`.
* It is `@state` so flipping it triggers the re-render that mounts the drawn label nodes, since `activeMode` reads it during `render`.
*/
@state()
private supportsHtmlInCanvas = false;
/**
* The Three.js scene, camera, and renderer, created once the viewport exists.
*/
private scene: THREE.Scene | null = null;
private camera: THREE.PerspectiveCamera | null = null;
private renderer: THREE.WebGLRenderer | null = null;
/**
* The built lamp and the handles to its parts, materials, and light, or `null` before the scene is built.
*/
private lamp: LampHandle | null = null;
/**
* The studio floor mesh, kept so its geometry and material can be disposed on teardown.
*/
private floorMesh: THREE.Mesh | null = null;
/**
* The studio floor material, kept so a theme change can recolor it in place.
*/
private floorMaterial: THREE.MeshStandardMaterial | null = null;
/**
* The themed studio lights, kept so a theme change can dim or lift them to match the palette.
*/
private themedLights: {
ambient: THREE.AmbientLight | null;
key: THREE.DirectionalLight | null;
rim: THREE.DirectionalLight | null;
} = { ambient: null, key: null, rim: null };
/**
* The reusable raycaster for the part-selection pick, allocated once.
*/
private readonly raycaster = new THREE.Raycaster();
/**
* A reusable vector for projecting a part anchor, so the overlay sync allocates nothing per frame.
*/
private readonly projected = new THREE.Vector3();
/**
* The overlay canvas 2D context, cached after the first paint so the per-frame draw does not re-acquire it.
*/
private overlayCtx: CanvasRenderingContext2D | null = null;
/**
* The CSS-pixel size the overlay backing store was last sized for, so it is only resized when the box actually changes rather than every frame.
*/
private overlaySize = { width: 0, height: 0, dpr: 0 };
/**
* The resolved selection accent as a Three.js color, cached so the per-frame highlight does not call `getComputedStyle`, and refreshed on a theme change.
*/
private readonly accent = new THREE.Color( 0x2257e6 );
/**
* The themed colors the flat overlay paints with, cached and refreshed on a theme change so the canvas-only and drawn-label paints do not read the DOM every frame.
*
* The canvas cannot inherit a CSS color, so these mirror the tokens the rest of the page uses, resolved once per theme rather than once per frame.
*/
private overlayColors = {
ink: '#17171a',
surface: '#ffffff',
accent: '#2257e6',
accentInk: '#ffffff',
fontSans: 'system-ui, sans-serif',
fontMono: 'monospace',
};
/**
* The current screen position of each part's overlay label, in CSS pixels, recomputed on every rendered frame while the lamp is on screen.
*
* It is the bridge from the 3D scene to the 2D layers: the DOM-overlay labels read it for their `left` and `top`, and the canvas-only and drawn-label paints read it to place text, so all three layers stay glued to the orbiting model from one projection.
*/
@state()
private anchorPoints: Record<string, { x: number; y: number; visible: boolean }> = {};
/**
* The orbit yaw and pitch in radians, driven by the drag and applied to the lamp group each frame.
*
* The lamp rotates rather than the camera, so the studio lighting stays fixed and the part the reader turns toward stays lit; the pitch is bounded so the lamp never tips past a readable angle.
*/
private orbit = { yaw: 0.5, pitch: 0.05 };
/**
* Whether a drag-to-orbit gesture is in progress, and the last pointer position it was sampled at.
*/
private isDragging = false;
/**
* The id of the pointer that started the current gesture, so a second finger or pen mid-drag cannot steer, end, or cancel a gesture it does not own.
*
* `isPrimary` is not enough as a filter, since touch and pen each get their own primary pointer; the id is the one key that names this exact contact.
*/
private activePointerId: number | null = null;
private lastPointer = { x: 0, y: 0 };
/**
* Whether the pointer moved past a small threshold during the current gesture, so a press-and-release in place still counts as a select rather than being eaten as a drag.
*/
private dragMoved = false;
/**
* Whether the viewport is currently on screen, so the render loop only runs while the demo is visible.
*/
private isVisible = true;
/**
* Whether {@link setUp} has wired the scene, listeners, and observers, so its two entry points, the first update and a reconnect, can never double-wire them.
*/
private isSetUp = false;
/**
* The media query that fires when `devicePixelRatio` changes, so the renderer and the overlay can be re-corrected on a zoom or a move to a different-density display.
*
* It is recreated on each change because the query is pinned to a single resolution, which is the lifecycle the canvas makes the author own.
*/
private dprQuery: MediaQueryList | null = null;
/**
* The media query the studio watches so it follows the OS color scheme when the page has not pinned a theme, retained so its listener tears down on disconnect.
*/
private themeMedia: MediaQueryList | null = null;
/**
* The observer watching `data-theme` on the document element, so an in-page theme toggle re-lights the studio at runtime, retained so it disconnects cleanly.
*/
private themeObserver: MutationObserver | null = null;
/**
* The resize and intersection observers, tracked so they tear down cleanly on disconnect.
*/
private resizeObserver: ResizeObserver | null = null;
private intersectionObserver: IntersectionObserver | null = null;
/**
* The animation-frame handle, cleared on disconnect or when the demo scrolls offscreen so a hidden lamp stops its loop.
*/
private frame = 0;
/**
* Whether the next animation frame has work to do.
*
* Nothing in the scene self-animates, so the loop keeps its frame cadence but skips the render and the overlay sync until an orbit, a selection, a config edit, a theme flip, or a resize marks the scene dirty through {@link requestRender}; label-only rendering changes in the experimental mode, a focus ring appearing on a drawn label, arrive through the canvas `paint` event and never need the flag.
*/
private needsRender = false;
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
:host {
display: block;
/* The host is a size container, so the split layout collapses on the embed's own width rather than the viewport's. */
container-type: inline-size;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
/* Resolve the selection accent into a private name; reading --configurator-accent first, then the themed --accent, then the brand, avoids the self-reference cycle that redefining --accent here would cause when a host sets --configurator-accent to the page accent. */
--cfg-accent: var( --configurator-accent, var( --accent, #2257e6 ) );
}
/* The stacked default keeps the canvas and inspector in one column; the split layout puts the canvas on the left and the inspector plus the harness panel in one right column, the glass-demo shape. */
.layout {
display: grid;
gap: var( --space-4, 16px );
}
.layout.split {
grid-template-columns: minmax( 0, 1fr ) 17rem;
grid-template-areas:
'main side'
'footer footer';
align-items: start;
}
.layout.split .main {
grid-area: main;
}
.layout.split .side {
grid-area: side;
}
/* The tall trailing content spans both columns under the stage, so a long report no longer strands the canvas column. */
.layout.split .footer {
grid-area: footer;
}
/* The left column: anything slotted into the header sits above the stage, and the stage carries the canvas. */
.main {
display: flex;
flex-direction: column;
gap: var( --space-3, 12px );
min-width: 0;
}
/* The header slot collapses to nothing when the harness slots no caption, so a bare embed shows just the stage. */
slot[name='header']::slotted( * ) {
margin: 0;
}
/* The right column of the split layout: the real inspector stacked above whatever the harness slots in, so every control reads as one column beside the canvas. */
.side {
display: flex;
flex-direction: column;
gap: var( --space-4, 16px );
min-width: 0;
}
.layout.split .inspector {
margin-top: 0;
}
/* Collapse to a single stacked column when the embed itself is too narrow for a comfortable two-column split. */
@container ( width <= 38rem ) {
.layout.split {
grid-template-columns: minmax( 0, 1fr );
grid-template-areas:
'main'
'side'
'footer';
}
}
.stage {
position: relative;
width: 100%;
aspect-ratio: 100 / 62;
border-radius: var( --radius-md, 11px );
border: 1px solid var( --line, #e7e6e2 );
overflow: hidden;
background: var( --surface, #fff );
}
canvas.viewport {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.stage.dragging canvas.viewport {
cursor: grabbing;
}
/* The flat overlay canvas sits exactly over the WebGL canvas in CSS-pixel space, the painted faux UI in canvas-only and the drawn-label host in html-in-canvas. */
canvas.overlay-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* The DOM-overlay layer sits over the canvas in CSS-pixel space, the bookkeeping the experimental API folds into the platform. */
.overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.overlay .label {
position: absolute;
transform: translate( -50%, -50% );
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
.overlay .label:hover {
border-color: var( --cfg-accent );
}
.overlay .label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
.overlay .label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
/*
* The drawn labels are real children of the overlay canvas in the experimental path: laid out and hit-testable, never display:none, anchored at the canvas origin with style.transform reserved for the platform alignment matrix that places and keeps them on the drawn pixels.
* Their look is kept identical to the DOM-overlay label above, the small light pill with ink text and a hairline border, so the only difference a reader can tell between the two modes is the mechanism, not the size or color of the pill.
*/
canvas.overlay-canvas .drawn-label {
position: absolute;
top: 0;
left: 0;
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
canvas.overlay-canvas .drawn-label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
canvas.overlay-canvas .drawn-label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.inspector {
margin-top: var( --space-3, 12px );
padding: var( --space-4, 16px );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-md, 11px );
background: var( --surface, #fff );
}
.inspector .head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var( --space-3, 12px );
margin-bottom: var( --space-3, 12px );
}
.inspector h3 {
margin: 0;
font-size: 14px;
color: var( --ink, #17171a );
}
.inspector .part-tag {
font-family: var( --font-mono, monospace );
font-size: 11px;
color: var( --ink-faint, #71717b );
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var( --space-3, 12px );
}
.field:last-child {
margin-bottom: 0;
}
.field > .field-label {
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var( --ink-faint, #71717b );
}
.swatches {
display: flex;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
border: 2px solid var( --line, #e7e6e2 );
cursor: pointer;
}
.swatch[aria-pressed='true'] {
border-color: var( --cfg-accent );
box-shadow: 0 0 0 2px var( --surface, #fff ), 0 0 0 4px var( --cfg-accent );
}
.segmented {
display: inline-flex;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-sm, 7px );
overflow: hidden;
width: max-content;
}
.segmented button {
padding: 7px 14px;
font: inherit;
font-size: 13px;
border: 0;
background: var( --bg, #fcfcfb );
color: var( --ink, #17171a );
cursor: pointer;
}
.segmented button + button {
border-left: 1px solid var( --line, #e7e6e2 );
}
.segmented button[aria-pressed='true'] {
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 13px;
color: var( --ink, #17171a );
cursor: pointer;
}
.switch .track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: var( --line, #e7e6e2 );
transition: background 0.2s ease;
flex: none;
}
.switch .track.on {
background: var( --cfg-accent );
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s ease;
}
.switch .track.on .knob {
transform: translateX( 16px );
}
`;
/**
* Resolves the page theme and the experimental-API support before the first render, so the studio opens in the page's palette and the first render already knows the active mode.
*
* On a reconnect it also re-runs {@link setUp}: the shadow DOM survives a re-parenting, so `hasUpdated` stays true, `firstUpdated` never fires again, and the wiring {@link tearDown} removed has to come back here, with the `@query` fields still resolving into the kept shadow root.
*/
connectedCallback(): void {
super.connectedCallback();
this.theme = this.detectTheme();
this.resolveSupport();
if ( this.hasUpdated ) {
this.setUp();
}
}
/**
* Runs the shared {@link setUp} once the viewport canvas exists after the first render.
*/
protected firstUpdated(): void {
this.setUp();
}
/**
* Builds the scene, wires the controls, observers, and theme listeners, and kicks the loop.
*
* It is shared by `firstUpdated` and a reconnect, because `firstUpdated` only ever fires once: without `connectedCallback` re-running this, a configurator moved to a new parent would come back with everything {@link tearDown} released still gone.
* The `isSetUp` guard keeps the two entry points from wiring the scene twice.
* It also refuses to wire a detached element: an append and remove in the same task still runs the pending first update after disconnection, and window listeners wired there would never hear the disconnect that already happened.
*/
private setUp(): void {
if ( this.isSetUp || ! this.isConnected ) {
return;
}
this.isSetUp = true;
this.refreshAccent();
this.initScene();
this.applySelectionHighlight();
this.bindEvents();
this.bindThemeListeners();
this.observeDevicePixelRatio();
this.startLoop();
}
/**
* Re-runs the overlay paint whenever a property that changes the flat layers updates, so a mode switch, a selection, or a config edit repaints the canvas-only UI or the drawn labels.
*
* A selection, mode, or objects change also marks the scene dirty through {@link requestRender}, so the gated loop draws the change on its next frame.
* The 3D scene redraws itself in the loop, so this only touches the flat overlay, and only when the loop is paused does it need to force one; the guarded call keeps a visible, animating demo from double-painting.
* The `anchorPoints` guard is what keeps a paused offscreen demo from looping: {@link syncOverlay} writes the `anchorPoints` `@state`, which schedules another update, so re-running `syncOverlay` on that follow-up would project, write `anchorPoints` again, and never terminate; skipping the call when the only change is `anchorPoints` lets a real change like a theme, selection, mode, or config edit sync the overlay exactly once and then settle.
*/
protected updated( changed: Map<PropertyKey, unknown> ): void {
if ( changed.has( 'selectedId' ) ) {
this.applySelectionHighlight();
this.requestRender();
}
if ( changed.has( 'mode' ) || changed.has( 'objects' ) ) {
this.requestRender();
}
if ( this.frame === 0 && ! changed.has( 'anchorPoints' ) ) {
this.syncOverlay();
}
}
/**
* Tears the configurator down through the shared {@link tearDown} when it leaves the document.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.tearDown();
}
/**
* Stops the loop and tears down every observer, listener, and the WebGL context so a removed configurator leaks nothing.
*
* It is the mirror of {@link setUp} rather than the body of `disconnectedCallback`, so a reconnected configurator can rebuild exactly what this released; clearing `isSetUp` is what re-arms that rebuild.
*/
private tearDown(): void {
this.isSetUp = false;
this.stopLoop();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
this.dprQuery = null;
this.themeMedia?.removeEventListener( 'change', this.onThemeMediaChange );
this.themeMedia = null;
this.themeObserver?.disconnect();
this.themeObserver = null;
this.$viewport?.removeEventListener( 'pointerdown', this.onPointerDown );
window.removeEventListener( 'pointermove', this.onPointerMove );
window.removeEventListener( 'pointerup', this.onPointerUp );
window.removeEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.removeEventListener( 'paint', this.onOverlayPaint );
if ( this.lamp ) {
disposeLamp( this.lamp );
this.lamp = null;
}
this.floorMesh?.geometry.dispose();
this.floorMesh = null;
this.floorMaterial?.dispose();
this.floorMaterial = null;
this.renderer?.dispose();
this.renderer = null;
// The window listeners that would release a live gesture are gone now, so the flags reset here; a stale drag would otherwise let plain hover motion orbit the lamp after a reconnect, since a mouse keeps a stable pointerId.
this.isDragging = false;
this.dragMoved = false;
this.activePointerId = null;
}
/**
* The mode actually rendered, which is the requested mode unless `html-in-canvas` was asked for without support.
*
* This is the single place the requested-versus-active split is resolved, so the template and the overlay paint agree on which layer owns the labels.
*/
private get activeMode(): ConfiguratorMode {
if ( this.mode === 'html-in-canvas' && ! this.supportsHtmlInCanvas ) {
return 'dom-overlay';
}
return this.mode;
}
/**
* The studio palette for the current theme, the one object the scene materials and lights read.
*/
private get palette() {
return STUDIO_PALETTES[ this.theme ];
}
/**
* The four selectable parts that map onto a real lamp mesh, in the lamp's stacking order.
*
* The component only knows the four lamp ids, so it filters the bound scene to them and keeps them in a stable order, which keeps the overlay labels and the raycast list aligned with the geometry the helper built.
*/
private get parts(): SceneObject[] {
const byId = new Map( this.objects.map( ( object ) => [ object.id, object ] ) );
const ordered: SceneObject[] = [];
for ( const id of PART_IDS ) {
const object = byId.get( id );
if ( object ) {
ordered.push( object );
}
}
return ordered;
}
/**
* The selected part, or `undefined` when nothing is selected or the selection is not a known lamp part.
*/
private get selectedPart(): SceneObject | undefined {
return this.parts.find( ( part ) => part.id === this.selectedId );
}
/**
* Records whether the drawn-label path can run, through the shared {@link supportsHtmlInCanvas} predicate that feature-detects the full path, `drawElementImage` on a live 2D context and `requestPaint` on the canvas, rather than sniffing a UA string.
*
* It runs from `connectedCallback`, since the probe needs only `document`, so the first render already knows the answer instead of flipping the mode an update cycle later.
*/
private resolveSupport(): void {
this.supportsHtmlInCanvas = supportsHtmlInCanvas();
}
/**
* Resolves the page theme the same way the demo page decides it: an explicit `data-theme` on the document element wins, and the OS color scheme is the fallback when nothing is pinned.
*
* @returns The theme the studio should light for right now.
*/
private detectTheme(): ConfiguratorTheme {
const pinned = document.documentElement.dataset.theme;
if ( pinned === 'dark' || pinned === 'light' ) {
return pinned;
}
return window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light';
}
/**
* The current size of the viewport canvas in CSS pixels, read from the element so it is right in both standalone and embedded contexts.
*/
private viewportSize(): { width: number; height: number } {
return {
width: this.$viewport.clientWidth || 600,
height: this.$viewport.clientHeight || Math.round( 600 * CANVAS_ASPECT ),
};
}
/**
* Creates the scene, camera, renderer, the studio floor and lighting, and the lamp, then frames the camera on the lamp.
*/
private initScene(): void {
const palette = this.palette;
const scene = new THREE.Scene();
scene.background = new THREE.Color( palette.background );
this.scene = scene;
const size = this.viewportSize();
const camera = new THREE.PerspectiveCamera( 38, size.width / size.height, 0.1, 100 );
camera.position.set( 0, 0.55, 5.6 );
camera.lookAt( 0, 0.26, 0 );
this.camera = camera;
const renderer = new THREE.WebGLRenderer( { canvas: this.$viewport, antialias: true, alpha: true } );
renderer.setSize( size.width, size.height, false );
renderer.setPixelRatio( Math.min( window.devicePixelRatio || 1, 2 ) );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
this.renderer = renderer;
this.buildStudio( scene );
this.buildLampInto( scene );
}
/**
* Builds the studio floor and the three-point lighting from the current palette, keeping each so a theme change can recolor or re-level it.
*
* @param scene - The scene to add the studio to.
*/
private buildStudio( scene: THREE.Scene ): void {
const palette = this.palette;
const floorMaterial = new THREE.MeshStandardMaterial( { color: palette.floor, roughness: 0.95, metalness: 0.05 } );
const floor = new THREE.Mesh( new THREE.CircleGeometry( 6, 64 ), floorMaterial );
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.95;
scene.add( floor );
this.floorMesh = floor;
this.floorMaterial = floorMaterial;
const ambient = new THREE.AmbientLight( 0xffffff, palette.ambient );
scene.add( ambient );
this.themedLights.ambient = ambient;
const key = new THREE.DirectionalLight( 0xffffff, palette.key );
key.position.set( 2.6, 4, 3.2 );
scene.add( key );
this.themedLights.key = key;
const rim = new THREE.DirectionalLight( 0xbcd0ff, palette.rim );
rim.position.set( -3, 2.2, -2.6 );
scene.add( rim );
this.themedLights.rim = rim;
}
/**
* Builds the lamp from the helper in the lamp's current config and adds it to the scene, applying the current orbit so it opens at a readable three-quarter angle.
*
* @param scene - The scene to add the lamp to.
*/
private buildLampInto( scene: THREE.Scene ): void {
const lamp = buildLamp( this.palette, this.shadeHex(), this.config.baseFinish, this.config.bulbOn );
lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
scene.add( lamp.group );
this.lamp = lamp;
}
/**
* The hex color of the currently selected shade swatch, falling back to the first swatch when the stored id is unknown.
*/
private shadeHex(): string {
const swatch = SHADE_SWATCHES.find( ( entry ) => entry.id === this.config.shadeSwatchId ) ?? SHADE_SWATCHES[ 0 ];
return swatch.hex;
}
/**
* Wires the orbit pointer listeners, the overlay's `paint` listener, the resize observer that keeps the renderer matched to the embedded stage, and the intersection observer that pauses the loop offscreen.
*
* The orbit starts on a `pointerdown` on the viewport and tracks on the window so a drag that leaves the canvas keeps rotating, and a release without a meaningful move is treated as a select on the same press.
* The `paint` listener registers once, because the overlay canvas is rendered in every mode and so survives a mode switch; on a build without the experimental API the event never fires, which leaves the handler inert.
*/
private bindEvents(): void {
this.$viewport.addEventListener( 'pointerdown', this.onPointerDown );
window.addEventListener( 'pointermove', this.onPointerMove );
window.addEventListener( 'pointerup', this.onPointerUp );
window.addEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.addEventListener( 'paint', this.onOverlayPaint );
this.resizeObserver = new ResizeObserver( () => this.onResize() );
this.resizeObserver.observe( this.$viewport );
this.intersectionObserver = new IntersectionObserver(
( entries ) => this.onIntersection( entries ),
{ threshold: 0.01 },
);
this.intersectionObserver.observe( this.$viewport );
}
/**
* Wires the two ways the page theme can change at runtime: the OS color scheme and an in-page `data-theme` toggle, both funnelled through {@link refreshTheme}.
*/
private bindThemeListeners(): void {
this.themeMedia = window.matchMedia( '(prefers-color-scheme: dark)' );
this.themeMedia.addEventListener( 'change', this.onThemeMediaChange );
this.themeObserver = new MutationObserver( () => this.refreshTheme() );
this.themeObserver.observe( document.documentElement, { attributes: true, attributeFilter: [ 'data-theme' ] } );
}
/**
* Re-resolves the OS color scheme into the studio when the system theme flips.
*/
private onThemeMediaChange = (): void => {
this.refreshTheme();
};
/**
* Re-resolves the theme and, when it actually changed, recolors the studio and the shared metal so the lamp follows the page.
*/
private refreshTheme(): void {
const next = this.detectTheme();
if ( next === this.theme ) {
return;
}
this.theme = next;
this.applyTheme();
}
/**
* Pushes the current palette onto the live scene: the background, the floor, the lights, and the shared metal tone of the arm and base, so a runtime theme change lands without rebuilding the scene.
*/
private applyTheme(): void {
const palette = this.palette;
if ( this.scene ) {
this.scene.background = new THREE.Color( palette.background );
}
this.floorMaterial?.color.setHex( palette.floor );
if ( this.themedLights.ambient ) {
this.themedLights.ambient.intensity = palette.ambient;
}
if ( this.themedLights.key ) {
this.themedLights.key.intensity = palette.key;
}
if ( this.themedLights.rim ) {
this.themedLights.rim.intensity = palette.rim;
}
if ( this.lamp ) {
this.lamp.baseMaterial.color.setHex( palette.metal );
this.lamp.armMaterial.color.setHex( palette.metal );
}
this.refreshAccent();
this.applySelectionHighlight();
this.requestRender();
}
/**
* Sizes the WebGL backing store and the overlay backing store for the current device pixel ratio, and rebuilds the one-shot listener for the next change.
*
* `matchMedia( '(resolution: Ndppx)' )` only watches the current ratio, so a move to a different-density display needs the query rebuilt each time; the renderer caps the ratio at two so a very dense display does not balloon the backing store.
*/
private observeDevicePixelRatio(): void {
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
const dpr = window.devicePixelRatio || 1;
this.dprQuery = window.matchMedia( `(resolution: ${ dpr }dppx)` );
this.dprQuery.addEventListener( 'change', this.onDevicePixelRatioChange, { once: true } );
this.renderer?.setPixelRatio( Math.min( dpr, 2 ) );
}
/**
* Re-corrects the backing stores after the device pixel ratio changes, then re-arms the watcher and repaints.
*/
private onDevicePixelRatioChange = (): void => {
this.observeDevicePixelRatio();
this.onResize();
};
/**
* Resizes the renderer and updates the camera aspect when the viewport's CSS box changes, then repaints the overlay.
*/
private onResize(): void {
if ( ! this.camera || ! this.renderer ) {
return;
}
const size = this.viewportSize();
this.camera.aspect = size.width / size.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize( size.width, size.height, false );
this.requestRender();
this.syncOverlay();
}
/**
* Pauses or resumes the render loop as the demo scrolls in and out of view, so an offscreen lamp costs no frames.
*
* @param entries - The intersection records for the observed viewport.
*/
private onIntersection( entries: IntersectionObserverEntry[] ): void {
for ( const entry of entries ) {
this.isVisible = entry.isIntersecting;
if ( this.isVisible ) {
this.startLoop();
} else {
this.stopLoop();
}
}
}
/**
* Begins a drag-to-orbit gesture, recording the start pointer so motion is measured as a delta and a press in place can still resolve to a select.
*
* Only the primary pointer's primary button starts a gesture: a right or middle click falls through to the browser, since a context menu can swallow the matching release and leave a phantom drag armed, and a gesture already in progress owns the viewport, so a new contact of any type is ignored until the owning pointer releases, because `isPrimary` is per pointer type and a first touch mid-mouse-drag would otherwise pass the guard and steal the gesture.
*
* @param event - The pointerdown on the viewport.
*/
private onPointerDown = ( event: PointerEvent ): void => {
if ( this.activePointerId !== null || ! event.isPrimary || event.button !== 0 ) {
return;
}
this.isDragging = true;
this.dragMoved = false;
this.activePointerId = event.pointerId;
this.lastPointer = { x: event.clientX, y: event.clientY };
};
/**
* Applies a drag delta to the orbit yaw and pitch while a gesture is active, bounding the pitch so the lamp stays framed.
*
* Once the pointer has moved past a few pixels the gesture is marked as a drag, so the matching release orbits rather than selecting.
*
* @param event - The pointermove on the window.
*/
private onPointerMove = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
const dx = event.clientX - this.lastPointer.x;
const dy = event.clientY - this.lastPointer.y;
this.lastPointer = { x: event.clientX, y: event.clientY };
if ( Math.abs( dx ) + Math.abs( dy ) > 3 ) {
this.dragMoved = true;
}
this.orbit.yaw += dx * 0.01;
this.orbit.pitch += dy * 0.01;
this.orbit.pitch = Math.max( -0.5, Math.min( 0.6, this.orbit.pitch ) );
this.requestRender();
};
/**
* Ends the gesture, and when the pointer never moved past the drag threshold, treats the release as a raycast select on the part under it.
*
* @param event - The pointerup on the window.
*/
private onPointerUp = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
if ( ! this.dragMoved ) {
this.pickAt( event );
}
};
/**
* Abandons the gesture when the browser cancels the owning pointer, a touch scroll takeover or a system sheet, without treating the interruption as a select.
*
* Only the gesture's own pointer can cancel it, so an unrelated contact being cancelled leaves a live drag alone.
*/
private onPointerCancel = ( event: PointerEvent ): void => {
if ( event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
};
/**
* Raycasts from the camera through a pointer position and selects the lamp part it hits, or clears the selection on a miss.
*
* This is the article's 3D hit-testing lesson: there is still no built-in canvas hit test, so the pointer is converted into normalized device coordinates, `raycaster.setFromCamera` builds the ray, and `intersectObjects` against the whole lamp group resolves the nearest hit to a part id, the raycast that replaces the old `isPointInPath`.
* Every visible mesh carries the id of the part it belongs to, so a click on a cosmetic piece like the shade cap or the neck still selects; a hit whose chain carries no id, the additive glow halo, falls through to the next hit rather than eating the click, and the selection only clears when nothing in the list resolves.
* In `canvas-only` the painted UI sits on top of the scene, so its pills, swatches, and panel are hit-tested first, more controls whose geometry has to be re-derived by hand because no DOM exists to do it.
*
* @param event - The pointer event whose position to pick at.
*/
private pickAt( event: PointerEvent ): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
if ( this.activeMode === 'canvas-only' && this.pickFauxUiAt( event ) ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const ndc = new THREE.Vector2(
( ( event.clientX - rect.left ) / rect.width ) * 2 - 1,
-( ( event.clientY - rect.top ) / rect.height ) * 2 + 1,
);
this.raycaster.setFromCamera( ndc, this.camera );
const hits = this.raycaster.intersectObjects( this.lamp.group.children, true );
for ( const hit of hits ) {
const id = this.partIdForObject( hit.object );
if ( id ) {
this.select( id, 'canvas' );
return;
}
}
this.select( null, 'canvas' );
}
/**
* Hit-tests the painted faux UI by hand, the part label pills first, then the inspector's swatch row, then the panel body, and applies whatever the matching real control would do.
*
* This mirrors the exact geometry {@link paintPill} and {@link paintFauxInspector} paint (the pill boxes around the visible anchors, the swatch centers and radius, the {@link fauxPanelRect} box), because painted pixels carry no hit regions of their own.
* That duplication is the point the trap teaches: the mouse path can be made to feel complete, but every painted control costs a hand-derived hit region like this, the demo now pays that cost for all of them, and focus, the accessibility tree, and text selection still get nothing.
* A hit on the panel body that lands on no control still returns `true`, so a click on the painted inspector never raycasts the scene through it.
* The swatch hit radius is a couple of pixels wider than the painted circle so the click is comfortable, the kind of forgiveness a real `<button>` gives for free.
*
* @param event - The pointer event whose position to test.
* @returns Whether the point landed on the painted UI, with the matching action applied.
*/
private pickFauxUiAt( event: PointerEvent ): boolean {
const rect = this.$viewport.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
// Test in reverse paint order, so the topmost painted thing wins: the swatches sit on the panel, the panel covers any pill it overlaps, and the pills sit over the scene.
const panel = this.fauxPanelRect( rect.width, rect.height );
const swatchY = panel.y + 54 + 8;
let swatchX = panel.x + 12 + 8;
for ( const swatch of SHADE_SWATCHES ) {
if ( Math.hypot( px - swatchX, py - swatchY ) <= 10 ) {
this.onSelectSwatch( swatch.id );
return true;
}
swatchX += 24;
}
if ( px >= panel.x && px <= panel.x + panel.width && py >= panel.y && py <= panel.y + panel.height ) {
return true;
}
const ctx = this.overlayCtx;
if ( ctx ) {
ctx.font = `600 12px ${ this.overlayColors.fontSans }`;
// Within the pills the test also runs in reverse paint order, since a later-painted pill covers an earlier one where they overlap: one more stacking decision the DOM normally owns and painted UI has to re-derive.
for ( const part of [ ...this.parts ].reverse() ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
const padX = 9;
const boxWidth = ctx.measureText( part.label ).width + padX * 2;
const boxHeight = 22;
const x = point.x - boxWidth / 2;
const y = point.y - boxHeight / 2;
if ( px >= x && px <= x + boxWidth && py >= y && py <= y + boxHeight ) {
this.select( part.id, 'canvas' );
return true;
}
}
}
return false;
}
/**
* Resolves the part a hit object belongs to by walking its ancestry to the first stamped `userData.partId`.
*
* The geometry helper stamps every visible mesh, cosmetic pieces included, so a hit anywhere on the lamp maps to the part a user believes they clicked; the glow sprite is left unstamped on purpose, so this returns `null` for it and the caller moves on to the next hit.
*
* @param object - The object the raycaster returned.
* @returns The part id stamped on the object or an ancestor, or `null` when the chain carries none.
*/
private partIdForObject( object: THREE.Object3D ): LampPartId | null {
let current: THREE.Object3D | null = object;
while ( current ) {
const stamped = current.userData.partId;
const id = PART_IDS.find( ( partId ) => partId === stamped );
if ( id ) {
return id;
}
current = current.parent;
}
return null;
}
/**
* Starts the render loop if it is not already running and the demo is on screen, and marks the scene dirty so the first frame back repaints.
*/
private startLoop(): void {
if ( this.frame === 0 && this.isVisible ) {
this.frame = requestAnimationFrame( this.renderLoop );
}
this.requestRender();
}
/**
* Stops the render loop and clears the frame handle so it can be restarted cleanly.
*/
private stopLoop(): void {
if ( this.frame !== 0 ) {
cancelAnimationFrame( this.frame );
this.frame = 0;
}
}
/**
* Marks the scene dirty so the next animation frame renders the WebGL view and re-syncs the overlay.
*/
private requestRender(): void {
this.needsRender = true;
}
/**
* The render loop: applies the orbit to the lamp, draws the WebGL frame, and re-syncs the overlay so the labels stay glued as the model turns.
*
* The loop stays hot at the frame cadence while the demo is on screen, but the work is gated on {@link needsRender}: an idle frame re-arms itself and returns, so a lamp nobody is touching renders nothing and writes no fresh `anchorPoints`, and the first frame after a change does the full pass.
*/
private renderLoop = (): void => {
this.frame = requestAnimationFrame( this.renderLoop );
if ( ! this.needsRender ) {
return;
}
if ( ! this.renderer || ! this.scene || ! this.camera || ! this.lamp ) {
return;
}
this.needsRender = false;
this.lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
this.renderer.render( this.scene, this.camera );
this.syncOverlay();
};
/**
* Lifts the emissive of the selected part and rests every other part, so the selection reads in 3D as a glow on the chosen mesh in either theme.
*
* It runs on a selection or theme change rather than every frame, since the emissive persists in the material between renders, and the cached accent keeps it off the `getComputedStyle` path.
* The bulb is left to its own lit glow, so selecting it does not fight the bulb toggle; its overlay label and inspector heading still mark the selection.
*/
private applySelectionHighlight(): void {
if ( ! this.lamp ) {
return;
}
for ( const id of PART_IDS ) {
if ( id === 'bulb' ) {
continue;
}
const material = this.lamp.parts[ id ].material;
if ( Array.isArray( material ) || ! ( material instanceof THREE.MeshStandardMaterial ) ) {
continue;
}
const selected = id === this.selectedId;
material.emissive.copy( selected ? this.accent : BLACK );
material.emissiveIntensity = selected ? 0.32 : 0;
}
}
/**
* Projects each part's 3D anchor to a CSS-pixel screen position and stores it, then repaints the flat overlay for the active mode.
*
* This is the alignment bookkeeping the article says the overlay owns: the anchor is taken into world space through the lamp's matrix, projected with the camera, and converted from normalized device coordinates to CSS pixels against the canvas rect, which keeps the projection device-pixel-ratio correct because the rect is in CSS pixels while the WebGL backing store carries the ratio separately.
*/
private syncOverlay(): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
this.lamp.group.updateMatrixWorld();
const next: Record<string, { x: number; y: number; visible: boolean }> = {};
for ( const id of PART_IDS ) {
this.projected.copy( this.lamp.anchors[ id ] );
this.lamp.group.localToWorld( this.projected );
this.projected.project( this.camera );
next[ id ] = {
x: ( this.projected.x * 0.5 + 0.5 ) * width,
y: ( -this.projected.y * 0.5 + 0.5 ) * height,
visible: this.projected.z < 1,
};
}
this.anchorPoints = next;
this.paintFlatOverlay();
}
/**
* Repaints the flat overlay canvas for the active mode: the faux UI in `canvas-only`, a requested `paint` event for the drawn labels in `html-in-canvas`, and a clear in `dom-overlay` where real HTML owns the labels.
*
* The experimental branch neither draws nor clears here; it calls `requestPaint()` and {@link onOverlayPaint} clears and draws inside the event, so a platform-initiated paint, a focus ring appearing on a drawn label, repaints the whole canvas atomically instead of compositing over pixels this pass left behind. The shared {@link supportsHtmlInCanvas} predicate gates the mode on both methods, so the branch only runs where the request is real.
*/
private paintFlatOverlay(): void {
const $canvas = this.$overlayCanvas;
if ( ! $canvas ) {
return;
}
if ( ! this.overlayCtx ) {
this.overlayCtx = $canvas.getContext( '2d' );
}
const ctx = this.overlayCtx;
if ( ! ctx ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = rect.width || 1;
const cssHeight = rect.height || 1;
// Resize the backing store when the box or the device pixel ratio changes, since assigning width or height clears and reallocates the canvas, which would flicker if done every frame; keying on dpr too reallocates when only the ratio shifts (a display move or browser zoom that leaves the CSS box bit-identical), matching the WebGL side that already re-sizes for the new ratio.
if ( this.overlaySize.width !== cssWidth || this.overlaySize.height !== cssHeight || this.overlaySize.dpr !== dpr ) {
$canvas.width = Math.round( cssWidth * dpr );
$canvas.height = Math.round( cssHeight * dpr );
this.overlaySize = { width: cssWidth, height: cssHeight, dpr };
}
if ( this.activeMode === 'html-in-canvas' ) {
$canvas.requestPaint();
return;
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
ctx.clearRect( 0, 0, cssWidth, cssHeight );
if ( this.activeMode === 'canvas-only' ) {
this.paintFauxUi( ctx, cssWidth, cssHeight );
}
}
/**
* Paints the canvas-only trap: every part label and a faux inspector panel, all as raw pixels with no DOM behind them.
*
* It looks like a working configurator, but the text is pixels that cannot be selected, found in page, or reached with a Tab key or a screen reader, which is the trap a real 3D scene invites because it is exactly where someone reaches for drawing the UI onto the canvas too.
*
* @param ctx - The overlay 2D context, scaled so one unit is one CSS pixel.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxUi( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
this.paintPill( ctx, part.label, point.x, point.y, part.id === this.selectedId );
}
this.paintFauxInspector( ctx, width, height );
}
/**
* Paints one rounded label pill centered on a point, the faux equivalent of an overlay label, from the cached themed colors.
*
* @param ctx - The overlay 2D context.
* @param text - The label text.
* @param cx - The pill center x in CSS pixels.
* @param cy - The pill center y in CSS pixels.
* @param selected - Whether to fill the pill with the accent.
*/
private paintPill( ctx: CanvasRenderingContext2D, text: string, cx: number, cy: number, selected: boolean ): void {
const colors = this.overlayColors;
ctx.font = `600 12px ${ colors.fontSans }`;
const padX = 9;
const boxWidth = ctx.measureText( text ).width + padX * 2;
const boxHeight = 22;
const x = cx - boxWidth / 2;
const y = cy - boxHeight / 2;
const pill = new Path2D();
pill.roundRect( x, y, boxWidth, boxHeight, 11 );
ctx.fillStyle = selected ? colors.accent : colors.surface;
ctx.fill( pill );
ctx.lineWidth = 1;
ctx.strokeStyle = selected ? colors.accent : this.withAlpha( colors.ink, 0.18 );
ctx.stroke( pill );
ctx.fillStyle = selected ? colors.accentInk : colors.ink;
ctx.textBaseline = 'middle';
ctx.fillText( text, x + padX, y + boxHeight / 2 );
}
/**
* The faux inspector panel's box in CSS pixels, one derivation shared by the painter and the hand-built hit test so the drawn panel and its hit region cannot drift apart.
*
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
* @returns The panel's position and size.
*/
private fauxPanelRect( width: number, height: number ): { x: number; y: number; width: number; height: number } {
const panelWidth = Math.min( 190, width - 24 );
const panelHeight = 96;
return {
x: width - panelWidth - 12,
y: height - panelHeight - 12,
width: panelWidth,
height: panelHeight,
};
}
/**
* Paints the faux inspector panel into the corner of the canvas, a picture of the real inspector with no DOM behind it, from the cached themed colors.
*
* The panel responds to the mouse only because {@link pickFauxUiAt} re-derives this exact geometry by hand, from the same {@link fauxPanelRect} box this painter draws; there is still no element, so nothing here can take focus, appear in the accessibility tree, or offer selectable text.
*
* @param ctx - The overlay 2D context.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxInspector( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
const colors = this.overlayColors;
const box = this.fauxPanelRect( width, height );
const x = box.x;
const y = box.y;
const panel = new Path2D();
panel.roundRect( x, y, box.width, box.height, 10 );
ctx.fillStyle = this.withAlpha( colors.surface, 0.95 );
ctx.fill( panel );
ctx.lineWidth = 1;
ctx.strokeStyle = this.withAlpha( colors.ink, 0.16 );
ctx.stroke( panel );
const selected = this.selectedPart;
ctx.fillStyle = colors.ink;
ctx.textBaseline = 'alphabetic';
ctx.font = `600 13px ${ colors.fontSans }`;
ctx.fillText( selected ? selected.label : 'Inspector', x + 12, y + 24 );
ctx.font = `11px ${ colors.fontMono }`;
ctx.fillStyle = this.withAlpha( colors.ink, 0.6 );
ctx.fillText( 'Shade color', x + 12, y + 46 );
const swatchY = y + 54;
let swatchX = x + 12;
for ( const swatch of SHADE_SWATCHES ) {
ctx.beginPath();
ctx.arc( swatchX + 8, swatchY + 8, 8, 0, Math.PI * 2 );
ctx.fillStyle = swatch.hex;
ctx.fill();
if ( swatch.id === this.config.shadeSwatchId ) {
ctx.lineWidth = 2;
ctx.strokeStyle = colors.accent;
ctx.stroke();
}
swatchX += 24;
}
}
/**
* Draws the real labels when the overlay canvas fires its `paint` event, so the experimental draw runs inside the event the way the canonical flow prescribes.
*
* The render loop and the sync passes never draw or clear the labels themselves; {@link paintFlatOverlay} requests a paint, and this handler clears the canvas and runs {@link drawLabelsIntoCanvas} when the event lands, so a platform-initiated paint, a focus ring appearing on a label, starts from its own clear instead of leaving residue over the last frame.
* The mode guard mirrors the experimental branch of the paint pass, since the listener stays wired in every mode, and the context guard covers an event that lands before the first paint pass has cached the 2D context.
*/
private onOverlayPaint = (): void => {
const ctx = this.overlayCtx;
const $canvas = this.$overlayCanvas;
if ( this.activeMode !== 'html-in-canvas' || ! ctx || ! $canvas ) {
return;
}
// Clear in device pixels under the identity transform, the same grid the label draw below runs on.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
ctx.clearRect( 0, 0, $canvas.width, $canvas.height );
this.drawLabelsIntoCanvas( ctx );
};
/**
* Draws every visible real label node into the overlay canvas at its own 1:1 size and projected center, then writes each returned alignment matrix back, the experimental in-canvas-label path.
*
* Each label's `style.transform` is a platform-owned slot here. The node is already a reachable element the browser exposes in the accessibility tree and the tab order, because the overlay canvas keeps it a real DOM child under `layoutsubtree`; writing the matrix back is only what keeps that element's focus ring, hit target, and accessibility bounds aligned under its drawn pixels. When `omitTransformSync` is set the write is skipped so the reader can watch the focus ring and click target drift off the pixels.
*
* A label whose part is facing away is hidden rather than skipped in place: `visibility: hidden` also removes it from the tab order and the accessibility tree while it is offscreen, the same contract the DOM-overlay labels keep, where a skipped node would otherwise stay tabbable at its stale position.
*
* The work runs in three passes, the transform resets and visibility first, then every box measurement, then every draw and write-back, so the style writes cost at most two layout flushes per paint instead of one per label.
*
* The draw runs on the bare device-pixel grid, not under the `dpr` scale the faux UI paints with: the label node already rasterizes at the display's pixel density, so drawing it under an extra `dpr` transform would double its size, which is what made the drawn pills balloon. The projected center and the label box are scaled into device pixels by hand instead, so the drawn pill matches the DOM-overlay pill exactly, then the transform is restored for the next paint.
*
* The explainer's canonical flow routes this draw through the canvas `paint` event, and that is where it runs: the render loop and the sync passes only call `requestPaint()`, and {@link onOverlayPaint} invokes this method when the event fires. Calling `drawElementImage` outside the event is still defined behavior, but it draws the previous frame's snapshot of the element and throws before a first snapshot exists, which is why the handler owns the draw.
*
* @param ctx - The overlay 2D context, on a supporting build where `drawElementImage` exists.
*/
private drawLabelsIntoCanvas( ctx: CanvasRenderingContext2D ): void {
const $host = this.$overlayCanvas;
if ( ! $host ) {
return;
}
const dpr = window.devicePixelRatio || 1;
// Drop to the identity transform so one drawing unit is one backing-store pixel, the space the element bitmap is already rasterized in; the faux-UI dpr scale is restored at the end.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
// First pass: reset any prior alignment before measuring, so each box read is the label's own size rather than a previously skewed one, and hide the labels whose part is facing away.
const drawable: Array<{ point: { x: number; y: number }; $node: HTMLElement }> = [];
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
const $node = $host.querySelector( `[data-part='${ part.id }']` );
if ( ! ( $node instanceof HTMLElement ) ) {
continue;
}
if ( ! point || ! point.visible ) {
$node.style.visibility = 'hidden';
continue;
}
$node.style.visibility = '';
if ( ! this.omitTransformSync ) {
$node.style.transform = '';
}
drawable.push( { point, $node } );
}
// Second pass: measure every box in one batch, so the writes above flush layout once rather than per label.
const boxes = drawable.map( ( entry ) => entry.$node.getBoundingClientRect() );
// Third pass: draw each label at its projected center and write the alignment matrix back.
for ( const [ index, entry ] of drawable.entries() ) {
const box = boxes[ index ];
const dx = ( entry.point.x - box.width / 2 ) * dpr;
const dy = ( entry.point.y - box.height / 2 ) * dpr;
const alignment = ctx.drawElementImage( entry.$node, dx, dy );
if ( ! this.omitTransformSync ) {
entry.$node.style.transform = alignment.toString();
}
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
}
/**
* Reads a CSS custom property off the host, falling back to a literal when the token sheet has not loaded.
*
* A canvas paints raw pixels and cannot inherit a CSS color, so every themed color the faux UI draws is read here at paint time, which is what keeps the canvas-only labels legible in light and dark.
*
* @param name - The custom property to read.
* @param fallback - The literal color to use when the property is empty.
* @returns The resolved color string.
*/
private token( name: string, fallback: string ): string {
const value = getComputedStyle( this ).getPropertyValue( name ).trim();
return value || fallback;
}
/**
* Resolves the selection accent the same way the stylesheet does, reading the private `--cfg-accent` so the 3D highlight and the painted UI match the DOM controls in either theme.
*/
private accentColor(): string {
return this.token( '--cfg-accent', '#2257e6' );
}
/**
* Re-reads the themed colors into the caches the render loop relies on: the accent as a Three.js color for the 3D highlight, and the token set the flat overlay paints with.
*
* It is called on connect and after a theme change, so every per-frame draw works from cached values rather than calling `getComputedStyle`.
* The flip side of that cache is staleness: a host that swaps `--configurator-accent` at runtime without a theme flip paints the old accent until the next theme change refreshes it.
*/
private refreshAccent(): void {
const accent = this.accentColor();
this.accent.set( accent );
this.overlayColors = {
ink: this.token( '--ink', '#17171a' ),
surface: this.token( '--surface', '#ffffff' ),
accent,
accentInk: this.token( '--accent-ink', '#ffffff' ),
fontSans: this.token( '--font-sans', 'system-ui, sans-serif' ),
fontMono: this.token( '--font-mono', 'monospace' ),
};
}
/**
* Returns a color string at a given alpha, parsing a `#rgb` or `#rrggbb` token into `rgba()` and mixing any other syntax toward transparent with `color-mix()`.
*
* The canvas needs an explicit alpha for the faux panel and pill borders, but the theme tokens are usually opaque hex, so the fast path parses those by hand; anything else goes through `color-mix()`, which any browser modern enough to hand us a non-hex token can paint.
*
* @param color - The base color, usually a hex token value.
* @param alpha - The alpha to apply, from 0 to 1.
* @returns An `rgba()` string for hex input, or a `color-mix()` expression for anything else.
*/
private withAlpha( color: string, alpha: number ): string {
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec( color.trim() );
if ( ! match ) {
return `color-mix( in srgb, ${ color } ${ Math.round( alpha * 100 ) }%, transparent )`;
}
const body = match[ 1 ];
const full = body.length === 3 ? body.split( '' ).map( ( c ) => c + c ).join( '' ) : body;
const r = parseInt( full.slice( 0, 2 ), 16 );
const g = parseInt( full.slice( 2, 4 ), 16 );
const b = parseInt( full.slice( 4, 6 ), 16 );
return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`;
}
/**
* Selects a part from a real overlay label, the keyboard-reachable path the raycast cannot provide.
*
* @param id - The id of the part the label represents.
*/
private onLabelClick( id: string ): void {
this.select( id, 'overlay' );
}
/**
* Updates the selection and dispatches the typed `object-select` event, tagged with the layer it came from.
*
* The event fires on every pick, even a repeat of the current selection, so a demo log can record each raycast; the internal `selectedId` only changes when the target actually changes.
* The event is `composed` so it crosses the shadow boundary and `bubbles` so a listener on the host hears it.
*
* @param id - The id to select, or `null` to clear.
* @param source - Which layer triggered the selection, the raycast or a real overlay control.
*/
private select( id: string | null, source: ConfiguratorSelectSource ): void {
if ( id !== this.selectedId ) {
this.selectedId = id;
}
this.dispatchEvent(
new CustomEvent<ConfiguratorSelectDetail>( 'object-select', {
detail: { objectId: id, source },
bubbles: true,
composed: true,
} ),
);
}
/**
* Recolors the shade to a swatch, applying it to the live material and reporting the new config.
*
* @param swatchId - The id of the chosen swatch.
*/
private onSelectSwatch( swatchId: string ): void {
this.config = { ...this.config, shadeSwatchId: swatchId };
if ( this.lamp ) {
setShadeColor( this.lamp, this.shadeHex() );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Switches the base finish, retuning the live base material and reporting the new config.
*
* @param finish - The finish to apply, matte or metal.
*/
private onSelectFinish( finish: BaseFinish ): void {
this.config = { ...this.config, baseFinish: finish };
if ( this.lamp ) {
setBaseFinish( this.lamp, finish );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Toggles the bulb, relighting the scene through the helper and reporting the new config.
*/
private onToggleBulb(): void {
const bulbOn = ! this.config.bulbOn;
this.config = { ...this.config, bulbOn };
if ( this.lamp ) {
setBulbState( this.lamp, bulbOn );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Dispatches the typed `config-change` event with the whole current config after an inspector edit.
*
* It is `composed` and `bubbles` for the same reason the selection event is, so a host listener hears every edit without a cast.
*/
private emitConfigChange(): void {
this.dispatchEvent(
new CustomEvent<ConfiguratorConfigChangeDetail>( 'config-change', {
detail: { config: this.config },
bubbles: true,
composed: true,
} ),
);
}
/**
* Types `addEventListener` for the configurator's own events so a listener receives the typed `CustomEvent` with no cast, and falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof ProductConfiguratorEventMap>(
type: K,
listener: ( this: ProductConfigurator, ev: ProductConfiguratorEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the WebGL viewport, the flat overlay canvas, the real DOM labels or the drawn-label nodes for the active mode, and the inspector and harness panel in the side column.
*
* The WebGL canvas always renders the lamp; what changes per mode is whether real HTML labels sit over it (`dom-overlay`), real label nodes are drawn into the overlay canvas (`html-in-canvas`), or the labels and a faux inspector are painted onto the overlay canvas with no DOM (`canvas-only`).
* The stage and the side column are always laid out by the same `.layout` grid: `stacked` keeps it one column so the side falls under the canvas, and `split` makes it two so the side becomes the right column, the glass-demo shape.
*/
render() {
const active = this.activeMode;
const parts = this.parts;
return html`
<div class=${ this.layout === 'split' ? 'layout split' : 'layout' }>
<div class="main">
<slot name="header"></slot>
<div class=${ this.isDragging ? 'stage dragging' : 'stage' }>
<canvas class="viewport" role="img" aria-label="A 3D desk lamp you can orbit by dragging. Click a part to select it."></canvas>
<canvas class="overlay-canvas" aria-hidden=${ active === 'html-in-canvas' ? 'false' : 'true' } ?layoutsubtree=${ active === 'html-in-canvas' }>
${ active === 'html-in-canvas'
? parts.map( ( part ) => html`
<button
type="button"
class="drawn-label"
data-part=${ part.id }
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
` )
: nothing }
</canvas>
${ active === 'dom-overlay'
? html`
<div class="overlay">
${ parts.map( ( part ) => {
const point = this.anchorPoints[ part.id ];
const shown = point && point.visible;
return html`
<button
type="button"
class="label"
style="left: ${ point ? point.x : 0 }px; top: ${ point ? point.y : 0 }px; visibility: ${ shown ? 'visible' : 'hidden' };"
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
`;
} ) }
</div>
`
: nothing }
</div>
</div>
${ this.renderSide( active ) }
</div>
`;
}
/**
* Renders the side column of the layout, the real inspector when the mode has one stacked above the harness `panel` slot, and in `split` a full-width `footer` slot under both columns.
*
* In `split` it is always rendered so it forms the right column even in `canvas-only`, where the inspector is painted into the canvas and the slot still carries the harness panel. In `stacked` it is rendered only when there is something to show, the inspector for a mode that has one, so a bare `canvas-only` embed does not leave an empty row under the canvas.
* The `footer` row spans both columns and only renders in `split`, so a harness can drop a tall report or log there instead of stacking it in the narrow side column, where it would run far below the stage and strand the canvas column; an empty `footer` slot produces no box, so a harness that slots nothing renders exactly as before.
*
* @param active - The active mode, which decides whether a real DOM inspector exists.
* @returns The side-column template, or `nothing` when stacked with no inspector to show.
*/
private renderSide( active: ConfiguratorMode ) {
const inspector = active === 'canvas-only' ? nothing : this.renderInspector();
if ( this.layout !== 'split' && active === 'canvas-only' ) {
return nothing;
}
return html`
<div class="side">
${ inspector }
<slot name="panel"></slot>
</div>
${ this.layout === 'split'
? html`<div class="footer"><slot name="footer"></slot></div>`
: nothing }
`;
}
/**
* Renders the real DOM inspector for the selected part: the shade swatches, the base finish, and the bulb toggle, each a real, keyboard-reachable control.
*
* It shows every control whatever the selected part, since the lamp is one product, and names the selected part in the heading so the selection still reads; with nothing selected the heading falls back to the whole lamp.
*/
private renderInspector() {
const selected = this.selectedPart;
return html`
<div class="inspector">
<div class="head">
<h3>${ selected ? selected.label : 'Lamp' }</h3>
${ selected ? html`<span class="part-tag">#${ selected.id }</span>` : nothing }
</div>
<div class="field">
<span class="field-label">Shade color</span>
<div class="swatches">
${ SHADE_SWATCHES.map( ( swatch ) => html`
<button
type="button"
class="swatch"
style="background: ${ swatch.hex };"
aria-label=${ swatch.label }
aria-pressed=${ swatch.id === this.config.shadeSwatchId }
@click=${ () => this.onSelectSwatch( swatch.id ) }
></button>
` ) }
</div>
</div>
<div class="field">
<span class="field-label">Base finish</span>
<div class="segmented">
<button type="button" aria-pressed=${ this.config.baseFinish === 'matte' } @click=${ () => this.onSelectFinish( 'matte' ) }>Matte</button>
<button type="button" aria-pressed=${ this.config.baseFinish === 'metal' } @click=${ () => this.onSelectFinish( 'metal' ) }>Metal</button>
</div>
</div>
<div class="field">
<span class="field-label">Bulb</span>
<button type="button" class="switch" aria-pressed=${ this.config.bulbOn } @click=${ () => this.onToggleBulb() }>
<span class=${ this.config.bulbOn ? 'track on' : 'track' }><span class="knob"></span></span>
<span>${ this.config.bulbOn ? 'On' : 'Off' }</span>
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'product-configurator': ProductConfigurator;
}
}The one import left is lamp.ts, the buildLamp geometry helper index.ts pulls in, folded here so the three files together are the complete component.
lamp.ts
import * as THREE from 'three';
import type { BaseFinish, ConfiguratorTheme, LampPartId } from './types';
/**
* The studio colors and light levels that swap with the page theme, one object per theme so the light and dark studios read as two deliberate palettes rather than scattered literals.
*
* The fields are hex integers and intensities because Three.js materials and lights take numeric colors, and they are kept here, beside the geometry they light, so the component body stays on the boundary work rather than Three.js plumbing.
*/
export interface StudioPalette {
/**
* The scene background and far-field clear color behind the lamp.
*/
background: number;
/**
* The studio floor color, the surface the lamp's base and contact shadow sit on.
*/
floor: number;
/**
* The metal color shared by the arm and the base, before the base finish retunes its reflectance.
*/
metal: number;
/**
* The ambient light intensity, the flat base fill that lifts the whole lamp.
*/
ambient: number;
/**
* The key light intensity, the main directional light that shapes the lamp.
*/
key: number;
/**
* The rim light intensity, the cool back light that separates the lamp from the background.
*/
rim: number;
}
/**
* The two studio palettes, keyed by page theme, read once when the scene is built and again on a runtime theme change.
*/
export const STUDIO_PALETTES: Record<ConfiguratorTheme, StudioPalette> = {
light: {
background: 0xe9eaee,
floor: 0xdedfe4,
metal: 0x9a9ca6,
ambient: 1.5,
key: 2.1,
rim: 1.2,
},
dark: {
background: 0x191a20,
floor: 0x212229,
metal: 0x6f7280,
ambient: 0.85,
key: 1.5,
rim: 1.0,
},
};
/**
* The reflectance pairs a finish maps to, so the base can switch between a soft matte and a polished metal by retuning its material rather than rebuilding the mesh.
*/
export const BASE_FINISHES: Record<BaseFinish, { roughness: number; metalness: number }> = {
matte: { roughness: 0.72, metalness: 0.15 },
metal: { roughness: 0.18, metalness: 0.95 },
};
/**
* The emissive intensity of the bulb mesh when the bulb is switched on, pushed well past one so the glass reads as a hot filament rather than a tinted ball, and dropped to zero when off.
*
* It is high on purpose: with ACES tone mapping a value near one barely lifts, so toggling the bulb has to swing the emissive hard to be an unmistakable change in both themes, and at this strength the lit glass reads as a far hotter filament.
*/
const BULB_EMISSIVE_ON = 4.2;
/**
* The intensity of the in-shade point light when the bulb is switched on, the light that actually relights the scene.
*
* It is lifted alongside the emissive so the on state visibly throws warm light onto the shade and arm, not just brightens the bulb itself, and at this level the on state floods the studio rather than nudging it.
*/
const BULB_LIGHT_ON = 5.7;
/**
* The peak opacity of the painted halo sprite around the bulb when it is lit, the soft bloom-like glow that sells the on state past what the emissive alone can show on a low-dynamic-range display, raised so the halo reads brighter alongside the hotter bulb.
*/
const BULB_GLOW_OPACITY_ON = 0.95;
/**
* The world-space diameter of the painted halo sprite, a touch wider than the bulb so the glow reads as light spilling off the glass rather than a second sphere.
*/
const BULB_GLOW_SIZE = 0.9;
/**
* The handle the component holds onto a built lamp: the group to add to the scene, the four selectable part meshes, and the materials and light the inspector retunes.
*
* The component keeps this rather than reaching into the scene graph, so the geometry stays in this helper and the component body works against named parts: it raycasts the {@link LampHandle.parts} meshes, projects the {@link LampHandle.anchors} for the overlay labels, and recolors or relights through the kept materials and light.
*/
export interface LampHandle {
/**
* The root group holding every lamp mesh, added to the scene as one unit and rotated by the orbit drag.
*/
group: THREE.Group;
/**
* The four primary part meshes keyed by id, the ones the selection highlight retunes.
*
* The raycast does not read this map; it intersects the whole group and resolves the `userData.partId` stamped on every visible mesh, so the cosmetic pieces select their parent part too.
*/
parts: Record<LampPartId, THREE.Mesh>;
/**
* The local-space anchor point for each part's overlay label, in the group's own coordinates.
*
* The component transforms each anchor into world space through the group's matrix and then projects it with the camera, so the labels stay glued to the parts as the lamp orbits.
*/
anchors: Record<LampPartId, THREE.Vector3>;
/**
* The shade material, kept so the inspector can recolor it from a swatch.
*/
shadeMaterial: THREE.MeshStandardMaterial;
/**
* The base material, kept so the inspector can switch its finish between matte and metal.
*/
baseMaterial: THREE.MeshStandardMaterial;
/**
* The shared arm metal, used by the arm, joint, and neck, kept so a runtime theme change can recolor it alongside the base.
*/
armMaterial: THREE.MeshStandardMaterial;
/**
* The bulb material, kept so its emissive glow can be turned on or off with the bulb.
*/
bulbMaterial: THREE.MeshStandardMaterial;
/**
* The point light inside the shade, kept so toggling the bulb actually relights the scene rather than only changing the bulb's own color.
*/
bulbLight: THREE.PointLight;
/**
* The camera-facing halo sprite around the bulb, kept so toggling the bulb can fade its painted bloom in and out with the emissive.
*
* It is a flat additive billboard rather than real volumetric light: a cheap, tasteful way to read the lit bulb as glowing on a low-dynamic-range display where a high emissive alone clips to a flat disc.
*/
bulbGlow: THREE.Sprite;
}
/**
* Builds the soft radial-gradient texture the bulb halo sprite is painted with, a warm core fading to transparent so the additive sprite reads as a bloom rather than a hard disc.
*
* It is drawn into a small offscreen canvas once per lamp; the sprite stretches it, so a modest resolution stays smooth.
*
* @returns The radial-gradient texture for the glow sprite.
*/
function buildGlowTexture(): THREE.Texture {
const size = 128;
const $canvas = document.createElement( 'canvas' );
$canvas.width = size;
$canvas.height = size;
const ctx = $canvas.getContext( '2d' );
if ( ctx ) {
const half = size / 2;
const gradient = ctx.createRadialGradient( half, half, 0, half, half, half );
gradient.addColorStop( 0, 'rgba( 255, 244, 214, 1 )' );
gradient.addColorStop( 0.4, 'rgba( 255, 224, 150, 0.65 )' );
gradient.addColorStop( 1, 'rgba( 255, 214, 130, 0 )' );
ctx.fillStyle = gradient;
ctx.fillRect( 0, 0, size, size );
}
const texture = new THREE.CanvasTexture( $canvas );
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
/**
* Builds the procedural desk lamp from Three.js primitives and returns the handle the component drives it through.
*
* The lamp is four named, separately selectable meshes, a tapered shade, a slim arm, a glass bulb, and a weighted base, arranged into a small desk lamp and centered on the studio floor so it frames cleanly at the demo widths.
* Keeping the whole build here is deliberate: the article reshows this as the geometry slice while the component body stays on the projection, the raycast, the feature detection, and the transform sync, so the lesson never turns into a Three.js tutorial.
* Every visible mesh is stamped with the `userData.partId` of the part it belongs to, cosmetic pieces included, so the component's ancestry walk resolves a raycast hit anywhere on the lamp to the part a user believes they clicked; the glow sprite alone stays unstamped so it never intercepts a pick.
*
* @param palette - The studio palette for the current theme, used for the shared metal tone the arm and base start from.
* @param shadeHex - The initial shade color as a `#rrggbb` string, the first swatch.
* @param finish - The initial base finish.
* @param bulbOn - Whether the bulb starts lit.
* @returns The lamp handle: the group, the part meshes, the label anchors, the retunable materials, and the in-shade light.
*/
export function buildLamp( palette: StudioPalette, shadeHex: string, finish: BaseFinish, bulbOn: boolean ): LampHandle {
const group = new THREE.Group();
const shadeMaterial = new THREE.MeshStandardMaterial( {
color: new THREE.Color( shadeHex ),
roughness: 0.45,
metalness: 0.1,
side: THREE.DoubleSide,
} );
const shade = new THREE.Mesh( new THREE.CylinderGeometry( 0.62, 0.92, 0.7, 48, 1, true ), shadeMaterial );
shade.position.set( 0, 1.62, 0 );
shade.rotation.z = 0.12;
shade.userData.partId = 'shade';
group.add( shade );
// A thin disc closes the top of the open shade cylinder so it does not read as a hollow tube from above, sharing the shade material so a recolor reaches it too.
const shadeCap = new THREE.Mesh( new THREE.CircleGeometry( 0.62, 48 ), shadeMaterial );
shadeCap.position.set( 0, 0.35, 0 );
shadeCap.rotation.x = -Math.PI / 2;
shadeCap.userData.partId = 'shade';
shade.add( shadeCap );
const finishValues = BASE_FINISHES[ finish ];
const armMaterial = new THREE.MeshStandardMaterial( { color: palette.metal, roughness: 0.3, metalness: 0.85 } );
const arm = new THREE.Mesh( new THREE.CylinderGeometry( 0.05, 0.05, 1.5, 20 ), armMaterial );
arm.position.set( 0, 0.92, 0 );
arm.rotation.z = 0.12;
arm.userData.partId = 'arm';
group.add( arm );
// A small elbow joint where the arm meets the shade, so the join reads as a real lamp rather than a stick poked into a cone.
const joint = new THREE.Mesh( new THREE.SphereGeometry( 0.1, 20, 16 ), armMaterial );
joint.position.set( -0.18, 1.6, 0 );
joint.userData.partId = 'arm';
group.add( joint );
const baseMaterial = new THREE.MeshStandardMaterial( {
color: palette.metal,
roughness: finishValues.roughness,
metalness: finishValues.metalness,
} );
const base = new THREE.Mesh( new THREE.CylinderGeometry( 0.55, 0.62, 0.16, 48 ), baseMaterial );
base.position.set( 0.12, 0.08, 0 );
base.userData.partId = 'base';
group.add( base );
// A short neck links the base to the arm so the silhouette is continuous; it sits right on the base puck, so it is stamped as base, the part a user reads it as.
const neck = new THREE.Mesh( new THREE.CylinderGeometry( 0.08, 0.12, 0.22, 24 ), armMaterial );
neck.position.set( 0.12, 0.24, 0 );
neck.userData.partId = 'base';
group.add( neck );
const bulbMaterial = new THREE.MeshStandardMaterial( {
color: 0xfff4d6,
emissive: new THREE.Color( 0xffd479 ),
emissiveIntensity: bulbOn ? BULB_EMISSIVE_ON : 0,
roughness: 0.25,
metalness: 0,
} );
const bulb = new THREE.Mesh( new THREE.SphereGeometry( 0.16, 24, 20 ), bulbMaterial );
bulb.position.set( 0, 1.5, 0 );
bulb.userData.partId = 'bulb';
group.add( bulb );
// A flat additive billboard around the bulb that paints the bloom the emissive cannot show once it clips, faded in and out with the bulb so the on state glows unmistakably.
// It is deliberately left without a partId, since the halo is wider than the bulb and an unstamped hit falls through to the next object behind it, so the glow never eats a pick.
const bulbGlowMaterial = new THREE.SpriteMaterial( {
map: buildGlowTexture(),
color: 0xffffff,
transparent: true,
opacity: bulbOn ? BULB_GLOW_OPACITY_ON : 0,
blending: THREE.AdditiveBlending,
depthWrite: false,
} );
const bulbGlow = new THREE.Sprite( bulbGlowMaterial );
bulbGlow.position.set( 0, 1.5, 0 );
bulbGlow.scale.set( BULB_GLOW_SIZE, BULB_GLOW_SIZE, 1 );
group.add( bulbGlow );
const bulbLight = new THREE.PointLight( 0xffe6b0, bulbOn ? BULB_LIGHT_ON : 0, 6, 2 );
bulbLight.position.set( 0, 1.45, 0 );
group.add( bulbLight );
// Recenter the assembled lamp on the floor so the orbit pivot sits in the middle of the model and it stays framed as it turns.
group.position.set( -0.06, -0.95, 0 );
return {
group,
parts: { shade, arm, bulb, base },
anchors: {
shade: new THREE.Vector3( 0.1, 2.0, 0 ),
arm: new THREE.Vector3( 0.36, 0.95, 0 ),
bulb: new THREE.Vector3( 0, 1.5, 0.18 ),
base: new THREE.Vector3( 0.12, 0.18, 0 ),
},
shadeMaterial,
baseMaterial,
armMaterial,
bulbMaterial,
bulbLight,
bulbGlow,
};
}
/**
* Switches the bulb on or off, lifting or dropping both the bulb's emissive glow and the in-shade light so the whole scene relights, not just the bulb.
*
* @param lamp - The lamp handle to relight.
* @param on - Whether the bulb should be lit.
*/
export function setBulbState( lamp: LampHandle, on: boolean ): void {
lamp.bulbMaterial.emissiveIntensity = on ? BULB_EMISSIVE_ON : 0;
lamp.bulbLight.intensity = on ? BULB_LIGHT_ON : 0;
lamp.bulbGlow.material.opacity = on ? BULB_GLOW_OPACITY_ON : 0;
}
/**
* Applies a base finish to the base material by retuning its roughness and metalness in place.
*
* @param lamp - The lamp handle whose base to refinish.
* @param finish - The finish to apply, matte or metal.
*/
export function setBaseFinish( lamp: LampHandle, finish: BaseFinish ): void {
const values = BASE_FINISHES[ finish ];
lamp.baseMaterial.roughness = values.roughness;
lamp.baseMaterial.metalness = values.metalness;
}
/**
* Recolors the shade material to a hex string in place, so a swatch change is a material edit rather than a rebuild.
*
* @param lamp - The lamp handle whose shade to recolor.
* @param hex - The new shade color as a `#rrggbb` string.
*/
export function setShadeColor( lamp: LampHandle, hex: string ): void {
lamp.shadeMaterial.color.set( hex );
}
/**
* Disposes every geometry and material the lamp owns, called on teardown so a removed configurator leaks no GPU resources.
*
* It walks the group rather than the kept handles so meshes built only for looks, the shade cap, the joint, and the neck, are freed too, and the bulb halo sprite's material and its painted glow texture are freed alongside them since a sprite is not a mesh.
*
* @param lamp - The lamp handle to dispose.
*/
export function disposeLamp( lamp: LampHandle ): void {
lamp.group.traverse( ( object ) => {
if ( object instanceof THREE.Mesh ) {
object.geometry.dispose();
const material = object.material;
if ( Array.isArray( material ) ) {
for ( const entry of material ) {
entry.dispose();
}
} else {
material.dispose();
}
}
} );
lamp.bulbGlow.material.map?.dispose();
lamp.bulbGlow.material.dispose();
}The final review demo runs the same lamp through all three ownership models, one tab at a time, over a shared set of parts and a shared selection. Pick a mode, drag to orbit, click a part, Tab through the controls, open the inspector, and try to select the part name in it, then switch tabs, and the picked part stays lit because the selection is shared. Each tab carries a four-row capability strip, Keyboard, Selectable text, Inspectable, and Find in page, and a color-coded verdict chip reading Avoid, Ship, or Enhance behind a flag, so each mode reads on its own. The experimental tab reports whether the API was detected on your browser and degrades to the overlay where it’s missing.
import { LitElement, html, css, nothing } from 'lit';
import { supportsHtmlInCanvas } from '@demos/supports-html-in-canvas';
import { customElement, property, state, query } from 'lit/decorators.js';
import * as THREE from 'three';
import { buildLamp, disposeLamp, setBaseFinish, setBulbState, setShadeColor, STUDIO_PALETTES } from './lamp';
import type { LampHandle } from './lamp';
import type {
BaseFinish,
ConfiguratorConfigChangeDetail,
ConfiguratorLayout,
ConfiguratorMode,
ConfiguratorSelectDetail,
ConfiguratorSelectSource,
ConfiguratorTheme,
LampConfig,
LampPartId,
ProductConfiguratorEventMap,
SceneObject,
ShadeSwatch,
} from './types';
declare global {
interface CanvasRenderingContext2D {
/**
* Draws a laid-out DOM child of the canvas into the bitmap and returns the alignment transform.
*
* Experimental, part of the Chromium-only HTML-in-Canvas origin trial, so it is absent from `lib.dom.d.ts` and declared here.
* Writing the returned matrix into the element's `style.transform` is what keeps focus, hit testing, and accessibility geometry on the drawn pixels.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface OffscreenCanvasRenderingContext2D {
/**
* The offscreen mirror of {@link CanvasRenderingContext2D.drawElementImage}, declared for completeness so a worker path type-checks too.
*/
drawElementImage( element: Element, dx: number, dy: number, dwidth?: number, dheight?: number ): DOMMatrix;
}
interface HTMLCanvasElement {
/**
* Opts the canvas in to the experimental model, so its direct children lay out and become drawable and hit-testable.
*
* Reflects the `layoutsubtree` HTML attribute, and is absent from `lib.dom.d.ts` because the feature is still flag-gated.
*/
layoutSubtree: boolean;
/**
* Forces a `paint` event on the next frame, the way `requestAnimationFrame` schedules a draw.
*/
requestPaint(): void;
}
}
/**
* The swatches the shade can be recolored to, a small neutral-to-bold range so the recolor reads in both themes.
*
* It is a module constant rather than a property because the swatch set is part of the lamp's design, not data a harness owns, and the first entry is the default the lamp opens on.
*/
const SHADE_SWATCHES: ReadonlyArray<ShadeSwatch> = [
{ id: 'porcelain', label: 'Porcelain', hex: '#e9e7e0' },
{ id: 'cobalt', label: 'Cobalt', hex: '#2257e6' },
{ id: 'moss', label: 'Moss', hex: '#3f7d5a' },
{ id: 'clay', label: 'Clay', hex: '#c2683f' },
];
/**
* The aspect ratio of the canvas, its height as a fraction of its width, so the stage reserves the right height before WebGL sizes itself.
*/
const CANVAS_ASPECT = 0.62;
/**
* The labelled parts the lamp draws, in the order the overlay stacks them, so the four ids the geometry and inspector know about have a single declared source.
*/
const PART_IDS: ReadonlyArray<LampPartId> = [ 'shade', 'arm', 'bulb', 'base' ];
/**
* A shared black color reused when a part's selection highlight is cleared, so the highlight reset allocates nothing per frame.
*/
const BLACK = new THREE.Color( 0x000000 );
/**
* A real-time 3D product configurator: a procedural desk lamp in a WebGL canvas, with a raycast selection, an orbit drag, and an inspector, that moves its UI between three ownership models.
*
* The lamp is graphics the DOM cannot draw, owned by the canvas, and the labels and inspector are interface, owned by the DOM, so `mode` decides which layer carries them.
* `canvas-only` paints the labels and a faux inspector onto a flat overlay canvas with no focusable DOM, the trap; `dom-overlay` is the version that ships, projecting each part's 3D anchor to a real HTML label kept glued as the lamp orbits, with the real inspector beside it; `html-in-canvas` feature-detects the experimental `drawElementImage` path, draws the real label nodes into the flat overlay canvas, and writes the returned alignment transform back, falling back to `dom-overlay` when the API is absent.
* Selection is a raycast from the camera, the 3D replacement for the old 2D hit test, and the inspector recolors the shade, switches the base finish, and toggles the bulb, which relights the scene.
*
* @element product-configurator
* @slot header - Sits above the stage in the left column, for a harness caption over the canvas.
* @slot panel - Stacks under the real inspector in the right column of the `split` layout, for a harness control panel beside the canvas.
* @slot footer - Spans both columns under the stage in the `split` layout, for a tall report or log that would otherwise strand the canvas column if stacked in the narrow side column.
* @fires object-select - Fired when the selected part changes, carrying a `ConfiguratorSelectDetail`.
* @fires config-change - Fired after an inspector edit, carrying the whole `LampConfig`.
* @cssprop [--configurator-accent=#2257e6] - The selection and focus color used by the labels and the inspector.
*/
@customElement( 'product-configurator' )
export class ProductConfigurator extends LitElement {
/**
* The selectable parts, the four named meshes of the lamp.
*
* It is `attribute: false` because an array binds as a property (`.objects=${parts}`) and never round-trips through a string attribute.
* It has no default, since inventing the parts inside the component would hide the contract that the parent names the scene; the component builds a mesh for each known id and skips any other.
*/
@property( { attribute: false } )
objects: SceneObject[] = [];
/**
* Which ownership model the configurator renders.
*
* `html-in-canvas` is a request, not a guarantee: when the experimental API is absent the component renders `dom-overlay` instead, so the selected mode and the active mode can differ.
*/
@property( { type: String } )
mode: ConfiguratorMode = 'dom-overlay';
/**
* How the canvas and the inspector are arranged.
*
* Defaults to `stacked` so a standalone embed keeps the inspector under the canvas; a harness sets `split` to get the glass-demo shape, the canvas in a left column and the inspector plus a `panel` slot in one right column, collapsing back to a single stacked column on a narrow width.
*/
@property( { type: String } )
layout: ConfiguratorLayout = 'stacked';
/**
* Whether to deliberately skip writing the alignment transform back to the drawn label.
*
* Teaching switch for the experimental path: with the sync omitted, the focus ring and click target drift off the drawn pixels, which is the failure the article makes visible.
* It does nothing outside `html-in-canvas` mode on a supporting build.
*/
@property( { type: Boolean, attribute: 'omit-transform-sync' } )
omitTransformSync = false;
/**
* The id of the currently selected part, or `null` when nothing is selected.
*
* It is a controllable property: leave it unset and the component manages selection itself from a raycast or a keyboard pick, or bind it (`.selectedId=${id}`) to drive several configurators from one shared selection, the way the review demo syncs three modes.
* Either way consumers also hear changes through the `object-select` event, and the component writes the property on every pick so the uncontrolled case stays in sync.
*/
@property( { attribute: 'selected-id' } )
selectedId: string | null = null;
/**
* The viewport canvas Three.js renders the lamp into, resolved by Lit after the first render.
*/
@query( '.viewport' )
private $viewport!: HTMLCanvasElement;
/**
* The flat 2D overlay canvas stacked over the WebGL canvas, used to paint the faux UI in `canvas-only` and to host the drawn labels in `html-in-canvas`.
*/
@query( '.overlay-canvas' )
private $overlayCanvas?: HTMLCanvasElement;
/**
* The selected page theme, resolved on connect and kept in sync at runtime, so the studio is lit to match the page in light and dark.
*
* It is `@state` so a runtime theme flip re-renders, and the scene recolors through {@link applyTheme} rather than a rebuild.
*/
@state()
private theme: ConfiguratorTheme = 'light';
/**
* The lamp's configurable state, the shade swatch, the base finish, and the bulb, edited by the inspector.
*
* It is `@state` so an edit re-renders the inspector and re-runs the overlay draw, and it opens on the first swatch with a matte base and a lit bulb so the lamp reads as switched on.
*/
@state()
private config: LampConfig = {
shadeSwatchId: SHADE_SWATCHES[ 0 ].id,
baseFinish: 'matte',
bulbOn: true,
};
/**
* Whether the live 2D context exposes the experimental `drawElementImage` method.
*
* Resolved on connect from a scratch canvas, so it gates the experimental path per render rather than trusting the requested `mode`.
* It is `@state` so flipping it triggers the re-render that mounts the drawn label nodes, since `activeMode` reads it during `render`.
*/
@state()
private supportsHtmlInCanvas = false;
/**
* The Three.js scene, camera, and renderer, created once the viewport exists.
*/
private scene: THREE.Scene | null = null;
private camera: THREE.PerspectiveCamera | null = null;
private renderer: THREE.WebGLRenderer | null = null;
/**
* The built lamp and the handles to its parts, materials, and light, or `null` before the scene is built.
*/
private lamp: LampHandle | null = null;
/**
* The studio floor mesh, kept so its geometry and material can be disposed on teardown.
*/
private floorMesh: THREE.Mesh | null = null;
/**
* The studio floor material, kept so a theme change can recolor it in place.
*/
private floorMaterial: THREE.MeshStandardMaterial | null = null;
/**
* The themed studio lights, kept so a theme change can dim or lift them to match the palette.
*/
private themedLights: {
ambient: THREE.AmbientLight | null;
key: THREE.DirectionalLight | null;
rim: THREE.DirectionalLight | null;
} = { ambient: null, key: null, rim: null };
/**
* The reusable raycaster for the part-selection pick, allocated once.
*/
private readonly raycaster = new THREE.Raycaster();
/**
* A reusable vector for projecting a part anchor, so the overlay sync allocates nothing per frame.
*/
private readonly projected = new THREE.Vector3();
/**
* The overlay canvas 2D context, cached after the first paint so the per-frame draw does not re-acquire it.
*/
private overlayCtx: CanvasRenderingContext2D | null = null;
/**
* The CSS-pixel size the overlay backing store was last sized for, so it is only resized when the box actually changes rather than every frame.
*/
private overlaySize = { width: 0, height: 0, dpr: 0 };
/**
* The resolved selection accent as a Three.js color, cached so the per-frame highlight does not call `getComputedStyle`, and refreshed on a theme change.
*/
private readonly accent = new THREE.Color( 0x2257e6 );
/**
* The themed colors the flat overlay paints with, cached and refreshed on a theme change so the canvas-only and drawn-label paints do not read the DOM every frame.
*
* The canvas cannot inherit a CSS color, so these mirror the tokens the rest of the page uses, resolved once per theme rather than once per frame.
*/
private overlayColors = {
ink: '#17171a',
surface: '#ffffff',
accent: '#2257e6',
accentInk: '#ffffff',
fontSans: 'system-ui, sans-serif',
fontMono: 'monospace',
};
/**
* The current screen position of each part's overlay label, in CSS pixels, recomputed on every rendered frame while the lamp is on screen.
*
* It is the bridge from the 3D scene to the 2D layers: the DOM-overlay labels read it for their `left` and `top`, and the canvas-only and drawn-label paints read it to place text, so all three layers stay glued to the orbiting model from one projection.
*/
@state()
private anchorPoints: Record<string, { x: number; y: number; visible: boolean }> = {};
/**
* The orbit yaw and pitch in radians, driven by the drag and applied to the lamp group each frame.
*
* The lamp rotates rather than the camera, so the studio lighting stays fixed and the part the reader turns toward stays lit; the pitch is bounded so the lamp never tips past a readable angle.
*/
private orbit = { yaw: 0.5, pitch: 0.05 };
/**
* Whether a drag-to-orbit gesture is in progress, and the last pointer position it was sampled at.
*/
private isDragging = false;
/**
* The id of the pointer that started the current gesture, so a second finger or pen mid-drag cannot steer, end, or cancel a gesture it does not own.
*
* `isPrimary` is not enough as a filter, since touch and pen each get their own primary pointer; the id is the one key that names this exact contact.
*/
private activePointerId: number | null = null;
private lastPointer = { x: 0, y: 0 };
/**
* Whether the pointer moved past a small threshold during the current gesture, so a press-and-release in place still counts as a select rather than being eaten as a drag.
*/
private dragMoved = false;
/**
* Whether the viewport is currently on screen, so the render loop only runs while the demo is visible.
*/
private isVisible = true;
/**
* Whether {@link setUp} has wired the scene, listeners, and observers, so its two entry points, the first update and a reconnect, can never double-wire them.
*/
private isSetUp = false;
/**
* The media query that fires when `devicePixelRatio` changes, so the renderer and the overlay can be re-corrected on a zoom or a move to a different-density display.
*
* It is recreated on each change because the query is pinned to a single resolution, which is the lifecycle the canvas makes the author own.
*/
private dprQuery: MediaQueryList | null = null;
/**
* The media query the studio watches so it follows the OS color scheme when the page has not pinned a theme, retained so its listener tears down on disconnect.
*/
private themeMedia: MediaQueryList | null = null;
/**
* The observer watching `data-theme` on the document element, so an in-page theme toggle re-lights the studio at runtime, retained so it disconnects cleanly.
*/
private themeObserver: MutationObserver | null = null;
/**
* The resize and intersection observers, tracked so they tear down cleanly on disconnect.
*/
private resizeObserver: ResizeObserver | null = null;
private intersectionObserver: IntersectionObserver | null = null;
/**
* The animation-frame handle, cleared on disconnect or when the demo scrolls offscreen so a hidden lamp stops its loop.
*/
private frame = 0;
/**
* Whether the next animation frame has work to do.
*
* Nothing in the scene self-animates, so the loop keeps its frame cadence but skips the render and the overlay sync until an orbit, a selection, a config edit, a theme flip, or a resize marks the scene dirty through {@link requestRender}; label-only rendering changes in the experimental mode, a focus ring appearing on a drawn label, arrive through the canvas `paint` event and never need the flag.
*/
private needsRender = false;
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
:host {
display: block;
/* The host is a size container, so the split layout collapses on the embed's own width rather than the viewport's. */
container-type: inline-size;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
/* Resolve the selection accent into a private name; reading --configurator-accent first, then the themed --accent, then the brand, avoids the self-reference cycle that redefining --accent here would cause when a host sets --configurator-accent to the page accent. */
--cfg-accent: var( --configurator-accent, var( --accent, #2257e6 ) );
}
/* The stacked default keeps the canvas and inspector in one column; the split layout puts the canvas on the left and the inspector plus the harness panel in one right column, the glass-demo shape. */
.layout {
display: grid;
gap: var( --space-4, 16px );
}
.layout.split {
grid-template-columns: minmax( 0, 1fr ) 17rem;
grid-template-areas:
'main side'
'footer footer';
align-items: start;
}
.layout.split .main {
grid-area: main;
}
.layout.split .side {
grid-area: side;
}
/* The tall trailing content spans both columns under the stage, so a long report no longer strands the canvas column. */
.layout.split .footer {
grid-area: footer;
}
/* The left column: anything slotted into the header sits above the stage, and the stage carries the canvas. */
.main {
display: flex;
flex-direction: column;
gap: var( --space-3, 12px );
min-width: 0;
}
/* The header slot collapses to nothing when the harness slots no caption, so a bare embed shows just the stage. */
slot[name='header']::slotted( * ) {
margin: 0;
}
/* The right column of the split layout: the real inspector stacked above whatever the harness slots in, so every control reads as one column beside the canvas. */
.side {
display: flex;
flex-direction: column;
gap: var( --space-4, 16px );
min-width: 0;
}
.layout.split .inspector {
margin-top: 0;
}
/* Collapse to a single stacked column when the embed itself is too narrow for a comfortable two-column split. */
@container ( width <= 38rem ) {
.layout.split {
grid-template-columns: minmax( 0, 1fr );
grid-template-areas:
'main'
'side'
'footer';
}
}
.stage {
position: relative;
width: 100%;
aspect-ratio: 100 / 62;
border-radius: var( --radius-md, 11px );
border: 1px solid var( --line, #e7e6e2 );
overflow: hidden;
background: var( --surface, #fff );
}
canvas.viewport {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.stage.dragging canvas.viewport {
cursor: grabbing;
}
/* The flat overlay canvas sits exactly over the WebGL canvas in CSS-pixel space, the painted faux UI in canvas-only and the drawn-label host in html-in-canvas. */
canvas.overlay-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* The DOM-overlay layer sits over the canvas in CSS-pixel space, the bookkeeping the experimental API folds into the platform. */
.overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.overlay .label {
position: absolute;
transform: translate( -50%, -50% );
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
.overlay .label:hover {
border-color: var( --cfg-accent );
}
.overlay .label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
.overlay .label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
/*
* The drawn labels are real children of the overlay canvas in the experimental path: laid out and hit-testable, never display:none, anchored at the canvas origin with style.transform reserved for the platform alignment matrix that places and keeps them on the drawn pixels.
* Their look is kept identical to the DOM-overlay label above, the small light pill with ink text and a hairline border, so the only difference a reader can tell between the two modes is the mechanism, not the size or color of the pill.
*/
canvas.overlay-canvas .drawn-label {
position: absolute;
top: 0;
left: 0;
padding: 3px 9px;
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
background: var( --surface, #fff );
color: var( --ink, #17171a );
box-shadow: 0 1px 3px rgba( 0, 0, 0, 0.16 );
cursor: pointer;
pointer-events: auto;
}
canvas.overlay-canvas .drawn-label:focus-visible {
outline: 2px solid var( --cfg-accent );
outline-offset: 2px;
}
canvas.overlay-canvas .drawn-label[aria-pressed='true'] {
border-color: var( --cfg-accent );
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.inspector {
margin-top: var( --space-3, 12px );
padding: var( --space-4, 16px );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-md, 11px );
background: var( --surface, #fff );
}
.inspector .head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var( --space-3, 12px );
margin-bottom: var( --space-3, 12px );
}
.inspector h3 {
margin: 0;
font-size: 14px;
color: var( --ink, #17171a );
}
.inspector .part-tag {
font-family: var( --font-mono, monospace );
font-size: 11px;
color: var( --ink-faint, #71717b );
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var( --space-3, 12px );
}
.field:last-child {
margin-bottom: 0;
}
.field > .field-label {
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var( --ink-faint, #71717b );
}
.swatches {
display: flex;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
border: 2px solid var( --line, #e7e6e2 );
cursor: pointer;
}
.swatch[aria-pressed='true'] {
border-color: var( --cfg-accent );
box-shadow: 0 0 0 2px var( --surface, #fff ), 0 0 0 4px var( --cfg-accent );
}
.segmented {
display: inline-flex;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-sm, 7px );
overflow: hidden;
width: max-content;
}
.segmented button {
padding: 7px 14px;
font: inherit;
font-size: 13px;
border: 0;
background: var( --bg, #fcfcfb );
color: var( --ink, #17171a );
cursor: pointer;
}
.segmented button + button {
border-left: 1px solid var( --line, #e7e6e2 );
}
.segmented button[aria-pressed='true'] {
background: var( --cfg-accent );
color: var( --accent-ink, #fff );
}
.switch {
display: inline-flex;
align-items: center;
gap: 10px;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: 13px;
color: var( --ink, #17171a );
cursor: pointer;
}
.switch .track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: var( --line, #e7e6e2 );
transition: background 0.2s ease;
flex: none;
}
.switch .track.on {
background: var( --cfg-accent );
}
.switch .knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s ease;
}
.switch .track.on .knob {
transform: translateX( 16px );
}
`;
/**
* Resolves the page theme and the experimental-API support before the first render, so the studio opens in the page's palette and the first render already knows the active mode.
*
* On a reconnect it also re-runs {@link setUp}: the shadow DOM survives a re-parenting, so `hasUpdated` stays true, `firstUpdated` never fires again, and the wiring {@link tearDown} removed has to come back here, with the `@query` fields still resolving into the kept shadow root.
*/
connectedCallback(): void {
super.connectedCallback();
this.theme = this.detectTheme();
this.resolveSupport();
if ( this.hasUpdated ) {
this.setUp();
}
}
/**
* Runs the shared {@link setUp} once the viewport canvas exists after the first render.
*/
protected firstUpdated(): void {
this.setUp();
}
/**
* Builds the scene, wires the controls, observers, and theme listeners, and kicks the loop.
*
* It is shared by `firstUpdated` and a reconnect, because `firstUpdated` only ever fires once: without `connectedCallback` re-running this, a configurator moved to a new parent would come back with everything {@link tearDown} released still gone.
* The `isSetUp` guard keeps the two entry points from wiring the scene twice.
* It also refuses to wire a detached element: an append and remove in the same task still runs the pending first update after disconnection, and window listeners wired there would never hear the disconnect that already happened.
*/
private setUp(): void {
if ( this.isSetUp || ! this.isConnected ) {
return;
}
this.isSetUp = true;
this.refreshAccent();
this.initScene();
this.applySelectionHighlight();
this.bindEvents();
this.bindThemeListeners();
this.observeDevicePixelRatio();
this.startLoop();
}
/**
* Re-runs the overlay paint whenever a property that changes the flat layers updates, so a mode switch, a selection, or a config edit repaints the canvas-only UI or the drawn labels.
*
* A selection, mode, or objects change also marks the scene dirty through {@link requestRender}, so the gated loop draws the change on its next frame.
* The 3D scene redraws itself in the loop, so this only touches the flat overlay, and only when the loop is paused does it need to force one; the guarded call keeps a visible, animating demo from double-painting.
* The `anchorPoints` guard is what keeps a paused offscreen demo from looping: {@link syncOverlay} writes the `anchorPoints` `@state`, which schedules another update, so re-running `syncOverlay` on that follow-up would project, write `anchorPoints` again, and never terminate; skipping the call when the only change is `anchorPoints` lets a real change like a theme, selection, mode, or config edit sync the overlay exactly once and then settle.
*/
protected updated( changed: Map<PropertyKey, unknown> ): void {
if ( changed.has( 'selectedId' ) ) {
this.applySelectionHighlight();
this.requestRender();
}
if ( changed.has( 'mode' ) || changed.has( 'objects' ) ) {
this.requestRender();
}
if ( this.frame === 0 && ! changed.has( 'anchorPoints' ) ) {
this.syncOverlay();
}
}
/**
* Tears the configurator down through the shared {@link tearDown} when it leaves the document.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.tearDown();
}
/**
* Stops the loop and tears down every observer, listener, and the WebGL context so a removed configurator leaks nothing.
*
* It is the mirror of {@link setUp} rather than the body of `disconnectedCallback`, so a reconnected configurator can rebuild exactly what this released; clearing `isSetUp` is what re-arms that rebuild.
*/
private tearDown(): void {
this.isSetUp = false;
this.stopLoop();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.intersectionObserver?.disconnect();
this.intersectionObserver = null;
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
this.dprQuery = null;
this.themeMedia?.removeEventListener( 'change', this.onThemeMediaChange );
this.themeMedia = null;
this.themeObserver?.disconnect();
this.themeObserver = null;
this.$viewport?.removeEventListener( 'pointerdown', this.onPointerDown );
window.removeEventListener( 'pointermove', this.onPointerMove );
window.removeEventListener( 'pointerup', this.onPointerUp );
window.removeEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.removeEventListener( 'paint', this.onOverlayPaint );
if ( this.lamp ) {
disposeLamp( this.lamp );
this.lamp = null;
}
this.floorMesh?.geometry.dispose();
this.floorMesh = null;
this.floorMaterial?.dispose();
this.floorMaterial = null;
this.renderer?.dispose();
this.renderer = null;
// The window listeners that would release a live gesture are gone now, so the flags reset here; a stale drag would otherwise let plain hover motion orbit the lamp after a reconnect, since a mouse keeps a stable pointerId.
this.isDragging = false;
this.dragMoved = false;
this.activePointerId = null;
}
/**
* The mode actually rendered, which is the requested mode unless `html-in-canvas` was asked for without support.
*
* This is the single place the requested-versus-active split is resolved, so the template and the overlay paint agree on which layer owns the labels.
*/
private get activeMode(): ConfiguratorMode {
if ( this.mode === 'html-in-canvas' && ! this.supportsHtmlInCanvas ) {
return 'dom-overlay';
}
return this.mode;
}
/**
* The studio palette for the current theme, the one object the scene materials and lights read.
*/
private get palette() {
return STUDIO_PALETTES[ this.theme ];
}
/**
* The four selectable parts that map onto a real lamp mesh, in the lamp's stacking order.
*
* The component only knows the four lamp ids, so it filters the bound scene to them and keeps them in a stable order, which keeps the overlay labels and the raycast list aligned with the geometry the helper built.
*/
private get parts(): SceneObject[] {
const byId = new Map( this.objects.map( ( object ) => [ object.id, object ] ) );
const ordered: SceneObject[] = [];
for ( const id of PART_IDS ) {
const object = byId.get( id );
if ( object ) {
ordered.push( object );
}
}
return ordered;
}
/**
* The selected part, or `undefined` when nothing is selected or the selection is not a known lamp part.
*/
private get selectedPart(): SceneObject | undefined {
return this.parts.find( ( part ) => part.id === this.selectedId );
}
/**
* Records whether the drawn-label path can run, through the shared {@link supportsHtmlInCanvas} predicate that feature-detects the full path, `drawElementImage` on a live 2D context and `requestPaint` on the canvas, rather than sniffing a UA string.
*
* It runs from `connectedCallback`, since the probe needs only `document`, so the first render already knows the answer instead of flipping the mode an update cycle later.
*/
private resolveSupport(): void {
this.supportsHtmlInCanvas = supportsHtmlInCanvas();
}
/**
* Resolves the page theme the same way the demo page decides it: an explicit `data-theme` on the document element wins, and the OS color scheme is the fallback when nothing is pinned.
*
* @returns The theme the studio should light for right now.
*/
private detectTheme(): ConfiguratorTheme {
const pinned = document.documentElement.dataset.theme;
if ( pinned === 'dark' || pinned === 'light' ) {
return pinned;
}
return window.matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light';
}
/**
* The current size of the viewport canvas in CSS pixels, read from the element so it is right in both standalone and embedded contexts.
*/
private viewportSize(): { width: number; height: number } {
return {
width: this.$viewport.clientWidth || 600,
height: this.$viewport.clientHeight || Math.round( 600 * CANVAS_ASPECT ),
};
}
/**
* Creates the scene, camera, renderer, the studio floor and lighting, and the lamp, then frames the camera on the lamp.
*/
private initScene(): void {
const palette = this.palette;
const scene = new THREE.Scene();
scene.background = new THREE.Color( palette.background );
this.scene = scene;
const size = this.viewportSize();
const camera = new THREE.PerspectiveCamera( 38, size.width / size.height, 0.1, 100 );
camera.position.set( 0, 0.55, 5.6 );
camera.lookAt( 0, 0.26, 0 );
this.camera = camera;
const renderer = new THREE.WebGLRenderer( { canvas: this.$viewport, antialias: true, alpha: true } );
renderer.setSize( size.width, size.height, false );
renderer.setPixelRatio( Math.min( window.devicePixelRatio || 1, 2 ) );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
this.renderer = renderer;
this.buildStudio( scene );
this.buildLampInto( scene );
}
/**
* Builds the studio floor and the three-point lighting from the current palette, keeping each so a theme change can recolor or re-level it.
*
* @param scene - The scene to add the studio to.
*/
private buildStudio( scene: THREE.Scene ): void {
const palette = this.palette;
const floorMaterial = new THREE.MeshStandardMaterial( { color: palette.floor, roughness: 0.95, metalness: 0.05 } );
const floor = new THREE.Mesh( new THREE.CircleGeometry( 6, 64 ), floorMaterial );
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.95;
scene.add( floor );
this.floorMesh = floor;
this.floorMaterial = floorMaterial;
const ambient = new THREE.AmbientLight( 0xffffff, palette.ambient );
scene.add( ambient );
this.themedLights.ambient = ambient;
const key = new THREE.DirectionalLight( 0xffffff, palette.key );
key.position.set( 2.6, 4, 3.2 );
scene.add( key );
this.themedLights.key = key;
const rim = new THREE.DirectionalLight( 0xbcd0ff, palette.rim );
rim.position.set( -3, 2.2, -2.6 );
scene.add( rim );
this.themedLights.rim = rim;
}
/**
* Builds the lamp from the helper in the lamp's current config and adds it to the scene, applying the current orbit so it opens at a readable three-quarter angle.
*
* @param scene - The scene to add the lamp to.
*/
private buildLampInto( scene: THREE.Scene ): void {
const lamp = buildLamp( this.palette, this.shadeHex(), this.config.baseFinish, this.config.bulbOn );
lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
scene.add( lamp.group );
this.lamp = lamp;
}
/**
* The hex color of the currently selected shade swatch, falling back to the first swatch when the stored id is unknown.
*/
private shadeHex(): string {
const swatch = SHADE_SWATCHES.find( ( entry ) => entry.id === this.config.shadeSwatchId ) ?? SHADE_SWATCHES[ 0 ];
return swatch.hex;
}
/**
* Wires the orbit pointer listeners, the overlay's `paint` listener, the resize observer that keeps the renderer matched to the embedded stage, and the intersection observer that pauses the loop offscreen.
*
* The orbit starts on a `pointerdown` on the viewport and tracks on the window so a drag that leaves the canvas keeps rotating, and a release without a meaningful move is treated as a select on the same press.
* The `paint` listener registers once, because the overlay canvas is rendered in every mode and so survives a mode switch; on a build without the experimental API the event never fires, which leaves the handler inert.
*/
private bindEvents(): void {
this.$viewport.addEventListener( 'pointerdown', this.onPointerDown );
window.addEventListener( 'pointermove', this.onPointerMove );
window.addEventListener( 'pointerup', this.onPointerUp );
window.addEventListener( 'pointercancel', this.onPointerCancel );
this.$overlayCanvas?.addEventListener( 'paint', this.onOverlayPaint );
this.resizeObserver = new ResizeObserver( () => this.onResize() );
this.resizeObserver.observe( this.$viewport );
this.intersectionObserver = new IntersectionObserver(
( entries ) => this.onIntersection( entries ),
{ threshold: 0.01 },
);
this.intersectionObserver.observe( this.$viewport );
}
/**
* Wires the two ways the page theme can change at runtime: the OS color scheme and an in-page `data-theme` toggle, both funnelled through {@link refreshTheme}.
*/
private bindThemeListeners(): void {
this.themeMedia = window.matchMedia( '(prefers-color-scheme: dark)' );
this.themeMedia.addEventListener( 'change', this.onThemeMediaChange );
this.themeObserver = new MutationObserver( () => this.refreshTheme() );
this.themeObserver.observe( document.documentElement, { attributes: true, attributeFilter: [ 'data-theme' ] } );
}
/**
* Re-resolves the OS color scheme into the studio when the system theme flips.
*/
private onThemeMediaChange = (): void => {
this.refreshTheme();
};
/**
* Re-resolves the theme and, when it actually changed, recolors the studio and the shared metal so the lamp follows the page.
*/
private refreshTheme(): void {
const next = this.detectTheme();
if ( next === this.theme ) {
return;
}
this.theme = next;
this.applyTheme();
}
/**
* Pushes the current palette onto the live scene: the background, the floor, the lights, and the shared metal tone of the arm and base, so a runtime theme change lands without rebuilding the scene.
*/
private applyTheme(): void {
const palette = this.palette;
if ( this.scene ) {
this.scene.background = new THREE.Color( palette.background );
}
this.floorMaterial?.color.setHex( palette.floor );
if ( this.themedLights.ambient ) {
this.themedLights.ambient.intensity = palette.ambient;
}
if ( this.themedLights.key ) {
this.themedLights.key.intensity = palette.key;
}
if ( this.themedLights.rim ) {
this.themedLights.rim.intensity = palette.rim;
}
if ( this.lamp ) {
this.lamp.baseMaterial.color.setHex( palette.metal );
this.lamp.armMaterial.color.setHex( palette.metal );
}
this.refreshAccent();
this.applySelectionHighlight();
this.requestRender();
}
/**
* Sizes the WebGL backing store and the overlay backing store for the current device pixel ratio, and rebuilds the one-shot listener for the next change.
*
* `matchMedia( '(resolution: Ndppx)' )` only watches the current ratio, so a move to a different-density display needs the query rebuilt each time; the renderer caps the ratio at two so a very dense display does not balloon the backing store.
*/
private observeDevicePixelRatio(): void {
this.dprQuery?.removeEventListener( 'change', this.onDevicePixelRatioChange );
const dpr = window.devicePixelRatio || 1;
this.dprQuery = window.matchMedia( `(resolution: ${ dpr }dppx)` );
this.dprQuery.addEventListener( 'change', this.onDevicePixelRatioChange, { once: true } );
this.renderer?.setPixelRatio( Math.min( dpr, 2 ) );
}
/**
* Re-corrects the backing stores after the device pixel ratio changes, then re-arms the watcher and repaints.
*/
private onDevicePixelRatioChange = (): void => {
this.observeDevicePixelRatio();
this.onResize();
};
/**
* Resizes the renderer and updates the camera aspect when the viewport's CSS box changes, then repaints the overlay.
*/
private onResize(): void {
if ( ! this.camera || ! this.renderer ) {
return;
}
const size = this.viewportSize();
this.camera.aspect = size.width / size.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize( size.width, size.height, false );
this.requestRender();
this.syncOverlay();
}
/**
* Pauses or resumes the render loop as the demo scrolls in and out of view, so an offscreen lamp costs no frames.
*
* @param entries - The intersection records for the observed viewport.
*/
private onIntersection( entries: IntersectionObserverEntry[] ): void {
for ( const entry of entries ) {
this.isVisible = entry.isIntersecting;
if ( this.isVisible ) {
this.startLoop();
} else {
this.stopLoop();
}
}
}
/**
* Begins a drag-to-orbit gesture, recording the start pointer so motion is measured as a delta and a press in place can still resolve to a select.
*
* Only the primary pointer's primary button starts a gesture: a right or middle click falls through to the browser, since a context menu can swallow the matching release and leave a phantom drag armed, and a gesture already in progress owns the viewport, so a new contact of any type is ignored until the owning pointer releases, because `isPrimary` is per pointer type and a first touch mid-mouse-drag would otherwise pass the guard and steal the gesture.
*
* @param event - The pointerdown on the viewport.
*/
private onPointerDown = ( event: PointerEvent ): void => {
if ( this.activePointerId !== null || ! event.isPrimary || event.button !== 0 ) {
return;
}
this.isDragging = true;
this.dragMoved = false;
this.activePointerId = event.pointerId;
this.lastPointer = { x: event.clientX, y: event.clientY };
};
/**
* Applies a drag delta to the orbit yaw and pitch while a gesture is active, bounding the pitch so the lamp stays framed.
*
* Once the pointer has moved past a few pixels the gesture is marked as a drag, so the matching release orbits rather than selecting.
*
* @param event - The pointermove on the window.
*/
private onPointerMove = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
const dx = event.clientX - this.lastPointer.x;
const dy = event.clientY - this.lastPointer.y;
this.lastPointer = { x: event.clientX, y: event.clientY };
if ( Math.abs( dx ) + Math.abs( dy ) > 3 ) {
this.dragMoved = true;
}
this.orbit.yaw += dx * 0.01;
this.orbit.pitch += dy * 0.01;
this.orbit.pitch = Math.max( -0.5, Math.min( 0.6, this.orbit.pitch ) );
this.requestRender();
};
/**
* Ends the gesture, and when the pointer never moved past the drag threshold, treats the release as a raycast select on the part under it.
*
* @param event - The pointerup on the window.
*/
private onPointerUp = ( event: PointerEvent ): void => {
if ( ! this.isDragging || event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
if ( ! this.dragMoved ) {
this.pickAt( event );
}
};
/**
* Abandons the gesture when the browser cancels the owning pointer, a touch scroll takeover or a system sheet, without treating the interruption as a select.
*
* Only the gesture's own pointer can cancel it, so an unrelated contact being cancelled leaves a live drag alone.
*/
private onPointerCancel = ( event: PointerEvent ): void => {
if ( event.pointerId !== this.activePointerId ) {
return;
}
this.isDragging = false;
this.activePointerId = null;
};
/**
* Raycasts from the camera through a pointer position and selects the lamp part it hits, or clears the selection on a miss.
*
* This is the article's 3D hit-testing lesson: there is still no built-in canvas hit test, so the pointer is converted into normalized device coordinates, `raycaster.setFromCamera` builds the ray, and `intersectObjects` against the whole lamp group resolves the nearest hit to a part id, the raycast that replaces the old `isPointInPath`.
* Every visible mesh carries the id of the part it belongs to, so a click on a cosmetic piece like the shade cap or the neck still selects; a hit whose chain carries no id, the additive glow halo, falls through to the next hit rather than eating the click, and the selection only clears when nothing in the list resolves.
* In `canvas-only` the painted UI sits on top of the scene, so its pills, swatches, and panel are hit-tested first, more controls whose geometry has to be re-derived by hand because no DOM exists to do it.
*
* @param event - The pointer event whose position to pick at.
*/
private pickAt( event: PointerEvent ): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
if ( this.activeMode === 'canvas-only' && this.pickFauxUiAt( event ) ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const ndc = new THREE.Vector2(
( ( event.clientX - rect.left ) / rect.width ) * 2 - 1,
-( ( event.clientY - rect.top ) / rect.height ) * 2 + 1,
);
this.raycaster.setFromCamera( ndc, this.camera );
const hits = this.raycaster.intersectObjects( this.lamp.group.children, true );
for ( const hit of hits ) {
const id = this.partIdForObject( hit.object );
if ( id ) {
this.select( id, 'canvas' );
return;
}
}
this.select( null, 'canvas' );
}
/**
* Hit-tests the painted faux UI by hand, the part label pills first, then the inspector's swatch row, then the panel body, and applies whatever the matching real control would do.
*
* This mirrors the exact geometry {@link paintPill} and {@link paintFauxInspector} paint (the pill boxes around the visible anchors, the swatch centers and radius, the {@link fauxPanelRect} box), because painted pixels carry no hit regions of their own.
* That duplication is the point the trap teaches: the mouse path can be made to feel complete, but every painted control costs a hand-derived hit region like this, the demo now pays that cost for all of them, and focus, the accessibility tree, and text selection still get nothing.
* A hit on the panel body that lands on no control still returns `true`, so a click on the painted inspector never raycasts the scene through it.
* The swatch hit radius is a couple of pixels wider than the painted circle so the click is comfortable, the kind of forgiveness a real `<button>` gives for free.
*
* @param event - The pointer event whose position to test.
* @returns Whether the point landed on the painted UI, with the matching action applied.
*/
private pickFauxUiAt( event: PointerEvent ): boolean {
const rect = this.$viewport.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
// Test in reverse paint order, so the topmost painted thing wins: the swatches sit on the panel, the panel covers any pill it overlaps, and the pills sit over the scene.
const panel = this.fauxPanelRect( rect.width, rect.height );
const swatchY = panel.y + 54 + 8;
let swatchX = panel.x + 12 + 8;
for ( const swatch of SHADE_SWATCHES ) {
if ( Math.hypot( px - swatchX, py - swatchY ) <= 10 ) {
this.onSelectSwatch( swatch.id );
return true;
}
swatchX += 24;
}
if ( px >= panel.x && px <= panel.x + panel.width && py >= panel.y && py <= panel.y + panel.height ) {
return true;
}
const ctx = this.overlayCtx;
if ( ctx ) {
ctx.font = `600 12px ${ this.overlayColors.fontSans }`;
// Within the pills the test also runs in reverse paint order, since a later-painted pill covers an earlier one where they overlap: one more stacking decision the DOM normally owns and painted UI has to re-derive.
for ( const part of [ ...this.parts ].reverse() ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
const padX = 9;
const boxWidth = ctx.measureText( part.label ).width + padX * 2;
const boxHeight = 22;
const x = point.x - boxWidth / 2;
const y = point.y - boxHeight / 2;
if ( px >= x && px <= x + boxWidth && py >= y && py <= y + boxHeight ) {
this.select( part.id, 'canvas' );
return true;
}
}
}
return false;
}
/**
* Resolves the part a hit object belongs to by walking its ancestry to the first stamped `userData.partId`.
*
* The geometry helper stamps every visible mesh, cosmetic pieces included, so a hit anywhere on the lamp maps to the part a user believes they clicked; the glow sprite is left unstamped on purpose, so this returns `null` for it and the caller moves on to the next hit.
*
* @param object - The object the raycaster returned.
* @returns The part id stamped on the object or an ancestor, or `null` when the chain carries none.
*/
private partIdForObject( object: THREE.Object3D ): LampPartId | null {
let current: THREE.Object3D | null = object;
while ( current ) {
const stamped = current.userData.partId;
const id = PART_IDS.find( ( partId ) => partId === stamped );
if ( id ) {
return id;
}
current = current.parent;
}
return null;
}
/**
* Starts the render loop if it is not already running and the demo is on screen, and marks the scene dirty so the first frame back repaints.
*/
private startLoop(): void {
if ( this.frame === 0 && this.isVisible ) {
this.frame = requestAnimationFrame( this.renderLoop );
}
this.requestRender();
}
/**
* Stops the render loop and clears the frame handle so it can be restarted cleanly.
*/
private stopLoop(): void {
if ( this.frame !== 0 ) {
cancelAnimationFrame( this.frame );
this.frame = 0;
}
}
/**
* Marks the scene dirty so the next animation frame renders the WebGL view and re-syncs the overlay.
*/
private requestRender(): void {
this.needsRender = true;
}
/**
* The render loop: applies the orbit to the lamp, draws the WebGL frame, and re-syncs the overlay so the labels stay glued as the model turns.
*
* The loop stays hot at the frame cadence while the demo is on screen, but the work is gated on {@link needsRender}: an idle frame re-arms itself and returns, so a lamp nobody is touching renders nothing and writes no fresh `anchorPoints`, and the first frame after a change does the full pass.
*/
private renderLoop = (): void => {
this.frame = requestAnimationFrame( this.renderLoop );
if ( ! this.needsRender ) {
return;
}
if ( ! this.renderer || ! this.scene || ! this.camera || ! this.lamp ) {
return;
}
this.needsRender = false;
this.lamp.group.rotation.set( this.orbit.pitch, this.orbit.yaw, 0 );
this.renderer.render( this.scene, this.camera );
this.syncOverlay();
};
/**
* Lifts the emissive of the selected part and rests every other part, so the selection reads in 3D as a glow on the chosen mesh in either theme.
*
* It runs on a selection or theme change rather than every frame, since the emissive persists in the material between renders, and the cached accent keeps it off the `getComputedStyle` path.
* The bulb is left to its own lit glow, so selecting it does not fight the bulb toggle; its overlay label and inspector heading still mark the selection.
*/
private applySelectionHighlight(): void {
if ( ! this.lamp ) {
return;
}
for ( const id of PART_IDS ) {
if ( id === 'bulb' ) {
continue;
}
const material = this.lamp.parts[ id ].material;
if ( Array.isArray( material ) || ! ( material instanceof THREE.MeshStandardMaterial ) ) {
continue;
}
const selected = id === this.selectedId;
material.emissive.copy( selected ? this.accent : BLACK );
material.emissiveIntensity = selected ? 0.32 : 0;
}
}
/**
* Projects each part's 3D anchor to a CSS-pixel screen position and stores it, then repaints the flat overlay for the active mode.
*
* This is the alignment bookkeeping the article says the overlay owns: the anchor is taken into world space through the lamp's matrix, projected with the camera, and converted from normalized device coordinates to CSS pixels against the canvas rect, which keeps the projection device-pixel-ratio correct because the rect is in CSS pixels while the WebGL backing store carries the ratio separately.
*/
private syncOverlay(): void {
if ( ! this.camera || ! this.lamp ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
this.lamp.group.updateMatrixWorld();
const next: Record<string, { x: number; y: number; visible: boolean }> = {};
for ( const id of PART_IDS ) {
this.projected.copy( this.lamp.anchors[ id ] );
this.lamp.group.localToWorld( this.projected );
this.projected.project( this.camera );
next[ id ] = {
x: ( this.projected.x * 0.5 + 0.5 ) * width,
y: ( -this.projected.y * 0.5 + 0.5 ) * height,
visible: this.projected.z < 1,
};
}
this.anchorPoints = next;
this.paintFlatOverlay();
}
/**
* Repaints the flat overlay canvas for the active mode: the faux UI in `canvas-only`, a requested `paint` event for the drawn labels in `html-in-canvas`, and a clear in `dom-overlay` where real HTML owns the labels.
*
* The experimental branch neither draws nor clears here; it calls `requestPaint()` and {@link onOverlayPaint} clears and draws inside the event, so a platform-initiated paint, a focus ring appearing on a drawn label, repaints the whole canvas atomically instead of compositing over pixels this pass left behind. The shared {@link supportsHtmlInCanvas} predicate gates the mode on both methods, so the branch only runs where the request is real.
*/
private paintFlatOverlay(): void {
const $canvas = this.$overlayCanvas;
if ( ! $canvas ) {
return;
}
if ( ! this.overlayCtx ) {
this.overlayCtx = $canvas.getContext( '2d' );
}
const ctx = this.overlayCtx;
if ( ! ctx ) {
return;
}
const rect = this.$viewport.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = rect.width || 1;
const cssHeight = rect.height || 1;
// Resize the backing store when the box or the device pixel ratio changes, since assigning width or height clears and reallocates the canvas, which would flicker if done every frame; keying on dpr too reallocates when only the ratio shifts (a display move or browser zoom that leaves the CSS box bit-identical), matching the WebGL side that already re-sizes for the new ratio.
if ( this.overlaySize.width !== cssWidth || this.overlaySize.height !== cssHeight || this.overlaySize.dpr !== dpr ) {
$canvas.width = Math.round( cssWidth * dpr );
$canvas.height = Math.round( cssHeight * dpr );
this.overlaySize = { width: cssWidth, height: cssHeight, dpr };
}
if ( this.activeMode === 'html-in-canvas' ) {
$canvas.requestPaint();
return;
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
ctx.clearRect( 0, 0, cssWidth, cssHeight );
if ( this.activeMode === 'canvas-only' ) {
this.paintFauxUi( ctx, cssWidth, cssHeight );
}
}
/**
* Paints the canvas-only trap: every part label and a faux inspector panel, all as raw pixels with no DOM behind them.
*
* It looks like a working configurator, but the text is pixels that cannot be selected, found in page, or reached with a Tab key or a screen reader, which is the trap a real 3D scene invites because it is exactly where someone reaches for drawing the UI onto the canvas too.
*
* @param ctx - The overlay 2D context, scaled so one unit is one CSS pixel.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxUi( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
if ( ! point || ! point.visible ) {
continue;
}
this.paintPill( ctx, part.label, point.x, point.y, part.id === this.selectedId );
}
this.paintFauxInspector( ctx, width, height );
}
/**
* Paints one rounded label pill centered on a point, the faux equivalent of an overlay label, from the cached themed colors.
*
* @param ctx - The overlay 2D context.
* @param text - The label text.
* @param cx - The pill center x in CSS pixels.
* @param cy - The pill center y in CSS pixels.
* @param selected - Whether to fill the pill with the accent.
*/
private paintPill( ctx: CanvasRenderingContext2D, text: string, cx: number, cy: number, selected: boolean ): void {
const colors = this.overlayColors;
ctx.font = `600 12px ${ colors.fontSans }`;
const padX = 9;
const boxWidth = ctx.measureText( text ).width + padX * 2;
const boxHeight = 22;
const x = cx - boxWidth / 2;
const y = cy - boxHeight / 2;
const pill = new Path2D();
pill.roundRect( x, y, boxWidth, boxHeight, 11 );
ctx.fillStyle = selected ? colors.accent : colors.surface;
ctx.fill( pill );
ctx.lineWidth = 1;
ctx.strokeStyle = selected ? colors.accent : this.withAlpha( colors.ink, 0.18 );
ctx.stroke( pill );
ctx.fillStyle = selected ? colors.accentInk : colors.ink;
ctx.textBaseline = 'middle';
ctx.fillText( text, x + padX, y + boxHeight / 2 );
}
/**
* The faux inspector panel's box in CSS pixels, one derivation shared by the painter and the hand-built hit test so the drawn panel and its hit region cannot drift apart.
*
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
* @returns The panel's position and size.
*/
private fauxPanelRect( width: number, height: number ): { x: number; y: number; width: number; height: number } {
const panelWidth = Math.min( 190, width - 24 );
const panelHeight = 96;
return {
x: width - panelWidth - 12,
y: height - panelHeight - 12,
width: panelWidth,
height: panelHeight,
};
}
/**
* Paints the faux inspector panel into the corner of the canvas, a picture of the real inspector with no DOM behind it, from the cached themed colors.
*
* The panel responds to the mouse only because {@link pickFauxUiAt} re-derives this exact geometry by hand, from the same {@link fauxPanelRect} box this painter draws; there is still no element, so nothing here can take focus, appear in the accessibility tree, or offer selectable text.
*
* @param ctx - The overlay 2D context.
* @param width - The overlay width in CSS pixels.
* @param height - The overlay height in CSS pixels.
*/
private paintFauxInspector( ctx: CanvasRenderingContext2D, width: number, height: number ): void {
const colors = this.overlayColors;
const box = this.fauxPanelRect( width, height );
const x = box.x;
const y = box.y;
const panel = new Path2D();
panel.roundRect( x, y, box.width, box.height, 10 );
ctx.fillStyle = this.withAlpha( colors.surface, 0.95 );
ctx.fill( panel );
ctx.lineWidth = 1;
ctx.strokeStyle = this.withAlpha( colors.ink, 0.16 );
ctx.stroke( panel );
const selected = this.selectedPart;
ctx.fillStyle = colors.ink;
ctx.textBaseline = 'alphabetic';
ctx.font = `600 13px ${ colors.fontSans }`;
ctx.fillText( selected ? selected.label : 'Inspector', x + 12, y + 24 );
ctx.font = `11px ${ colors.fontMono }`;
ctx.fillStyle = this.withAlpha( colors.ink, 0.6 );
ctx.fillText( 'Shade color', x + 12, y + 46 );
const swatchY = y + 54;
let swatchX = x + 12;
for ( const swatch of SHADE_SWATCHES ) {
ctx.beginPath();
ctx.arc( swatchX + 8, swatchY + 8, 8, 0, Math.PI * 2 );
ctx.fillStyle = swatch.hex;
ctx.fill();
if ( swatch.id === this.config.shadeSwatchId ) {
ctx.lineWidth = 2;
ctx.strokeStyle = colors.accent;
ctx.stroke();
}
swatchX += 24;
}
}
/**
* Draws the real labels when the overlay canvas fires its `paint` event, so the experimental draw runs inside the event the way the canonical flow prescribes.
*
* The render loop and the sync passes never draw or clear the labels themselves; {@link paintFlatOverlay} requests a paint, and this handler clears the canvas and runs {@link drawLabelsIntoCanvas} when the event lands, so a platform-initiated paint, a focus ring appearing on a label, starts from its own clear instead of leaving residue over the last frame.
* The mode guard mirrors the experimental branch of the paint pass, since the listener stays wired in every mode, and the context guard covers an event that lands before the first paint pass has cached the 2D context.
*/
private onOverlayPaint = (): void => {
const ctx = this.overlayCtx;
const $canvas = this.$overlayCanvas;
if ( this.activeMode !== 'html-in-canvas' || ! ctx || ! $canvas ) {
return;
}
// Clear in device pixels under the identity transform, the same grid the label draw below runs on.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
ctx.clearRect( 0, 0, $canvas.width, $canvas.height );
this.drawLabelsIntoCanvas( ctx );
};
/**
* Draws every visible real label node into the overlay canvas at its own 1:1 size and projected center, then writes each returned alignment matrix back, the experimental in-canvas-label path.
*
* Each label's `style.transform` is a platform-owned slot here. The node is already a reachable element the browser exposes in the accessibility tree and the tab order, because the overlay canvas keeps it a real DOM child under `layoutsubtree`; writing the matrix back is only what keeps that element's focus ring, hit target, and accessibility bounds aligned under its drawn pixels. When `omitTransformSync` is set the write is skipped so the reader can watch the focus ring and click target drift off the pixels.
*
* A label whose part is facing away is hidden rather than skipped in place: `visibility: hidden` also removes it from the tab order and the accessibility tree while it is offscreen, the same contract the DOM-overlay labels keep, where a skipped node would otherwise stay tabbable at its stale position.
*
* The work runs in three passes, the transform resets and visibility first, then every box measurement, then every draw and write-back, so the style writes cost at most two layout flushes per paint instead of one per label.
*
* The draw runs on the bare device-pixel grid, not under the `dpr` scale the faux UI paints with: the label node already rasterizes at the display's pixel density, so drawing it under an extra `dpr` transform would double its size, which is what made the drawn pills balloon. The projected center and the label box are scaled into device pixels by hand instead, so the drawn pill matches the DOM-overlay pill exactly, then the transform is restored for the next paint.
*
* The explainer's canonical flow routes this draw through the canvas `paint` event, and that is where it runs: the render loop and the sync passes only call `requestPaint()`, and {@link onOverlayPaint} invokes this method when the event fires. Calling `drawElementImage` outside the event is still defined behavior, but it draws the previous frame's snapshot of the element and throws before a first snapshot exists, which is why the handler owns the draw.
*
* @param ctx - The overlay 2D context, on a supporting build where `drawElementImage` exists.
*/
private drawLabelsIntoCanvas( ctx: CanvasRenderingContext2D ): void {
const $host = this.$overlayCanvas;
if ( ! $host ) {
return;
}
const dpr = window.devicePixelRatio || 1;
// Drop to the identity transform so one drawing unit is one backing-store pixel, the space the element bitmap is already rasterized in; the faux-UI dpr scale is restored at the end.
ctx.setTransform( 1, 0, 0, 1, 0, 0 );
// First pass: reset any prior alignment before measuring, so each box read is the label's own size rather than a previously skewed one, and hide the labels whose part is facing away.
const drawable: Array<{ point: { x: number; y: number }; $node: HTMLElement }> = [];
for ( const part of this.parts ) {
const point = this.anchorPoints[ part.id ];
const $node = $host.querySelector( `[data-part='${ part.id }']` );
if ( ! ( $node instanceof HTMLElement ) ) {
continue;
}
if ( ! point || ! point.visible ) {
$node.style.visibility = 'hidden';
continue;
}
$node.style.visibility = '';
if ( ! this.omitTransformSync ) {
$node.style.transform = '';
}
drawable.push( { point, $node } );
}
// Second pass: measure every box in one batch, so the writes above flush layout once rather than per label.
const boxes = drawable.map( ( entry ) => entry.$node.getBoundingClientRect() );
// Third pass: draw each label at its projected center and write the alignment matrix back.
for ( const [ index, entry ] of drawable.entries() ) {
const box = boxes[ index ];
const dx = ( entry.point.x - box.width / 2 ) * dpr;
const dy = ( entry.point.y - box.height / 2 ) * dpr;
const alignment = ctx.drawElementImage( entry.$node, dx, dy );
if ( ! this.omitTransformSync ) {
entry.$node.style.transform = alignment.toString();
}
}
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
}
/**
* Reads a CSS custom property off the host, falling back to a literal when the token sheet has not loaded.
*
* A canvas paints raw pixels and cannot inherit a CSS color, so every themed color the faux UI draws is read here at paint time, which is what keeps the canvas-only labels legible in light and dark.
*
* @param name - The custom property to read.
* @param fallback - The literal color to use when the property is empty.
* @returns The resolved color string.
*/
private token( name: string, fallback: string ): string {
const value = getComputedStyle( this ).getPropertyValue( name ).trim();
return value || fallback;
}
/**
* Resolves the selection accent the same way the stylesheet does, reading the private `--cfg-accent` so the 3D highlight and the painted UI match the DOM controls in either theme.
*/
private accentColor(): string {
return this.token( '--cfg-accent', '#2257e6' );
}
/**
* Re-reads the themed colors into the caches the render loop relies on: the accent as a Three.js color for the 3D highlight, and the token set the flat overlay paints with.
*
* It is called on connect and after a theme change, so every per-frame draw works from cached values rather than calling `getComputedStyle`.
* The flip side of that cache is staleness: a host that swaps `--configurator-accent` at runtime without a theme flip paints the old accent until the next theme change refreshes it.
*/
private refreshAccent(): void {
const accent = this.accentColor();
this.accent.set( accent );
this.overlayColors = {
ink: this.token( '--ink', '#17171a' ),
surface: this.token( '--surface', '#ffffff' ),
accent,
accentInk: this.token( '--accent-ink', '#ffffff' ),
fontSans: this.token( '--font-sans', 'system-ui, sans-serif' ),
fontMono: this.token( '--font-mono', 'monospace' ),
};
}
/**
* Returns a color string at a given alpha, parsing a `#rgb` or `#rrggbb` token into `rgba()` and mixing any other syntax toward transparent with `color-mix()`.
*
* The canvas needs an explicit alpha for the faux panel and pill borders, but the theme tokens are usually opaque hex, so the fast path parses those by hand; anything else goes through `color-mix()`, which any browser modern enough to hand us a non-hex token can paint.
*
* @param color - The base color, usually a hex token value.
* @param alpha - The alpha to apply, from 0 to 1.
* @returns An `rgba()` string for hex input, or a `color-mix()` expression for anything else.
*/
private withAlpha( color: string, alpha: number ): string {
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec( color.trim() );
if ( ! match ) {
return `color-mix( in srgb, ${ color } ${ Math.round( alpha * 100 ) }%, transparent )`;
}
const body = match[ 1 ];
const full = body.length === 3 ? body.split( '' ).map( ( c ) => c + c ).join( '' ) : body;
const r = parseInt( full.slice( 0, 2 ), 16 );
const g = parseInt( full.slice( 2, 4 ), 16 );
const b = parseInt( full.slice( 4, 6 ), 16 );
return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`;
}
/**
* Selects a part from a real overlay label, the keyboard-reachable path the raycast cannot provide.
*
* @param id - The id of the part the label represents.
*/
private onLabelClick( id: string ): void {
this.select( id, 'overlay' );
}
/**
* Updates the selection and dispatches the typed `object-select` event, tagged with the layer it came from.
*
* The event fires on every pick, even a repeat of the current selection, so a demo log can record each raycast; the internal `selectedId` only changes when the target actually changes.
* The event is `composed` so it crosses the shadow boundary and `bubbles` so a listener on the host hears it.
*
* @param id - The id to select, or `null` to clear.
* @param source - Which layer triggered the selection, the raycast or a real overlay control.
*/
private select( id: string | null, source: ConfiguratorSelectSource ): void {
if ( id !== this.selectedId ) {
this.selectedId = id;
}
this.dispatchEvent(
new CustomEvent<ConfiguratorSelectDetail>( 'object-select', {
detail: { objectId: id, source },
bubbles: true,
composed: true,
} ),
);
}
/**
* Recolors the shade to a swatch, applying it to the live material and reporting the new config.
*
* @param swatchId - The id of the chosen swatch.
*/
private onSelectSwatch( swatchId: string ): void {
this.config = { ...this.config, shadeSwatchId: swatchId };
if ( this.lamp ) {
setShadeColor( this.lamp, this.shadeHex() );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Switches the base finish, retuning the live base material and reporting the new config.
*
* @param finish - The finish to apply, matte or metal.
*/
private onSelectFinish( finish: BaseFinish ): void {
this.config = { ...this.config, baseFinish: finish };
if ( this.lamp ) {
setBaseFinish( this.lamp, finish );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Toggles the bulb, relighting the scene through the helper and reporting the new config.
*/
private onToggleBulb(): void {
const bulbOn = ! this.config.bulbOn;
this.config = { ...this.config, bulbOn };
if ( this.lamp ) {
setBulbState( this.lamp, bulbOn );
}
this.requestRender();
this.emitConfigChange();
}
/**
* Dispatches the typed `config-change` event with the whole current config after an inspector edit.
*
* It is `composed` and `bubbles` for the same reason the selection event is, so a host listener hears every edit without a cast.
*/
private emitConfigChange(): void {
this.dispatchEvent(
new CustomEvent<ConfiguratorConfigChangeDetail>( 'config-change', {
detail: { config: this.config },
bubbles: true,
composed: true,
} ),
);
}
/**
* Types `addEventListener` for the configurator's own events so a listener receives the typed `CustomEvent` with no cast, and falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof ProductConfiguratorEventMap>(
type: K,
listener: ( this: ProductConfigurator, ev: ProductConfiguratorEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the WebGL viewport, the flat overlay canvas, the real DOM labels or the drawn-label nodes for the active mode, and the inspector and harness panel in the side column.
*
* The WebGL canvas always renders the lamp; what changes per mode is whether real HTML labels sit over it (`dom-overlay`), real label nodes are drawn into the overlay canvas (`html-in-canvas`), or the labels and a faux inspector are painted onto the overlay canvas with no DOM (`canvas-only`).
* The stage and the side column are always laid out by the same `.layout` grid: `stacked` keeps it one column so the side falls under the canvas, and `split` makes it two so the side becomes the right column, the glass-demo shape.
*/
render() {
const active = this.activeMode;
const parts = this.parts;
return html`
<div class=${ this.layout === 'split' ? 'layout split' : 'layout' }>
<div class="main">
<slot name="header"></slot>
<div class=${ this.isDragging ? 'stage dragging' : 'stage' }>
<canvas class="viewport" role="img" aria-label="A 3D desk lamp you can orbit by dragging. Click a part to select it."></canvas>
<canvas class="overlay-canvas" aria-hidden=${ active === 'html-in-canvas' ? 'false' : 'true' } ?layoutsubtree=${ active === 'html-in-canvas' }>
${ active === 'html-in-canvas'
? parts.map( ( part ) => html`
<button
type="button"
class="drawn-label"
data-part=${ part.id }
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
` )
: nothing }
</canvas>
${ active === 'dom-overlay'
? html`
<div class="overlay">
${ parts.map( ( part ) => {
const point = this.anchorPoints[ part.id ];
const shown = point && point.visible;
return html`
<button
type="button"
class="label"
style="left: ${ point ? point.x : 0 }px; top: ${ point ? point.y : 0 }px; visibility: ${ shown ? 'visible' : 'hidden' };"
aria-pressed=${ part.id === this.selectedId }
@click=${ () => this.onLabelClick( part.id ) }
>${ part.label }</button>
`;
} ) }
</div>
`
: nothing }
</div>
</div>
${ this.renderSide( active ) }
</div>
`;
}
/**
* Renders the side column of the layout, the real inspector when the mode has one stacked above the harness `panel` slot, and in `split` a full-width `footer` slot under both columns.
*
* In `split` it is always rendered so it forms the right column even in `canvas-only`, where the inspector is painted into the canvas and the slot still carries the harness panel. In `stacked` it is rendered only when there is something to show, the inspector for a mode that has one, so a bare `canvas-only` embed does not leave an empty row under the canvas.
* The `footer` row spans both columns and only renders in `split`, so a harness can drop a tall report or log there instead of stacking it in the narrow side column, where it would run far below the stage and strand the canvas column; an empty `footer` slot produces no box, so a harness that slots nothing renders exactly as before.
*
* @param active - The active mode, which decides whether a real DOM inspector exists.
* @returns The side-column template, or `nothing` when stacked with no inspector to show.
*/
private renderSide( active: ConfiguratorMode ) {
const inspector = active === 'canvas-only' ? nothing : this.renderInspector();
if ( this.layout !== 'split' && active === 'canvas-only' ) {
return nothing;
}
return html`
<div class="side">
${ inspector }
<slot name="panel"></slot>
</div>
${ this.layout === 'split'
? html`<div class="footer"><slot name="footer"></slot></div>`
: nothing }
`;
}
/**
* Renders the real DOM inspector for the selected part: the shade swatches, the base finish, and the bulb toggle, each a real, keyboard-reachable control.
*
* It shows every control whatever the selected part, since the lamp is one product, and names the selected part in the heading so the selection still reads; with nothing selected the heading falls back to the whole lamp.
*/
private renderInspector() {
const selected = this.selectedPart;
return html`
<div class="inspector">
<div class="head">
<h3>${ selected ? selected.label : 'Lamp' }</h3>
${ selected ? html`<span class="part-tag">#${ selected.id }</span>` : nothing }
</div>
<div class="field">
<span class="field-label">Shade color</span>
<div class="swatches">
${ SHADE_SWATCHES.map( ( swatch ) => html`
<button
type="button"
class="swatch"
style="background: ${ swatch.hex };"
aria-label=${ swatch.label }
aria-pressed=${ swatch.id === this.config.shadeSwatchId }
@click=${ () => this.onSelectSwatch( swatch.id ) }
></button>
` ) }
</div>
</div>
<div class="field">
<span class="field-label">Base finish</span>
<div class="segmented">
<button type="button" aria-pressed=${ this.config.baseFinish === 'matte' } @click=${ () => this.onSelectFinish( 'matte' ) }>Matte</button>
<button type="button" aria-pressed=${ this.config.baseFinish === 'metal' } @click=${ () => this.onSelectFinish( 'metal' ) }>Metal</button>
</div>
</div>
<div class="field">
<span class="field-label">Bulb</span>
<button type="button" class="switch" aria-pressed=${ this.config.bulbOn } @click=${ () => this.onToggleBulb() }>
<span class=${ this.config.bulbOn ? 'track on' : 'track' }><span class="knob"></span></span>
<span>${ this.config.bulbOn ? 'On' : 'Off' }</span>
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'product-configurator': ProductConfigurator;
}
}The ship decision falls straight out of running the three modes side by side:
| Question | Canvas-only | DOM overlay | HTML-in-Canvas |
|---|---|---|---|
| Works without the experimental API? | Yes | Yes | Yes, falls back |
| Keyboard navigation works? | No | Yes | Yes, when present |
| Inspectable as DOM? | No | Yes | Yes, when present |
| Find in page works? | No | Yes | Yes, when present |
| Real, selectable DOM text? | No | Yes | Yes, when present |
| Support story allows production use? | No | Yes | No |
| Recommended status | Avoid | Ship | Enhance, behind a flag |
A senior would leave one review comment. Ship the DOM overlay, feature-detect HTML-in-Canvas as an enhancement, and keep the experimental path off the critical path until it’s cross-engine and the accessibility model has settled.
Now go back to the three demos at the top with this rubric in hand. The glass card’s indicator names which tier is actually live, and the 3D room still lands at Research even with real keystrokes and a native submit on its monitor, because the live path is flag-only and single-engine while the flat form beside it ships everywhere. The rubric scores all of them the same way every time, and the score is what tells you where each one goes.
A few ways this goes wrong
- Drawing the UI because the canvas let you. A drawn button has no focus, no tab order, and nothing in the accessibility tree. Draw the scene, and let the DOM own anything a user operates.
- Feature-detecting the wrong name.
typeof ctx.drawElement === 'function'is false on every browser that ships the current API, so the enhanced path silently never runs. Probe the live context fordrawElementImage, and re-check the IDL before you publish, because this API has renamed before. - Drawing the element and calling it accessible. The draw is a raster. Reachability comes from keeping the element a real DOM child under
layoutsubtreeand syncing its transform every paint; announcement still needs a screen-reader test. - Treating the overlay as free. It owns three coordinate systems, CSS pixels, the device-pixel-ratio scale, and the WebGL backing store, and for a 3D scene it re-projects every part anchor on every frame of an orbit, because the part moves on screen the moment the lamp turns. That projection has to stay DPR-correct as well, so budget the per-frame bookkeeping; it still ships, but it isn’t free.
- Shipping a flag-gated origin trial. It reaches almost none of your users. Keep it an enhancement with the fallback carrying the product, never the load-bearing path.
What separates a senior from a junior here
- They ask “should this be drawn?” before “how do I draw it?” A junior paints the whole interface onto the canvas. A senior sorts the screen into graphics and interface first, and only the graphics go on the canvas.
- They name which layer is production-safe at every step. The overlay is Baseline and ships; the experimental path is a Chromium-only enhancement with a fallback underneath. The build order follows that split, shippable parts first.
- They read a new API for ownership. A junior asks whether the demo compiles. A senior asks what behavior the old code recreated, whether the new API preserves it, and what fails when it’s missing, then wraps the call so a rename costs one adapter.
- They verify the accessibility claim instead of asserting it. Drawing pixels is provable; “a screen reader announces this” is not, so they open DevTools and a screen reader before promising it, and make the transform-sync failure a visible test rather than a footnote.
When NOT to draw your UI
A drawn UI is an enhancement over interface that’s already reachable some other way, which is both the case for it and its boundary. If a control, a label, or a form exists only as pixels, it’s unreachable for anyone on a keyboard, a screen reader, or a browser without the flag, which today is almost everyone. For anything essential, a mobile-first flow, or a control a user has to reach, keep it in the DOM where a tap and a Tab already work. The experimental path is worth watching, and worth prototyping behind feature detection, because it points at a future where the canvas and the DOM cooperate instead of forcing the choice. Until that future is cross-engine and the accessibility story has settled, the DOM stays authoritative and the canvas stays graphics.
Build your own
You’ve poked at the finished configurator above. Now build one, and test it the way it’ll actually get used. Go keyboard only with your hands off the mouse, then turn a screen reader on, then run the experimental tier in a flagged Chromium build with the DevTools accessibility panel open. That last stretch, watching what does and doesn’t make it into the accessibility tree, is where you find out whether what you built is operable or only looks operable.
Want more of these in your Google results?