34 KiB
Material Design 3 redesign (done: 0.50.0 - 0.62.0)
A plan to restyle StreamXR's VR interface with Material 3 Expressive (M3E), plus a frosted-glass, see-through look in the spirit of visionOS. Written in October 2026 after 0.48.1, and carried out step by step from 0.50.0 to 0.62.0 (October 2026); each step's "Done in" note below records what was built. ("M3" below means Material 3 as updated by Expressive.)
Decisions already made (by the maintainer)
-
Material 3 Expressive (decided 2026-10-13). Expressive is Google's 2025 update to Material 3, not a separate system: the same colour roles, palettes and tokens, plus a bigger shape library and shape changes on press, spring motion, emphasized type styles, and new / updated components (button groups, split buttons, toolbars, FAB menu, the morphing loading indicator, wavy progress, buttons in sizes XS-XL, the thick slider with a bar handle). Chosen because its bigger, bolder controls suit pointing with a laser at about a metre. Use its component specs wherever one fits (step 3).
-
Stock M3 first. Use M3's system as designed (colour roles, shapes, type scale, elevation, components); adapt it towards YouTube's look later, through the site's theme.
-
The old look can go. The choices in design-choices.md about colours, greys and panel looks (YouTube Red / Almost Black / Almost White, the grey hover tints, the flag colours) may be replaced. Update that file as each part lands, so it stays the record of how things look now.
-
Brand colour from a seed, made at run time (0.51.0; first decided as pre-computed tables, changed once the library proved small and fast; see "Colour").
-
Glass done cheaply: a blurred sample of the video behind the panel, plus tint, noise and a rim light; no extra full-scene render passes (see "Glass").
-
The look: M3 glass (chosen 2026-10-13 from the Settings mock-up: today / M3 solid / M3 glass, with a UI/UX designer's advice). Glass tint 62% of the surface colour, kept as one variable (the
glassTintopacity token) that everything reads, so it can be changed in one place. -
Controls panel decisions (2026-10-13, from its mock-up):
- Hover must be clearly visible (not M3's faint 8% state layer, too subtle for a laser in VR). First decided as "the stronger accent container"; replaced by a white outline ring once the colours were picked (see "The colours" below).
- Button labels under the icons (17 px in the mock-up): test in the headset before changing; 19-20 px if they're hard to read (in "To test in the headset" below).
- LIVE keeps YouTube's bright red (the brand colour,
color.primary/theme.accent#FF0033), not M3's darker red container.
-
The colors (decided 2026-10-13, from the controls panel mock-up, "Fidelity swapped + classic red"):
- Scheme: Fidelity (
SchemeFidelityinstead ofSchemeTonalSpot: stays closer to the seed; M3's dark schemes always lightenprimaryto pink, #ffb3af for #FF0033). - Pink and deep red swapped: buttons and unselected items are the pink (Fidelity's
primary#ffb3af, dark red icons / textonPrimary#68000e); where Fidelity would use its deep red container (#96041b), the swap puts the pink, and vice versa. - Classic YouTube red (#FF0033, white on it) for the accents: the seek bar's played part and handle, play / pause, Subscribe, Like when on, and LIVE.
- Hover: a white outline ring around the pointed-at button (it keeps its own colour, and its corners square up slightly, M3E's press shape), so a hover can't be mistaken for a red accent. This replaces the "stronger accent container" hover decided earlier the same day, which turned out bright red with these colours.
- Before: the Tonal Spot palette (0.50.1-0.51.0) had muted red-brown buttons and a hover too faint for VR.
- Scheme: Fidelity (
-
The panel styles (decided 2026-10-13, from the Settings and Browse mock-up):
- Tabs: M3 fixed tabs, equal widths, an icon above the label; the current one in
onSurfacewith a brand red indicator under it; pointed at: a faint tint and the white ring. - Switches: on = brand red (YouTube Red) track with a white handle holding a red check; off = dark track with an outline and a small grey handle.
- Setting rows as M3 list items: the name with its hint under it on the left, the control on the right (sliders in the middle, Default as a tonal pill with ↺).
- Filter chips: M3's rounded rectangles (
shape.small); selected = tonal pink with a check, others outlined. - Cards: no tile at rest (like YouTube); the tile (
surfaceContainerHighest) and the white ring appear only on the pointed-at card. Thumbnails rounded (shape.medium), the duration on a dark pill, the watched bar in brand red. - Text field: a full-round filled field with a search icon; focused = brand red outline and caret; its button filled brand red. Paging and steppers: round tonal icon buttons.
- Tabs: M3 fixed tabs, equal widths, an icon above the label; the current one in
To test in the headset
- Controls panel button labels: readable at 17 px? (else 19-20 px;
type.labelMediumin tokens.js). - Controls panel (0.52.0): the white hover ring easy to see on every button; the pink buttons comfortable over bright and dark videos (before the glass).
Glass (0.59.0)tested 2026-10-13: the frame rate stayed smooth; default tint set to 43% (0.60.0). (Was: the frame rate stays smooth (one mipmap rebuild per video frame, a few extra texture reads per panel pixel; if not: fewer taps, or tint + grain + rim only); text readable over moving video; the default tint (54%) to settle with the slider.)
Tested: the toolbar works in the headset as of 0.53.3 (2026-10-13): smooth, consistent expansion, hand-over between items and click fades.
Tested: 0.52.0 works in the headset (2026-10-13). The user noted a few minor UI quirks and will raise them in a UI/UX pass after the redesign is finished; ask for them then.
Why it's a medium-sized job
Panels are drawn with canvas 2D in the shell (src/core/page/vr-panel/). The theme today
(vr-panel/theme.js, ns.vrTheme) is only accent, accentPressed, dark and two alpha
helpers, set from the site's provider. Everything else is hard-coded in the drawing code:
greys (#aaa, #888, #ddd), translucent whites (rgba(255,255,255,0.07 / 0.1 / 0.18)),
the blue highlight (#3ea6ff), corner radii (12, 18, 30, 32 px), font sizes (16-30 px,
weights 500/600), card flag colours (in the YouTube provider's cards.js). All of that has
to move onto named tokens before the look can change in one place.
Files that draw: panel-ui.js (controls panel), chevrons-ui.js, hover-card-ui.js,
keyboard.js, glyphs.js, styles/kit.js, grid.js, thread.js, info.js,
catalog.js, queue.js, profile.js, options.js; the shaders in panel-pass.js,
laser-pass.js, pause-overlay.js, the controllers' icons (controllers/icons.js); and
the popup (src/streamxr/ui/popup.css, button.css), which is HTML and can use the same
tokens as CSS variables.
Steps
1. Token layer (no visible change) — colours done in 0.50.0
Replace ns.vrTheme with an M3 token set, ns.vrTokens (or extend vrTheme), and move
every hard-coded colour, radius, size and weight in the files above onto it. Map today's
values onto the tokens first, so this step draws pixel-identical canvases (check with
the usual before/after canvas hashes, as in the 0.32-0.34 and 0.48 refactors).
Done in 0.50.0: vr-panel/tokens.js (ns.vrTokens) replaced theme.js. Every colour
in the 13 canvas drawing files and the pause overlay is a role (TC.<role>,
TA(role, a)); today's in-between greys are kept as neutral tones (n90 ... n13) so
nothing changed on screen; YouTube's card flags name roles, with its three flag colours as
custom colours in its theme; the glass tint (0.62) lives in tokens.glass. Checked: every
style drawn (controls panel, chevrons, keyboard, Options, Catalog, Queue, Profile, Info,
hover card, with hover / pressed states and every flag) hashes identical to 0.49.2.
Left for step 3 (decided in 0.50.0): font sizes / weights and corner radii move onto
the type-scale and shape tokens component by component as each is restyled, since their
values change then anyway (converting today's 30 sizes first would buy nothing). Not in
canvas code and also left: the WebGL colours (laser, controller models: config.js RGB
floats), the popup's CSS (step 3.5, CSS variables).
Tokens (M3 names, camelCase):
- Colour roles (dark scheme; the panels are always dark in VR):
primary,onPrimary,primaryContainer,onPrimaryContainer,secondary,onSecondary,secondaryContainer,onSecondaryContainer,tertiary,onTertiary,tertiaryContainer,onTertiaryContainer,error,onError,errorContainer,onErrorContainer,surface,onSurface,surfaceVariant,onSurfaceVariant,surfaceContainerLowest,surfaceContainerLow,surfaceContainer,surfaceContainerHigh,surfaceContainerHighest,surfaceBright,surfaceDim,inverseSurface,inverseOnSurface,inversePrimary,outline,outlineVariant,scrim,shadow. - State layers (M3 interaction states, an overlay of the content colour):
hover0.08,focus0.10,pressed0.10,dragged0.16; disabled content 0.38, disabled container 0.12. - Shape (corner radius, px at 1280 px per metre; M3E's scale):
none0,extraSmall4,small8,medium12,large16,largeIncreased20,extraLarge28,extraLargeIncreased32,extraExtraLarge48,full(pill). Panel corners:extraLarge. Pressed buttons change shape (M3E): round at rest, squarer while pressed. - Type scale (M3 roles: display / headline / title / body / label, each large / medium / small, plus M3E's emphasized version of each, heavier, for the selected or most important text), sizes scaled for reading at ~1 m in VR (M3's sp values × about 1.6 on our canvases; tune in the headset). Font: Roboto (already used), with Roboto Flex as an option if bundled.
- Motion (M3E springs):
fast/default/slowspatial and effects springs (damping + stiffness), for hover, press and open / fold animations. Today a panel only redraws when something changes; animating needs a redraw while a spring settles (the same mechanism the scrolling title and the spinner use). A later step than the colours. - Elevation: levels 0-5 as surface tint + shadow; in glass panels mostly expressed by the tint strength and the rim light instead of drop shadows.
- Glass (our own, not M3):
glassTint(surface colour + opacity, 0.62; the only place that number lives),glassBlur(which mip level / how wide),glassNoise(strength),glassRim(rim light strength).
Card flags (2D / 180° / 360°, LIVE, Mix, Playlist, Short, Now playing) become M3 roles too (e.g. container colours), chosen by the provider by role name instead of hex values.
2. Colour: palettes per site — done in 0.50.1, made at run time since 0.51.0
Changed in 0.51.0 (maintainer's decision, after the cost was measured): the library
is bundled instead of shipping tables: third_party/material-color-utilities/ (jsDelivr's
bundle of 0.3.0, wrapped as a plain script, ns.materialColor, Apache 2.0; the one
exception to "no libraries"). A provider gives theme.seed (YouTube: #FF0033) and
tokens.js makes the palette when VR starts (scheme(seed): 0.43 ms each; applying
YouTube's theme 1.7 ms once per session). Checked: the palettes made in the extension equal
the 0.50.1 tables exactly. scheme() is there for dynamic colour later (e.g. a palette
from the video's thumbnail: the library also has sourceColorFromImage). The baseline
table stays in tokens.js as the default when no seed is given or the library is missing.
The "pre-computed" notes below are kept for the record.
Done in 0.50.1: generated with Google's material-color-utilities 0.3.0 (the library
Material Theme Builder runs; SchemeTonalSpot, dark, contrast 0), in a scratch page in
the built-in browser: YouTube's palette from #FF0033 is theme.palette in
provider.js, and the core's default (M3's baseline seed #6750A4) is m3 in
tokens.js. Nothing draws from m3 yet (step 3 switches components over). Cost, for a
future "dynamic colour" idea: the library is ~66 KB, and one palette takes ~0.4 ms
(~5 ms the first time), so generating at run time would be cheap; it would just mean
bundling that Apache-2.0 library. To regenerate: in a page, import it from
https://cdn.jsdelivr.net/npm/@material/material-color-utilities@0.3.0/+esm, then for each
role hexFromArgb(MaterialDynamicColors[role].getArgb(new SchemeTonalSpot( Hct.fromInt(argbFromHex(seed)), true, 0))).
- Each site's provider ships its palette as a plain table of the colour roles above (dark scheme), generated once from a seed colour with Google's Material Theme Builder (export as JSON, paste into the provider). YouTube: seed YouTube Red #FF0033. The shell keeps a neutral default palette (e.g. a blue seed) for sites without one.
- Why not calculate at run time: M3's palettes come from the HCT colour space (CAM16), a few hundred lines of maths we'd have to write ourselves (no libraries). It's only worth it for dynamic colour (a palette from each video's thumbnail); add it then if wanted.
- The provider interface's
themegrows from{ accent, dark }to the palette table;xr-session.jsapplies it when VR starts, as now.
3. Restyle onto M3 (the visible change) — 3.1 controls panel (0.52.0), 3.2 toolbar (0.53.0), 3.3 part 1 Settings and tabs (0.54.0), part 2 chips, cards, paging, field (0.55.0), part 3 Description, Channel, thread, loading (0.56.0), 3.4 keyboard (0.57.0), 3.5 popup (0.58.0) done
Done in 0.52.0, the controls panel (panel-ui.js redrawn; every control id, hit area
and behaviour kept): tonal pink buttons with Material Symbols and a small label, the
button row as one group (play / pause bigger, brand red, squarer), the M3E slider for the
seek bar (brand red played part with chapter gaps, pink unplayed, bar handle; thicker while
pointed at, with the heatmap) and the volume slider, menus as tonal buttons with the
current one primary (deep red) and ticked, Subscribe in brand red, Like / Dislike as a
connected group (Like on: brand red; Dislike on: deep red), "Unsubscribe?" in
errorContainer (the M3 error is the same pink as the buttons here), LIVE as a brand red
chip (tonal and clickable when behind), Skip ad as an inverse-surface button, a white ring
on whatever is pointed at, and the grab bar as a small pill with a drag handle (brand red
while moving). The Format button shows the first line of the format's name ("Cinema",
"360°") and an icon per format (360° cubemap: a cube). The panel's background is the M3
surfaceContainer at 90% until the glass (step 4). Tokens gained: the scheme variant and
role swaps in a theme, brand / onBrand / hoverRing, and the first type roles and
corner sizes (font(), shape). Other panels unchanged (checked against 0.51).
Done in 0.53.0, the toolbar (chevrons-ui.js; chosen 2026-10-13 from a mock-up of
"icon + small label on every item" against "icons only", picked for looking clean): an M3E
floating toolbar (full-round surfaceContainer bar) of icon-only items. The open panel's
item is primary (deep red) with a filled icon and its name; the pointed-at item turns
tonal pink with the white ring and expands to show its name (220 ms, ease-out). Every item
grows around its own centre (neighbours slide outward), so the laser stays on it while it
expands. Three animations (0.53.1-0.53.3): how expanded the bar is (only while the laser is on
it) and which item has the expansion (handed over between items), so going from item to
item doesn't make the bar bulge, and which item is selected (a click fades the deep red
and the name over); time steps start fresh on a new change (at most 50 ms). Icons come
from the registration's new optional icon (YouTube: info,
account_circle, search, home, playlist_play, settings); an item without one always shows
its name. The canvas is taller (104 px, was 76), so the panels above sit ~2 cm higher.
Done in 0.54.0, step 3.3 part 1: Settings and the tabs. The kit's drawTabs (kit.js)
draws every tab row (Options, Info, Queue through headerTabs; Catalog, Profile through
grid.tabs): M3 tabs, the current one onSurface with a brand red indicator under its
content, the pointed-at one with an 8% onSurface tint and the white ring (hoverRing,
also in kit.js); a tab's optional icon sits above its label in a row at least 84 px tall
(Options: 92 px, equal widths), else beside it. The Options style (options.js) redrawn:
rows as M3 list items (name with its hint under it, the control at the right end), M3
switches (brand red), the M3E slider (300 px track at x 360), Default as a tonal pill with
restart_alt (150-216 px, as its label needs), steppers with round tonal arrows, buttons
(with an optional icon in the model) at the row's end, choices as tonal buttons (chosen:
primary with a check). A dim row is faded once (its disabled controls not again).
Settings' tab icons come from YouTube's settings.js. Still to come in 3.3: chips, cards,
paging, the text field (0.55); the thread, the Channel header, the loading indicator (0.56).
Tested in the headset 2026-10-13: works.
Done in 0.55.0, step 3.3 part 2: chips, cards, paging, the text field. kit.js gained
button (the M3 button every style now uses: tonal / filled brand red / outlined, icon and
label, white ring, pressed shape and state layer; Options' buttons go through it too).
Cards (drawCard): no tile at rest, surfaceContainerHighest tile and the white ring on
the pointed-at card, thumbnails shape.medium, the corner text on a dark full-round pill
(above the watched bar), the watched bar brand red, flags shape.small, text in
onSurface / onSurfaceVariant. grid.js: chips (shape.small; chosen: tonal with a
check; others outlined in outlineVariant), the field (full-round
surfaceContainerHighest, search icon, brand red outline and caret while focused, close
icon, filled brand Search button), round tonal page arrows (56 px), a 1 px
outlineVariant divider; magnifier is now the search symbol. Catalog: the M3
background, titleLargeEmphasized title, tabs kept inside the panel (a maxX past the
title), suggestions as an M3 menu (surfaceContainerHigh, tinted rows with the ring).
Queue: the M3 background, round arrows, tabs end before the page count. Profile: round
tonal search and back buttons (its header and background wait for part 3). YouTube's
Browse tabs got icons (browse.js ICONS). Tested in the headset 2026-10-13: works (Channel
and Description still partly old, as planned for part 3).
Done in 0.56.0, step 3.3 part 3: Description, Channel, the thread, loading. The Info,
Profile and thread styles moved to the M3 roles (surfaceContainer 90% background with
shape.extraLarge; links, timestamps, chapter times and "current" marks in tertiary, the
palette's light blue). kit.js gained hoverArea (an 8% / 12% state layer and the white
ring for plain clickable areas), chip / chipWidth (shared by grid.js and the thread's
bar) and a danger kind for button; buttonFill now gives M3 state layers (10% hover,
16% pressed), so every remaining hover lost the blue press tint. Profile: the action is a
kit button (primary → filled brand, secondary → tonal, danger → errorContainer, busy →
faded tonal), the name in titleLargeEmphasized, the featured card as a tile with the
ring, posts' 👍 👎 💬 and poll choices with the ring. Info: chapters on
surfaceContainerHigh tiles (current: tertiary tint and outline), transcript rows with
hoverArea, round tonal page arrows, tabs ending before the page count. Thread: creator
names on a tonal pill, expand_more / expand_less on the replies button, the footer slot
got area(id, x, y, w, h, r); YouTube's comment footer uses it and Material Symbols
thumbs. Loading: vrGlyphs.spinner is now the M3E loading indicator (a brand-colour shape
morphing through seven soft shapes every 0.65 s while turning; disc: the contained
version), used by the panels and the pause overlay. The unused thumb / speaker / skip
glyphs were removed. 0.56.1: the card grids (grid.js grid, a loadingAt clock) show it
too while their first page loads (Channel, Browse, Search; their keys redraw every 33 ms
meanwhile). Tested in the headset 2026-10-13: 0.56.0 and 0.56.1 work.
Done in 0.57.0, step 3.4: the keyboard (keyboard.js; chosen 2026-10-13 from a mock-up
of "pink keys, deep-red specials" against "neutral letters, pink specials", picked as
calmer under the video, like Gboard): character keys and the space bar on
surfaceContainerHighest (onSurface text), Backspace / Shift / ?123 / ABC / Hide tonal
(secondaryContainer), Enter and an active Shift filled brand; shape.large keys,
shape.small and a 0.12 state layer while pressed, the white ring while pointed at.
Material Symbols backspace, shift (filled while on; a bar under it for caps lock),
keyboard_hide, keyboard_return before "Enter", keyboard before "Typing into …". The
body on surfaceContainer 90%; the grab bar a 240 × 30 pill with a 64 × 5 handle (brand
while moving), its clickable zone kept 500 px wide. Tested in the headset 2026-10-13: works,
letters easy to read, the ring easy to see.
Done in 0.58.0, step 3.5: the popup (popup.css, panel.js; decided 2026-10-13 from
its mock-up): the M3 roles as CSS variables --m3-<role>, filled in from the page world's
palette (new bridge pair THEME_REQUEST / THEME_RESPONSE: main.js applies the
provider's theme to tokens.js and sends m3; the popup takes only plain role names and
#rrggbb values), with neutral fallbacks and the site's accent (ui/site.js) as the brand
until it arrives. surfaceContainer panel with 28 px corners, the goggles in brand by
the title, the format picker as a full-round filled select with a "Format" label,
passthrough as an M3 switch (a real checkbox underneath), Enter VR and Exit VR both filled
brand (the maintainer's choice: the popup's most important button), errors on
errorContainer, the help folded into a Controls <details> (the maintainer's choice) and
Details' tools as outlined pills. The popup closes once VR has started (the maintainer's
request); an error keeps it open with the message; reopened during VR it offers Exit VR.
Tested 2026-10-13: works.
With tokens in place, switch the values and the shapes to M3, one area per release, each checked in the headset:
- Controls panel: the button row as an M3E button group (tonal icon buttons in a size that suits the laser, the play / pause button larger and filled, shape change on press); the seek bar as the M3E slider (thick track, bar handle, chapter gaps); menus (quality, speed, format, volume) as M3 menus; Subscribe as a filled button and Like / Dislike as a connected button group with counts.
- Chevron row: an M3E floating toolbar (or a connected button group): one item per panel, the open one selected.
- Styles: tabs (M3 primary tabs, icon + label), pills (M3 filter / assist chips), cards (M3 filled cards), switches and sliders (M3 switch, M3E slider), steppers (tonal icon buttons), the Default buttons (tonal, with an icon), the text field (M3 filled text field), the comment thread, the Profile header. Loading: the M3E loading indicator (morphing shapes) instead of today's spinner; buffering / long loads: M3E wavy progress.
- Keyboard: M3E buttons as keys (tonal for letters, secondary for Shift / Backspace / Symbols / Hide, filled primary for Enter), with the shape change on press.
- Popup: the same tokens as CSS variables.
- Icons: Material Symbols (Apache 2.0), decided and started in 0.49.1:
src/core/page/vr-panel/symbols.jsholds 81 icons (Rounded style, outlined and filled) as path outlines copied from Google's SVGs, drawn withns.vrSymbols.draw(g, name, cx, cy, size, filled); license inthird_party/material-symbols/. Chosen over the icon font: no font loading from the page world, no CSP orweb_accessible_resources, any size and colour. To add one: copy thedofsymbols/web/<name>/materialsymbolsrounded/<name>_24px.svgand<name>_fill1_24px.svgfrom github.com/google/material-design-icons. Not used by the panels yet (step 3 replaces the hand-drawn glyphs inglyphs.js,panel-ui.js,keyboard.js,controllers/icons.js). YouTube's own marks (verified ✓, artist ♪) stay. Don't use Apple's SF Symbols or other Apple assets: the look is only inspired by visionOS.
4. Glass — done in 0.59.0, tint settled at 43% in 0.60.0
Camera passthrough (seeing your room through the glass) isn't available: confirmed in
0.49.1 on the Steam Frame with the Arcturus Vision module (immersive-ar not supported,
blend mode opaque; docs/hardware.md). Desktop Chrome doesn't enable WebXR's
immersive-ar for OpenXR headsets by default, and SteamVR offers apps no passthrough
through OpenXR. So the glass blurs the video, not the room. StreamXR is ready for it
(0.49.2: the popup's "See your room" checkbox, an immersive-ar session that clears to
transparent); once that works, the glass over the room can only be tinted, not blurred
(the camera image never reaches the page), so the glass needs a "no video behind" case:
tint + noise + rim only.
The cheap version (one change to the panel shader in panel-pass.js, no extra passes):
- Blur from mipmaps. The video's texture is already uploaded whenever the video has a
new frame (
video-sphere.js,cinema-screen.js). After each upload, rebuild its mipmaps (onegenerateMipmap; WebGL 2 allows non-power-of-two sizes). Sampling a small mip level gives a blurred copy; 4-8 taps around the point smooth the blockiness. - What's behind each panel pixel:
- 360° / 180° sphere: the direction from the eye through the pixel, turned into the video's map position (the same maths as the sphere's own shader, with its yaw).
- Cinema screen: where the ray from the eye through the pixel hits the screen's plane (or its curve); outside the screen, the dark void colour.
- The renderer gives the panel pass what it needs: the video texture, the format, the sphere's yaw or the screen's pose and size.
- On top: the tint (
glassTint, the M3 surface colour at an opacity), a small baked noise texture for the frosted grain (glassNoise), and a rim light: a thin gradient along the edges, brighter on top (glassRim), for the visionOS sheen. - Limits: other panels behind a panel aren't blurred (only the video is), which you rarely see. Content (text, cards) is drawn on the canvas over the glass as now.
- Readability: text over moving video is the real risk. visionOS darkens its glass and
makes text adapt; we tune
glassTintopacity for comfort first. Keep a "glass off" fallback (tint only, or solid) as a setting if it tires the eyes. - Cost: one mipmap rebuild per new video frame, and a few texture taps per panel pixel. If it's too slow at headset resolution: fewer taps, or tint + noise + rim without the blur.
Done in 0.59.0 (decided 2026-10-13: a 54% tint, between the 62% first chosen and a
lighter 45% compared in a mock-up; a switch and a live tint slider in a new Appearance
settings tab with the palette icon; on by default, the maintainer to settle the default
tint after looking in the headset). As planned above, with these specifics:
- Tokens:
ns.vrTokens.glass = { on, tint, DEFAULT_TINT: 0.54, version }andpanelFill()(the surface at the tint with the glass on, at 0.9 off), used by every panel's background (controls, toolbar, the five styles, the keyboard, the hover card).versionis in every panel's redraw key.appearance-options.jsremembers{ on, tint }(storageglass), the slider spanning 20-90%; the global reset restores the defaults. - Renderers:
upload()rebuilds the video texture's mipmaps while the glass is on (gl-utils.js videoMipmaps, WebGL 2 only);backdrop()gives the panel pass the texture and the mapping:sphere(span, yaw),eac, orscreen(centre, axes, size, bend), building the mipmaps at once if the glass was just turned on. - Shader (
panel-pass.js): the panel's mask is the canvas alpha over the tint; the ray from the eye through each pixel goes into the sphere (equirectangular or cubemap) or the screen (a plane, or the far side of the bend's circle); five taps with a mipmap bias of 5 make the blur; outside the screen, a dark void colour; then the canvas over it, a hashed grain (±1.5%) and a rim (0.32 along the top edge, 0.08 elsewhere). The hover card has no backdrop (it sits in front of the controls panel); passthrough sessions get none (the room can't be blurred), so the tint alone shows there. - Checked: the real renderers and shader in a WebGL 2 canvas (a video frame as the video, a fake eye): no GL errors for 360°, cubemap, flat and curved screens; the panel takes on the video's colour behind it.
0.60.0: tested in the headset (smooth); the maintainer set the default tint to 43%
(DEFAULT_TINT). The popup got a "Glass panels" switch above Enter VR (the maintainer's
request: turn the glass off before VR on a weaker PC): it reads and writes the same
remembered { on, tint } (streamxr.glass in the page's localStorage), and
appearance-options.js load() runs again at each VR start (xr-session.js), so a change
made in the popup applies from the next Enter VR. Tested 2026-10-13: works.
5. Motion — done: part 1 (press and hover) 0.61.0, part 2 (open / fold) 0.62.0
Done in 0.61.0, part 1: press and hover (the maintainer chose to judge it in the headset
rather than tune it in a browser demo). motion.js: one tracker per panel canvas
(controls panel, keyboard, each style) steps two springs per control at the start of each
draw: hover with M3E's fast effects spring (damping 1.0, stiffness 3800: about 0.1 s, no
bounce) and press with the fast spatial spring (damping 0.6, stiffness 800: a little bounce),
and attaches ui.anim(id) -> { h, p }; ns.vrMotion.of(ui, id) reads it (or 1 / 0 without
a tracker). The kit's button, chip, hoverArea, hoverRing (now with an amount), tabs and
cards, Options' switch, slider and choices, the controls panel's buttons and the keyboard's
keys draw from the amounts (corners lerped from rest to pressed, state layers and the ring
scaled). Each canvas reports animating(), and vr-panel.js adds the clock to its redraw key
meanwhile (every frame, only while easing). Time steps start from zero after a pause and are
capped at 50 ms. Measured at 90 Hz: the ring fades in in ~0.1 s; a 100 ms click squares the
corners almost fully and springs back within ~0.3 s. Still to do (part 2, 0.62): open /
fold of panels, menus and the keyboard swap. Tested 2026-10-13: works; 0.61.1 draws the
controls panel's rings after all its buttons (Dislike covered part of Like's ring); tested, works.
Done in 0.62.0, part 2: open / fold. motion.js gained an open spring (M3E default
spatial: damping 0.8, stiffness 380) and value(spring, x): one eased value stepped by its
owner. vr-panel.js keeps anim = { vis, kb, open: Map }, stepped each frame in update
(stepAnims) towards visible, kbShown and the open panel; drawPanel draws every
surface with an alpha (new alpha argument to the panel pass, uAlpha in the shader)
and a scale of 0.92-1: the stack with vis, the controls panel and keyboard cross-fading
with kb (the stack's hinge follows the eased height, restack each frame it moves), each
panel above scaled about its bottom edge with its own open. Settled surfaces are drawn
first with depth writes; fading ones after, without. panel-ui.js eases its bottom row (the
transport buttons or a menu) whenever it changes (stepRow: faded in, risen 14 px; ~0.4 s),
and the rings it collects keep the row's fade and shift. Hit tests and input follow the
real state at once. Checked with the real vr-panel.js in WebGL 2 at 90 Hz: no GL errors;
the swap and hide / show mid-way show the cross-fade and fade. Tested 2026-10-13: works ("looks
beautiful"). The redesign is complete. Next: the minor UI quirks the maintainer noted along
the way (see "Tested" under "To test in the headset").
Today every state change is instant except the toolbar's expansion (0.53): a press squares a button's or key's corners and adds its state layer at once, and the ring appears at once. This step animates them all the same way, with the M3E springs (see "Motion" in step 1), so it's done once for every component rather than one by one:
- Press: the corners morph from round to square and back, and the state layer fades (buttons, keys of the VR keyboard (asked for after 0.57.0), chips, tabs, cards, the toolbar).
- Hover: the white ring and tints fade in and out.
- Open / fold: panels, menus and the keyboard swap ease in and out.
- Mechanism: a shared per-control animation clock (as
chevrons-ui.jsdoes), with a style redrawing while any spring is still settling.
How to check each step
- Step 1: canvas hashes identical to before (scratchpad copy of the old files, the same mock models), as in every earlier drawing refactor.
- Steps 2-4: renders of every panel with mock data for review, then the maintainer tests in the headset; record the new look in design-choices.md as it lands.
Related plans
- StreamXR / BrowseXR prep (Future-Plans.md): the rename, the site
registry, the folder split and the media source were done in 0.49.0, so the token layer
goes straight into
src/core/(vr-panel/), and the glass shader intosrc/core/page/vr-panel/panel-pass.jswith what it needs from the video coming fromsrc/streamxr/(the renderers). The message-shaped provider boundary is still to do and independent of the redesign.