Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ The noise field renders thousands of dots with per-frame brightness calculations
| **Base opacity** | 0.35 (hero), 0.5 (loading states) | Visible but subordinate to content |
| **Bloom** | Gaussian glow (sigma 6px) on all visible dots, intensity scales with brightness | Soft halos create overlapping luminous field |
| **Color** | HSL with hue from `djb2(route.path) % 360 / 360`, saturation 0.35 + brightness x 0.15, lightness 0.4 + brightness x 0.3 | Slightly warmer and brighter than original CLI spec for screen visibility |
| **Frame rate** | Capped at display refresh via `requestAnimationFrame` | No `setInterval`; GPU-synced |
| **Frame rate** | Display refresh via `requestAnimationFrame` for the 8s intro and while the cursor is over the field; 15 fps for the ambient shimmer after that | No `setInterval`; GPU-synced. The shimmer steps at 4 Hz, so 15 fps reads the same |
| **Canvas sizing** | `devicePixelRatio`-aware, resize via `ResizeObserver` | Crisp on retina, no layout thrash |

### Interactivity
Expand All @@ -338,8 +338,9 @@ The noise field renders thousands of dots with per-frame brightness calculations

- **GPU memory**: one framebuffer (bloom pass) + dot instance buffer. Under 2MB total
- **CPU per frame**: uniform update only (~0.01ms). All computation in shaders
- **Startup**: shader compilation happens once on mount; first frame within 16ms on modern GPUs
- **Startup**: the field starts after hydration, once the main thread is idle (`onNuxtReady`). It is decoration and never competes with first paint. Shader compilation happens once
- **Fallback**: if `WebGL2` is unavailable (rare), render a static SVG dot pattern at ambient shimmer brightness. No animation, no glow. Still warm, still branded
- **Software renderer**: if WebGL runs on the CPU (SwiftShader, llvmpipe, no GPU driver), every frame is main-thread work. Draw one static frame at ambient shimmer state, at 1x, like reduced motion
- **Visibility**: pause the animation loop when the canvas is offscreen (`IntersectionObserver`) or the tab is hidden (`document.visibilityState`). Zero GPU cost when not visible

### Reduced motion
Expand Down
106 changes: 81 additions & 25 deletions app/components/NoiseField.client.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useDocumentVisibility, useElementSize, useMouseInElement, useRafFn } from '@vueuse/core'
import { isUsableWebGL2Context } from '../utils/webgl-context'
import { isSoftwareRenderer, isUsableWebGL2Context, webglRendererName } from '../utils/webgl-context'

const props = withDefaults(defineProps<{
opacity?: number
Expand Down Expand Up @@ -212,24 +212,61 @@ const uniforms = {
let startTime = 0
let intersectionObserver: IntersectionObserver | null = null

function initGL() {
// The rings have faded by now and only the ambient shimmer moves. Its jitter
// steps at 4 Hz, so a capped frame rate keeps it smooth for a fraction of the
// GPU and main thread work. The cursor glow still runs at the display rate.
const INTRO_SECONDS = 8
const AMBIENT_FPS = 15

const CONTEXT_ATTRIBUTES: WebGLContextAttributes = {
alpha: true,
premultipliedAlpha: false,
antialias: false,
powerPreference: 'low-power',
}

/**
* A browser without GPU acceleration (no GPU, a blocklisted driver, most
* headless browsers) runs WebGL on the CPU, and each frame of a full-bleed
* shader becomes a long task on the main thread. Some browsers refuse that
* context under `failIfMajorPerformanceCaveat`; Chrome hands out SwiftShader
* anyway, so the renderer name decides too. Either way the field draws one
* static frame.
*/
type FieldContext
= | { _tag: 'accelerated', gl: WebGL2RenderingContext }
| { _tag: 'software', gl: WebGL2RenderingContext }

function createContext(canvas: HTMLCanvasElement): FieldContext | null {
const accelerated = canvas.getContext('webgl2', { ...CONTEXT_ATTRIBUTES, failIfMajorPerformanceCaveat: true })
if (isUsableWebGL2Context(accelerated))
return { _tag: isSoftwareRenderer(webglRendererName(accelerated)) ? 'software' : 'accelerated', gl: accelerated }
const software = canvas.getContext('webgl2', CONTEXT_ATTRIBUTES)
return isUsableWebGL2Context(software) ? { _tag: 'software', gl: software } : null
}

const isStatic = ref(false)
const introDone = ref(false)
// Software rendering pays per pixel on the CPU, so it draws at 1x.
let maxDpr = 2

function canvasDpr(): number {
return Math.min(window.devicePixelRatio || 1, maxDpr)
}

function initGL(): FieldContext['_tag'] | null {
const canvas = canvasRef.value
if (!canvas)
return false

const context = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: false,
antialias: false,
powerPreference: 'low-power',
})
if (!isUsableWebGL2Context(context))
return false
gl = context
return null

const context = createContext(canvas)
if (!context)
return null
gl = context.gl

program = createProgram(gl, VERT, FRAG)
if (!program)
return false
return null

vao = gl.createVertexArray()

Expand All @@ -243,14 +280,14 @@ function initGL() {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.clearColor(0, 0, 0, 0)

return true
return context._tag
}

function resizeCanvas() {
const canvas = canvasRef.value
if (!canvas || !gl)
return
const dpr = window.devicePixelRatio || 1
const dpr = canvasDpr()
const w = Math.round(width.value * dpr)
const h = Math.round(height.value * dpr)
if (canvas.width !== w || canvas.height !== h) {
Expand All @@ -266,8 +303,10 @@ function drawFrame() {

resizeCanvas()

const dpr = window.devicePixelRatio || 1
const t = prefersReducedMotion.value ? 3.0 : (Date.now() - startTime) / 1000
const dpr = canvasDpr()
const t = isStatic.value ? 3.0 : (Date.now() - startTime) / 1000
if (t > INTRO_SECONDS)
introDone.value = true

gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(program)
Expand All @@ -280,7 +319,7 @@ function drawFrame() {
gl.uniform1f(uniforms.u_dpr, dpr)
gl.uniform1f(uniforms.u_dark, isDark.value ? 1.0 : 0.0)

if (isOutside.value || prefersReducedMotion.value) {
if (isOutside.value || isStatic.value) {
gl.uniform2f(uniforms.u_mouse, -1, -1)
}
else {
Expand All @@ -296,17 +335,33 @@ const { pause, resume } = useRafFn(() => {
if (visibility.value === 'hidden' || !isVisible.value)
return
drawFrame()
}, { immediate: false })
}, {
immediate: false,
fpsLimit: () => introDone.value && isOutside.value ? AMBIENT_FPS : null,
})

// A static frame has no loop, so it redraws when its box or theme changes.
watch([width, height, isDark], () => {
if (isStatic.value)
drawFrame()
})

let disposed = false

// Wait for canvas ref (client component timing)
watch(canvasRef, (canvas) => {
if (!canvas || gl)
// The field is decoration. It starts once the page has hydrated and the main
// thread is idle, so it never competes with the first paint or hydration.
onNuxtReady(() => {
if (disposed || gl)
return

if (!initGL())
const mode = initGL()
if (!mode)
return

if (prefersReducedMotion.value) {
if (mode === 'software')
maxDpr = 1
if (mode === 'software' || prefersReducedMotion.value) {
isStatic.value = true
drawFrame()
return
}
Expand All @@ -323,6 +378,7 @@ watch(canvasRef, (canvas) => {
})

onBeforeUnmount(() => {
disposed = true
pause()
intersectionObserver?.disconnect()
if (gl) {
Expand Down
36 changes: 24 additions & 12 deletions app/components/SkillSourceList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SkillSourceItem } from '../types/skill-source'
import {
useActiveElement,
useElementHover,
useElementVisibility,
useFocusWithin,
usePreferredReducedMotion,
useRafFn,
Expand Down Expand Up @@ -64,19 +65,28 @@ watch(hasFocusedItem, (focused) => {
pendingItems.value = null
})

useRafFn(({ delta }) => {
const isOnScreen = useElementVisibility(viewport)
// The scroll writes layout on every frame, so it waits until the page has
// hydrated and idles, and stops whenever nobody can see it.
const pageReady = ref(false)
onNuxtReady(() => {
pageReady.value = true
})
const scrolling = computed(() =>
variant === 'stream'
&& autoScroll
&& pageReady.value
&& isOnScreen.value
&& !isHovered.value
&& !hasFocusedItem.value
&& !manuallyPaused.value
&& reducedMotion.value !== 'reduce',
)

const { pause, resume } = useRafFn(({ delta }) => {
const element = viewport.value
if (
!element
|| variant !== 'stream'
|| !autoScroll
|| isHovered.value
|| hasFocusedItem.value
|| manuallyPaused.value
|| reducedMotion.value === 'reduce'
) {
if (!element)
return
}

const maxScroll = element.scrollHeight - element.clientHeight
if (maxScroll <= 0)
Expand All @@ -89,7 +99,9 @@ useRafFn(({ delta }) => {

const distance = Math.min(delta, 64) * 0.018 * scrollDirection.value
element.scrollTop = Math.min(maxScroll, Math.max(0, element.scrollTop + distance))
}, { fpsLimit: 30, immediate: autoScroll })
}, { fpsLimit: 30, immediate: false })

watch(scrolling, run => run ? resume() : pause(), { immediate: true })

function itemKey(item: SkillSourceItem): string {
return `${item.owner}/${item.repo}/${item.name}`
Expand Down
21 changes: 21 additions & 0 deletions app/utils/webgl-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,24 @@ export function isUsableWebGL2Context(value: unknown): value is WebGL2RenderingC
return requiredWebGL2Methods.every(name => typeof candidate[name] === 'function')
&& requiredWebGL2Constants.every(name => typeof candidate[name] === 'number')
}

/**
* Renderers that run WebGL on the CPU: Chrome's SwiftShader fallback, Mesa's
* llvmpipe and softpipe, and Windows without a GPU driver. On these, every
* frame of a full-bleed shader is main-thread work.
*/
const SOFTWARE_RENDERER = /swiftshader|llvmpipe|softpipe|basic render driver/i

export function isSoftwareRenderer(renderer: string): boolean {
return SOFTWARE_RENDERER.test(renderer)
}

/**
* The GPU name when the browser exposes it. Firefox and Safari may mask it;
* a masked name reads as a GPU, and `failIfMajorPerformanceCaveat` covers them.
*/
export function webglRendererName(gl: WebGL2RenderingContext): string {
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
const name: unknown = gl.getParameter(debugInfo ? debugInfo.UNMASKED_RENDERER_WEBGL : gl.RENDERER)
return typeof name === 'string' ? name : ''
}
12 changes: 12 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,10 @@ export default defineNuxtConfig({
// inside the 36000s nuxt-skew-protection keeps old chunks for.
// `scripts/check-edge-cache.ts` proves it after each deploy.
'/skills/trending': edgeCache({ maxAge: 60, staleWhileRevalidate: 3600 }),
// The homepage is the same shape as the board: rendered signed out, no
// cookie read, and fed by the same feeds, so the same lifetimes hold. A
// render measured 300 to 480 ms to first byte from Sydney on 2026-10-01.
'/': edgeCache({ maxAge: 60, staleWhileRevalidate: 3600 }),
// Raw markdown and the typeahead index deliberately keep browser caching
// too: both are large, identical for everyone, and only change when the
// registry does.
Expand Down Expand Up @@ -696,6 +700,14 @@ export default defineNuxtConfig({
},
},

ui: {
// Nuxt UI otherwise writes a theme file for every component it ships, and
// Tailwind emits their variant classes into the render-blocking entry
// stylesheet. Detection keeps the components this app renders. Every
// component here is named statically, so nothing needs listing.
experimental: { componentDetection: true },
},

fonts: {
defaults: {
subsets: ['latin'],
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-edge-cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { checkEdgeCache } from './lib/edge-cache-check'

/**
* Prove the trending pages are cached at the edge and safe to cache.
* Prove the homepage and the trending pages are cached at the edge and safe to cache.
*
* pnpm production:edge-cache
* EDGE_CACHE_BASE_URL=https://skilld.dev pnpm production:edge-cache
Expand Down
2 changes: 1 addition & 1 deletion scripts/lib/edge-cache-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { varyKeysEdgeCache } from '../../shared/content-negotiation'
import { DEFAULT_TRENDING_RANGE } from '../../shared/trending-range'

/** The pages `nuxt.config.ts` gives an `edgeCache` rule. Each query is its own cache key. */
export const EDGE_CACHED_PATHS = ['/skills/trending', '/skills/trending?range=month', '/skills/trending?range=all']
export const EDGE_CACHED_PATHS = ['/', '/skills/trending', '/skills/trending?range=month', '/skills/trending?range=all']

const BROWSER_HEADERS = {
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
Expand Down
10 changes: 10 additions & 0 deletions test/unit/edge-cache-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ describe('checkEdgeCache', () => {
})
})

it('expects the homepage to send Markdown readers to /index.md', async () => {
const homeEdge = (location: string) => edge({ markdown: () => ({ status: 307, headers: { location }, body: '' }) })

expect(await run(homeEdge('/index.md'), ['/'])).toMatchObject({ _tag: 'passed' })
expect(await run(homeEdge('/.md'), ['/'])).toMatchObject({
_tag: 'failed',
failures: [{ _tag: 'markdown-location', path: '/', actual: '/.md' }],
})
})

it('fails when two boards render one page, as a cache key that ignores the query string would', async () => {
const paths = ['/skills/trending', '/skills/trending?range=month', '/skills/trending?range=all']
const result = await run(edge({
Expand Down
24 changes: 23 additions & 1 deletion test/unit/webgl-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { isUsableWebGL2Context } from '../../app/utils/webgl-context'
import { isSoftwareRenderer, isUsableWebGL2Context } from '../../app/utils/webgl-context'

const requiredMethodNames = [
'attachShader',
Expand Down Expand Up @@ -57,3 +57,25 @@ describe('isUsableWebGL2Context', () => {
expect(isUsableWebGL2Context(contextStub())).toBe(true)
})
})

describe('isSoftwareRenderer', () => {
it.each([
'ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)',
'llvmpipe (LLVM 15.0.7, 256 bits)',
'ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0, D3D11)',
])('names %s a CPU renderer', (renderer) => {
expect(isSoftwareRenderer(renderer)).toBe(true)
})

it.each([
'ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)',
// Mesa's hardware driver names its LLVM shader compiler too.
'ANGLE (AMD, AMD Radeon RX 9070 XT (radeonsi gfx1201 LLVM 20.1.2), OpenGL 4.6)',
'Apple GPU',
'Adreno (TM) 640',
'Mali-G78',
'WebKit WebGL',
])('names %s a GPU renderer', (renderer) => {
expect(isSoftwareRenderer(renderer)).toBe(false)
})
})