Zum Inhalt

Physarum Web Component Player Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build an embeddable <physarum-player> custom element (plus a hosted player.html embed route and an editor "copy embed code" button) that plays any Physarum preset purely visually.

Architecture: Ansatz A — a framework-agnostic PlayerCore (engine lifecycle, rAF loop, preset resolution, playlist cycle, visibility pause, poster fallback) wrapped by a thin custom element. No Solid in the embeddable bundle. The existing Engine, editor app, and stores stay untouched. The editor bridges to the player via the existing msgpack share token.

Tech Stack: TypeScript strict, Vite 5 (separate lib build for the element + multi-page input for the embed route), WebGPU Engine, Zod (PresetSchema/PresetParamsSchema), msgpack share token, Vitest (pure logic), Playwright (GPU/DOM smoke).

Conventions to honour: Conventional Commits without emoji; path alias ~/* → src/*; noUncheckedIndexedAccess + no any; never --no-verify (lefthook runs biome check --apply + tsc --noEmit). Run tests with npm test, typecheck with npm run typecheck, lint with npm run lint.

Spec: docs/superpowers/specs/2026-05-28-physarum-web-component-player-design.md


File Structure

File Responsibility
src/player/constants.ts Canonical host, bundle URL, built-in poster URL helper
src/player/types.ts ControlsMode, event detail types, PlayerCallbacks
src/player/playlist.ts Pure next/prev index math
src/player/resolvePreset.ts Resolve a source string (token/builtin/url/json) → Preset
src/player/PlayerCore.ts Adapter+Engine lifecycle, rAF loop, resize/visibility, playlist cycle
src/player/ControlsOverlay.ts Plain-DOM controls (play/pause, prev/next, fullscreen)
src/player/PhysarumPlayer.ts Custom element: shadow DOM, attribute/property mapping, poster fallback
src/player/element.ts Bundle entry: customElements.define('physarum-player', …)
src/player/embed-page.ts Embed route entry: URL query → configured <physarum-player>
src/share/embedCode.ts Build the <physarum-player preset="token:…"> snippet
player.html Second Vite input (embed route page)
vite.element.config.ts Standalone element library build → dist-player/physarum-player.js
tests/player/playlist.test.ts Unit: playlist math
tests/player/resolvePreset.test.ts Unit: source resolution
tests/share/embedCode.test.ts Unit: snippet round-trips
tests/e2e/player.spec.ts Smoke: element boots, draws, playlist advances

Modified: vite.config.ts (multi-page input), package.json (build:element script), .gitignore (dist-player), src/ui/SettingsMenu.tsx + src/App.tsx (embed button), .forgejo/workflows/deploy.yml (publish bundle), README.md + AGENTS.md (docs/status).


Task 1: Scaffolding — constants + types

Files: - Create: src/player/constants.ts - Create: src/player/types.ts

These are pure declarations (no behaviour) — no test, verified by typecheck.

  • [ ] Step 1: Create src/player/constants.ts
// src/player/constants.ts
// Canonical deploy host — built-in posters and the player bundle live here so
// an embed on a third-party site resolves assets without extra config.
export const CANONICAL_HOST = 'https://pages.jkaindl.de/physarum';

export const PLAYER_BUNDLE_URL = `${CANONICAL_HOST}/physarum-player.js`;

/** Built-in poster URL for a preset id under the given asset base. */
export function builtinPosterUrl(id: string, assetBase: string): string {
  const base = assetBase.replace(/\/+$/, '');
  return `${base}/preset-thumbs/${id}.png`;
}
  • [ ] Step 2: Create src/player/types.ts
// src/player/types.ts
export type ControlsMode = 'auto' | 'always' | 'none';

export interface PresetChangeDetail {
  index: number;
  id: string;
  name: string;
}

export interface PlayerErrorDetail {
  reason: 'no-webgpu' | 'preset-failed';
}

export interface PlayerCallbacks {
  onReady?: () => void;
  onPresetChange?: (detail: PresetChangeDetail) => void;
  onError?: (detail: PlayerErrorDetail) => void;
}
  • [ ] Step 3: Typecheck

Run: npm run typecheck Expected: PASS (no errors).

  • [ ] Step 4: Commit
git add src/player/constants.ts src/player/types.ts
git commit -m "feat(player): scaffold constants and shared types"

Task 2: Playlist index math (TDD)

Files: - Create: src/player/playlist.ts - Test: tests/player/playlist.test.ts

  • [ ] Step 1: Write the failing test
// tests/player/playlist.test.ts
import { describe, expect, it } from 'vitest';
import { nextIndex, prevIndex } from '~/player/playlist';

describe('nextIndex', () => {
  it('advances within bounds', () => {
    expect(nextIndex(0, 3, false)).toBe(1);
    expect(nextIndex(1, 3, false)).toBe(2);
  });
  it('wraps to 0 at the end when loop is on', () => {
    expect(nextIndex(2, 3, true)).toBe(0);
  });
  it('returns null at the end when loop is off', () => {
    expect(nextIndex(2, 3, false)).toBeNull();
  });
  it('returns null for an empty list', () => {
    expect(nextIndex(0, 0, true)).toBeNull();
  });
});

describe('prevIndex', () => {
  it('steps back within bounds', () => {
    expect(prevIndex(2, 3, false)).toBe(1);
  });
  it('wraps to the last index when loop is on', () => {
    expect(prevIndex(0, 3, true)).toBe(2);
  });
  it('returns null at the start when loop is off', () => {
    expect(prevIndex(0, 3, false)).toBeNull();
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run tests/player/playlist.test.ts Expected: FAIL — cannot resolve ~/player/playlist.

  • [ ] Step 3: Write minimal implementation
// src/player/playlist.ts
// Pure playlist index math — no DOM, no GPU. Returns the new index, wrapping
// when loop is on, or null when an end is reached and loop is off (signals the
// cycle to stop).
export function nextIndex(current: number, length: number, loop: boolean): number | null {
  if (length <= 0) return null;
  if (current + 1 < length) return current + 1;
  return loop ? 0 : null;
}

export function prevIndex(current: number, length: number, loop: boolean): number | null {
  if (length <= 0) return null;
  if (current - 1 >= 0) return current - 1;
  return loop ? length - 1 : null;
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run tests/player/playlist.test.ts Expected: PASS.

  • [ ] Step 5: Commit
git add src/player/playlist.ts tests/player/playlist.test.ts
git commit -m "feat(player): playlist index math"

Task 3: Preset source resolution (TDD)

Files: - Create: src/player/resolvePreset.ts - Test: tests/player/resolvePreset.test.ts

Background: decodePreset(token) is in src/share/encodePreset.ts; encodePreset(preset) produces the token. BUILT_IN_PRESETS and DEFAULT_PRESET are in src/presets/builtIn.ts. PresetSchema validates a full Preset; PresetParamsSchema validates bare params — both in src/presets/schema.ts.

  • [ ] Step 1: Write the failing test
// tests/player/resolvePreset.test.ts
import { describe, expect, it } from 'vitest';
import { DEFAULT_PRESET } from '~/presets/builtIn';
import { parseSource, resolvePreset } from '~/player/resolvePreset';
import { encodePreset } from '~/share/encodePreset';

describe('parseSource', () => {
  it('splits an explicit prefix', () => {
    expect(parseSource('builtin:dreamy')).toEqual({ kind: 'builtin', value: 'dreamy' });
    expect(parseSource('token:abc')).toEqual({ kind: 'token', value: 'abc' });
  });
  it('auto-detects a known built-in id', () => {
    expect(parseSource(DEFAULT_PRESET.id)).toEqual({ kind: 'builtin', value: DEFAULT_PRESET.id });
  });
  it('treats an unknown prefixless string as a token', () => {
    expect(parseSource('zzz-not-an-id')).toEqual({ kind: 'token', value: 'zzz-not-an-id' });
  });
});

describe('resolvePreset', () => {
  it('resolves a built-in by id', async () => {
    const p = await resolvePreset(`builtin:${DEFAULT_PRESET.id}`);
    expect(p?.id).toBe(DEFAULT_PRESET.id);
  });
  it('returns null for an unknown built-in id', async () => {
    expect(await resolvePreset('builtin:does-not-exist')).toBeNull();
  });
  it('round-trips a share token', async () => {
    const token = encodePreset(DEFAULT_PRESET);
    const p = await resolvePreset(`token:${token}`);
    expect(p?.params.agentCount).toBe(DEFAULT_PRESET.params.agentCount);
    expect(p?.builtIn).toBe(false);
  });
  it('returns null for a malformed token', async () => {
    expect(await resolvePreset('token:!!!not-base64!!!')).toBeNull();
  });
  it('resolves inline json of a full preset', async () => {
    const p = await resolvePreset(`json:${JSON.stringify(DEFAULT_PRESET)}`);
    expect(p?.id).toBe(DEFAULT_PRESET.id);
  });
  it('resolves inline json of bare params (wrapped)', async () => {
    const p = await resolvePreset(`json:${JSON.stringify(DEFAULT_PRESET.params)}`);
    expect(p?.params.agentCount).toBe(DEFAULT_PRESET.params.agentCount);
    expect(p?.builtIn).toBe(false);
  });
  it('returns null for invalid json', async () => {
    expect(await resolvePreset('json:{ not valid')).toBeNull();
  });
  it('fetches a url via an injected fetch', async () => {
    const fetchFn = (async () =>
      new Response(JSON.stringify(DEFAULT_PRESET), { status: 200 })) as unknown as typeof fetch;
    const p = await resolvePreset('url:https://example.test/p.json', { fetchFn });
    expect(p?.id).toBe(DEFAULT_PRESET.id);
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run tests/player/resolvePreset.test.ts Expected: FAIL — cannot resolve ~/player/resolvePreset.

  • [ ] Step 3: Write minimal implementation
// src/player/resolvePreset.ts
import type { Preset } from '~/engine/types';
import { BUILT_IN_PRESETS } from '~/presets/builtIn';
import { PresetParamsSchema, PresetSchema } from '~/presets/schema';
import { decodePreset } from '~/share/encodePreset';

export type SourceKind = 'token' | 'builtin' | 'url' | 'json';

export interface ParsedSource {
  kind: SourceKind;
  value: string;
}

const PREFIXES: readonly SourceKind[] = ['token', 'builtin', 'url', 'json'];

function isBuiltinId(id: string): boolean {
  return BUILT_IN_PRESETS.some((p) => p.id === id);
}

/** Splits an explicit `kind:value` prefix, or auto-detects when none: a known
 *  built-in id wins, otherwise the string is treated as a share token. */
export function parseSource(raw: string): ParsedSource {
  const trimmed = raw.trim();
  const colon = trimmed.indexOf(':');
  if (colon > 0) {
    const maybe = trimmed.slice(0, colon) as SourceKind;
    if (PREFIXES.includes(maybe)) return { kind: maybe, value: trimmed.slice(colon + 1) };
  }
  return isBuiltinId(trimmed)
    ? { kind: 'builtin', value: trimmed }
    : { kind: 'token', value: trimmed };
}

/** Validates arbitrary data as a full Preset, or as bare PresetParams which get
 *  wrapped in a minimal envelope. Returns null on failure. */
function coerceToPreset(data: unknown): Preset | null {
  const asPreset = PresetSchema.safeParse(data);
  if (asPreset.success) return { ...(asPreset.data as Preset), builtIn: false };
  const asParams = PresetParamsSchema.safeParse(data);
  if (asParams.success) {
    return {
      id: 'custom',
      name: 'Custom',
      builtIn: false,
      tags: [],
      tagSignal: 'pearl',
      params: asParams.data,
    } as Preset;
  }
  return null;
}

export interface ResolveOptions {
  fetchFn?: typeof fetch;
}

export async function resolvePreset(
  source: string,
  opts: ResolveOptions = {},
): Promise<Preset | null> {
  const { kind, value } = parseSource(source);
  try {
    switch (kind) {
      case 'builtin': {
        const hit = BUILT_IN_PRESETS.find((p) => p.id === value);
        return hit ? { ...hit } : null;
      }
      case 'token':
        return decodePreset(value);
      case 'json':
        return coerceToPreset(JSON.parse(value));
      case 'url': {
        const f = opts.fetchFn ?? fetch;
        const res = await f(value);
        if (!res.ok) return null;
        return coerceToPreset(await res.json());
      }
    }
  } catch {
    return null;
  }
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run tests/player/resolvePreset.test.ts Expected: PASS.

  • [ ] Step 5: Commit
git add src/player/resolvePreset.ts tests/player/resolvePreset.test.ts
git commit -m "feat(player): resolve preset sources (token/builtin/url/json)"

Task 4: Editor embed-code snippet (TDD)

Files: - Create: src/share/embedCode.ts - Test: tests/share/embedCode.test.ts

  • [ ] Step 1: Write the failing test
// tests/share/embedCode.test.ts
import { describe, expect, it } from 'vitest';
import { DEFAULT_PRESET } from '~/presets/builtIn';
import { decodePreset } from '~/share/encodePreset';
import { embedSnippet } from '~/share/embedCode';

describe('embedSnippet', () => {
  it('produces a <physarum-player> tag with a round-trippable token', () => {
    const snippet = embedSnippet(DEFAULT_PRESET);
    expect(snippet).toContain('<physarum-player');
    const match = snippet.match(/preset="token:([^"]+)"/);
    expect(match).not.toBeNull();
    const decoded = decodePreset(match?.[1] ?? '');
    expect(decoded?.params.agentCount).toBe(DEFAULT_PRESET.params.agentCount);
  });
  it('honours a custom bundle url', () => {
    const snippet = embedSnippet(DEFAULT_PRESET, { bundleUrl: 'https://x.test/p.js' });
    expect(snippet).toContain('src="https://x.test/p.js"');
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run tests/share/embedCode.test.ts Expected: FAIL — cannot resolve ~/share/embedCode.

  • [ ] Step 3: Write minimal implementation
// src/share/embedCode.ts
import type { Preset } from '~/engine/types';
import { PLAYER_BUNDLE_URL } from '~/player/constants';
import { encodePreset } from './encodePreset';

export interface EmbedOptions {
  bundleUrl?: string;
}

/** Builds a ready-to-paste embed snippet: a module script tag loading the
 *  player bundle plus a <physarum-player> carrying the preset as a
 *  self-contained share token. */
export function embedSnippet(preset: Preset, opts: EmbedOptions = {}): string {
  const token = encodePreset(preset);
  const src = opts.bundleUrl ?? PLAYER_BUNDLE_URL;
  return (
    `<script type="module" src="${src}"></script>\n` +
    `<physarum-player preset="token:${token}"></physarum-player>`
  );
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run tests/share/embedCode.test.ts Expected: PASS.

  • [ ] Step 5: Commit
git add src/share/embedCode.ts tests/share/embedCode.test.ts
git commit -m "feat(share): embed-code snippet builder"

Task 5: PlayerCore (engine lifecycle + loop)

Files: - Create: src/player/PlayerCore.ts

GPU/timing glue — exercised by the Playwright smoke in Task 11, not Vitest. Verified here by typecheck. Engine API: new Engine({ adapter, canvas, initialPreset }), tick(), setPreset(params), respawn(), resize(w,h), dispose(). requestAdapter() and isWebGPUAvailable() are in src/engine/adapter.ts.

  • [ ] Step 1: Create src/player/PlayerCore.ts
// src/player/PlayerCore.ts
import { type AdapterResult, isWebGPUAvailable, requestAdapter } from '~/engine/adapter';
import { Engine } from '~/engine/Engine';
import type { Preset } from '~/engine/types';
import { nextIndex, prevIndex } from './playlist';
import { resolvePreset } from './resolvePreset';
import type { PlayerCallbacks } from './types';

export interface PlayerCoreOptions {
  sources: string[];
  intervalMs: number;
  loop: boolean;
  autoplay: boolean;
}

export class PlayerCore {
  private readonly canvas: HTMLCanvasElement;
  private readonly cb: PlayerCallbacks;
  private engine: Engine | null = null;
  private adapter: AdapterResult | null = null;

  private sources: string[] = [];
  private index = 0;
  private intervalMs = 30_000;
  private loop = true;
  private current: Preset | null = null;

  private rafId = 0;
  private running = false; // loop actively ticking
  private userPaused = false; // explicit pause() by user
  private visible = true; // IntersectionObserver state
  private cycleTimer = 0;

  private resizeObs: ResizeObserver | null = null;
  private interObs: IntersectionObserver | null = null;
  private disposed = false;

  constructor(canvas: HTMLCanvasElement, callbacks: PlayerCallbacks = {}) {
    this.canvas = canvas;
    this.cb = callbacks;
  }

  get paused(): boolean {
    return this.userPaused;
  }

  /** Boots the GPU adapter + engine and loads the first source. Returns false
   *  (and emits onError) if WebGPU is unavailable, the adapter request fails,
   *  or the first preset can't be resolved — the caller then shows the poster. */
  async init(opts: PlayerCoreOptions): Promise<boolean> {
    this.sources = [...opts.sources];
    this.intervalMs = opts.intervalMs;
    this.loop = opts.loop;
    this.index = 0;

    if (!isWebGPUAvailable()) {
      this.cb.onError?.({ reason: 'no-webgpu' });
      return false;
    }
    const adapter = await requestAdapter();
    if (this.disposed) return false;
    if (!adapter) {
      this.cb.onError?.({ reason: 'no-webgpu' });
      return false;
    }
    this.adapter = adapter;

    const first = await this.resolveAt(this.index);
    if (this.disposed) return false;
    if (!first) {
      this.cb.onError?.({ reason: 'preset-failed' });
      return false;
    }
    this.engine = new Engine({ adapter, canvas: this.canvas, initialPreset: first.params });
    this.current = first;
    this.cb.onReady?.();
    this.emitPresetChange();

    this.userPaused = !opts.autoplay;
    if (opts.autoplay) this.play();
    return true;
  }

  private async resolveAt(i: number): Promise<Preset | null> {
    const src = this.sources[i];
    if (src === undefined) return null;
    return resolvePreset(src);
  }

  private emitPresetChange(): void {
    if (!this.current) return;
    this.cb.onPresetChange?.({ index: this.index, id: this.current.id, name: this.current.name });
  }

  private async loadIndex(i: number): Promise<void> {
    if (!this.engine) return;
    const preset = await this.resolveAt(i);
    if (!preset || this.disposed || !this.engine) return;
    this.index = i;
    this.current = preset;
    this.engine.setPreset(preset.params);
    this.engine.respawn();
    this.emitPresetChange();
  }

  next(): void {
    const ni = nextIndex(this.index, this.sources.length, this.loop);
    if (ni === null) return;
    void this.loadIndex(ni);
    this.restartCycle();
  }

  prev(): void {
    const pi = prevIndex(this.index, this.sources.length, this.loop);
    if (pi === null) return;
    void this.loadIndex(pi);
    this.restartCycle();
  }

  play(): void {
    this.userPaused = false;
    this.startLoop();
    this.startCycle();
  }

  pause(): void {
    this.userPaused = true;
    this.stopLoop();
    this.stopCycle();
  }

  // Loop runs only when not user-paused AND visible.
  private startLoop(): void {
    if (this.running || !this.engine || this.userPaused || !this.visible) return;
    this.running = true;
    const tick = () => {
      if (!this.running || !this.engine) return;
      const steps = Math.max(1, Math.min(8, Math.round(this.current?.params.simSpeed ?? 1)));
      for (let s = 0; s < steps; s += 1) this.engine.tick();
      this.rafId = requestAnimationFrame(tick);
    };
    this.rafId = requestAnimationFrame(tick);
  }

  private stopLoop(): void {
    this.running = false;
    cancelAnimationFrame(this.rafId);
  }

  private startCycle(): void {
    this.stopCycle();
    if (this.sources.length <= 1 || this.userPaused) return;
    this.cycleTimer = window.setInterval(() => {
      const ni = nextIndex(this.index, this.sources.length, this.loop);
      if (ni === null) {
        this.stopCycle();
        return;
      }
      void this.loadIndex(ni);
    }, this.intervalMs);
  }

  private stopCycle(): void {
    if (this.cycleTimer) {
      clearInterval(this.cycleTimer);
      this.cycleTimer = 0;
    }
  }

  private restartCycle(): void {
    if (!this.userPaused) this.startCycle();
  }

  observe(host: HTMLElement): void {
    this.resizeObs = new ResizeObserver((entries) => {
      const e = entries[0];
      if (!e || !this.engine) return;
      const { width, height } = e.contentRect;
      if (width > 0 && height > 0) this.engine.resize(width, height);
    });
    this.resizeObs.observe(host);

    this.interObs = new IntersectionObserver(
      (entries) => {
        const e = entries[0];
        if (!e) return;
        this.visible = e.isIntersecting;
        if (this.visible) this.startLoop();
        else this.stopLoop();
      },
      { threshold: 0 },
    );
    this.interObs.observe(host);
  }

  dispose(): void {
    this.disposed = true;
    this.stopLoop();
    this.stopCycle();
    this.resizeObs?.disconnect();
    this.interObs?.disconnect();
    this.engine?.dispose();
    this.engine = null;
  }
}
  • [ ] Step 2: Typecheck

Run: npm run typecheck Expected: PASS. (The adapter field is assigned for future use; if biome/tsc flags it as unused, keep it — it documents ownership and is harmless. If tsc errors on unused, remove the private adapter field and the assignment.)

  • [ ] Step 3: Commit
git add src/player/PlayerCore.ts
git commit -m "feat(player): PlayerCore engine lifecycle, loop, playlist cycle"

Task 6: ControlsOverlay (plain DOM)

Files: - Create: src/player/ControlsOverlay.ts

  • [ ] Step 1: Create src/player/ControlsOverlay.ts
// src/player/ControlsOverlay.ts
import type { ControlsMode } from './types';

export interface ControlsHandlers {
  onPlayPause: () => void;
  onPrev: () => void;
  onNext: () => void;
  onFullscreen: () => void;
}

/** Plain-DOM control bar for the player. Lives inside the element's shadow
 *  root; styling is provided by the host's shadow stylesheet. */
export class ControlsOverlay {
  readonly root: HTMLDivElement;
  private readonly playBtn: HTMLButtonElement;
  private readonly prevBtn: HTMLButtonElement;
  private readonly nextBtn: HTMLButtonElement;

  constructor(doc: Document, mode: ControlsMode, hasPlaylist: boolean, h: ControlsHandlers) {
    const root = doc.createElement('div');
    root.className = `phys-controls phys-controls--${mode}`;

    this.prevBtn = this.button(doc, '‹', 'Previous preset', h.onPrev);
    this.playBtn = this.button(doc, '⏸', 'Play / pause', h.onPlayPause);
    this.nextBtn = this.button(doc, '›', 'Next preset', h.onNext);
    const fsBtn = this.button(doc, '⛶', 'Fullscreen', h.onFullscreen);

    this.prevBtn.hidden = !hasPlaylist;
    this.nextBtn.hidden = !hasPlaylist;

    root.append(this.prevBtn, this.playBtn, this.nextBtn, fsBtn);
    this.root = root;
  }

  private button(
    doc: Document,
    label: string,
    aria: string,
    onClick: () => void,
  ): HTMLButtonElement {
    const b = doc.createElement('button');
    b.type = 'button';
    b.textContent = label;
    b.setAttribute('aria-label', aria);
    b.addEventListener('click', (e) => {
      e.stopPropagation();
      onClick();
    });
    return b;
  }

  setPaused(paused: boolean): void {
    this.playBtn.textContent = paused ? '▶' : '⏸';
  }
}
  • [ ] Step 2: Typecheck

Run: npm run typecheck Expected: PASS.

  • [ ] Step 3: Commit
git add src/player/ControlsOverlay.ts
git commit -m "feat(player): plain-DOM controls overlay"

Task 7: PhysarumPlayer custom element + entry

Files: - Create: src/player/PhysarumPlayer.ts - Create: src/player/element.ts

  • [ ] Step 1: Create src/player/PhysarumPlayer.ts
// src/player/PhysarumPlayer.ts
import type { Preset } from '~/engine/types';
import { DEFAULT_PRESET } from '~/presets/builtIn';
import { builtinPosterUrl, CANONICAL_HOST } from './constants';
import { ControlsOverlay } from './ControlsOverlay';
import { PlayerCore } from './PlayerCore';
import { parseSource } from './resolvePreset';
import type { ControlsMode } from './types';

const SHADOW_STYLE = `
  :host { display: block; position: relative; width: 100%; height: 100%; min-height: 120px; background: #060709; overflow: hidden; }
  canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
  .poster { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: none; }
  :host([data-fallback]) canvas { display: none; }
  :host([data-fallback]) .poster { display: block; }
  .phys-controls { position: absolute; left: 0; right: 0; bottom: 0; display: flex; gap: 8px; justify-content: center; padding: 10px; opacity: 0; transition: opacity .2s; pointer-events: none; }
  .phys-controls--always { opacity: 1; pointer-events: auto; }
  .phys-controls--none { display: none; }
  :host(:hover) .phys-controls--auto { opacity: 1; pointer-events: auto; }
  .phys-controls button { width: 40px; height: 40px; border-radius: 8px; border: 1px solid rgba(255,255,255,.2); background: rgba(0,0,0,.45); color: #fff; font-size: 18px; line-height: 1; cursor: pointer; }
  .phys-controls button[hidden] { display: none; }
`;

export class PhysarumPlayer extends HTMLElement {
  static get observedAttributes(): string[] {
    return ['preset', 'playlist', 'interval', 'controls', 'poster', 'asset-base', 'autoplay', 'loop'];
  }

  private core: PlayerCore | null = null;
  private controls: ControlsOverlay | null = null;
  private canvasEl!: HTMLCanvasElement;
  private posterEl!: HTMLImageElement;
  private connectedFlag = false;

  connectedCallback(): void {
    if (!this.shadowRoot) this.buildShadow();
    this.connectedFlag = true;
    void this.boot();
  }

  disconnectedCallback(): void {
    this.connectedFlag = false;
    this.core?.dispose();
    this.core = null;
  }

  attributeChangedCallback(): void {
    if (this.connectedFlag) void this.boot();
  }

  // --- properties reflecting attributes (1:1 with attributes) ---
  private reflect(name: string, v: string | null): void {
    if (v == null) this.removeAttribute(name);
    else this.setAttribute(name, v);
  }
  get preset(): string | null { return this.getAttribute('preset'); }
  set preset(v: string | null) { this.reflect('preset', v); }
  get playlist(): string | null { return this.getAttribute('playlist'); }
  set playlist(v: string | null) { this.reflect('playlist', v); }
  get interval(): string | null { return this.getAttribute('interval'); }
  set interval(v: string | null) { this.reflect('interval', v); }
  get controls(): string | null { return this.getAttribute('controls'); }
  set controls(v: string | null) { this.reflect('controls', v); }
  get poster(): string | null { return this.getAttribute('poster'); }
  set poster(v: string | null) { this.reflect('poster', v); }
  get assetBase(): string | null { return this.getAttribute('asset-base'); }
  set assetBase(v: string | null) { this.reflect('asset-base', v); }

  // --- JS control API ---
  play(): void { this.core?.play(); this.controls?.setPaused(false); }
  pause(): void { this.core?.pause(); this.controls?.setPaused(true); }
  next(): void { this.core?.next(); }
  prev(): void { this.core?.prev(); }
  load(source: string): void {
    this.removeAttribute('playlist');
    this.setAttribute('preset', source);
  }

  private buildShadow(): void {
    const root = this.attachShadow({ mode: 'open' });
    const style = document.createElement('style');
    style.textContent = SHADOW_STYLE;
    this.canvasEl = document.createElement('canvas');
    this.posterEl = document.createElement('img');
    this.posterEl.className = 'poster';
    this.posterEl.alt = '';
    root.append(style, this.posterEl, this.canvasEl);
  }

  private get sources(): string[] {
    const playlist = this.getAttribute('playlist');
    if (playlist && playlist.trim()) {
      return playlist
        .split(',')
        .map((s) => s.trim())
        .filter(Boolean);
    }
    const preset = this.getAttribute('preset');
    return preset && preset.trim() ? [preset.trim()] : [`builtin:${DEFAULT_PRESET.id}`];
  }

  private boolAttr(name: string, dflt: boolean): boolean {
    if (!this.hasAttribute(name)) return dflt;
    return this.getAttribute(name) !== 'false';
  }

  private dispatch(type: string, detail?: unknown): void {
    this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
  }

  private toggleFullscreen(): void {
    if (document.fullscreenElement) void document.exitFullscreen();
    else void this.requestFullscreen();
  }

  private showPoster(firstSource: string | undefined): void {
    this.setAttribute('data-fallback', '');
    const override = this.getAttribute('poster');
    let url = override ?? '';
    if (!url && firstSource) {
      const parsed = parseSource(firstSource);
      if (parsed.kind === 'builtin') {
        const base = this.getAttribute('asset-base') ?? CANONICAL_HOST;
        url = builtinPosterUrl(parsed.value, base);
      }
    }
    if (url) this.posterEl.src = url;
  }

  private async boot(): Promise<void> {
    this.core?.dispose();
    this.removeAttribute('data-fallback');

    const mode = (this.getAttribute('controls') ?? 'auto') as ControlsMode;
    const intervalSec = Number(this.getAttribute('interval') ?? '30');
    const intervalMs = Number.isFinite(intervalSec) && intervalSec > 0 ? intervalSec * 1000 : 30_000;
    const loop = this.boolAttr('loop', true);
    const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
    const autoplay = this.boolAttr('autoplay', !reduced);
    const sources = this.sources;
    const hasPlaylist = sources.length > 1;

    // (re)build controls
    this.shadowRoot?.querySelector('.phys-controls')?.remove();
    if (mode !== 'none') {
      this.controls = new ControlsOverlay(document, mode, hasPlaylist, {
        onPlayPause: () => (this.core?.paused ? this.play() : this.pause()),
        onPrev: () => this.prev(),
        onNext: () => this.next(),
        onFullscreen: () => this.toggleFullscreen(),
      });
      this.shadowRoot?.appendChild(this.controls.root);
    } else {
      this.controls = null;
    }

    this.core = new PlayerCore(this.canvasEl, {
      onReady: () => {
        this.controls?.setPaused(this.core?.paused ?? false);
        this.dispatch('physarum:ready');
      },
      onPresetChange: (d: { index: number; id: string; name: string }) =>
        this.dispatch('physarum:presetchange', d),
      onError: (d: { reason: string }) => {
        this.showPoster(sources[0]);
        this.dispatch('physarum:error', d);
      },
    });

    const ok = await this.core.init({ sources, intervalMs, loop, autoplay });
    if (ok) this.core.observe(this);
  }
}

// silence "unused" on Preset import if tree-shaken — keeps the type available
// for consumers that import alongside the element.
export type { Preset };

Note: if biome flags the trailing export type { Preset } as unnecessary, drop both it and the import type { Preset } line.

  • [ ] Step 2: Create src/player/element.ts
// src/player/element.ts
import { PhysarumPlayer } from './PhysarumPlayer';

if (typeof customElements !== 'undefined' && !customElements.get('physarum-player')) {
  customElements.define('physarum-player', PhysarumPlayer);
}

export { PhysarumPlayer };
  • [ ] Step 3: Typecheck + lint

Run: npm run typecheck && npm run lint Expected: PASS. Fix any biome findings (e.g. drop the Preset re-export note above if flagged).

  • [ ] Step 4: Commit
git add src/player/PhysarumPlayer.ts src/player/element.ts
git commit -m "feat(player): physarum-player custom element + entry"

Task 8: Embed route — player.html + embed-page + multi-page Vite input

Files: - Create: player.html - Create: src/player/embed-page.ts - Modify: vite.config.ts

  • [ ] Step 1: Create src/player/embed-page.ts
// src/player/embed-page.ts
// Embed route: reads URL query params and configures a single full-bleed
// <physarum-player>. Same element as the standalone bundle — one source of truth.
import './element';

const params = new URLSearchParams(window.location.search);
const player = document.createElement('physarum-player');

const attrs = ['preset', 'playlist', 'interval', 'controls', 'poster', 'asset-base', 'autoplay', 'loop'];
for (const attr of attrs) {
  const v = params.get(attr);
  if (v !== null) player.setAttribute(attr, v);
}

player.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;';
document.body.style.margin = '0';
document.body.appendChild(player);
  • [ ] Step 2: Create player.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <meta name="theme-color" content="#060709" />
    <title>Physarum Player</title>
    <style>
      html, body { margin: 0; height: 100%; background: #060709; }
    </style>
  </head>
  <body>
    <script type="module" src="/src/player/embed-page.ts"></script>
  </body>
</html>
  • [ ] Step 3: Add the second Rollup input in vite.config.ts

In the existing build block, add rollupOptions.input listing both pages. The file already imports fileURLToPath and URL. Change:

  build: {
    target: 'es2022',
    sourcemap: true,
  },

to:

  build: {
    target: 'es2022',
    sourcemap: true,
    rollupOptions: {
      input: {
        main: fileURLToPath(new URL('./index.html', import.meta.url)),
        player: fileURLToPath(new URL('./player.html', import.meta.url)),
      },
    },
  },
  • [ ] Step 4: Verify dev server serves the embed route

Run: npm run build Expected: PASS; output includes both dist/index.html and dist/player.html.

(Optional manual check: npm run dev, then open http://localhost:5173/player.html?preset=builtin:dreamy&controls=always — the canvas animates.)

  • [ ] Step 5: Commit
git add player.html src/player/embed-page.ts vite.config.ts
git commit -m "feat(player): embed route (player.html) via multi-page build"

Task 9: Standalone element library build

Files: - Create: vite.element.config.ts - Modify: package.json - Modify: .gitignore

The element dependency graph (element.ts → engine/presets/share) contains no Solid/JSX, so the lib build needs no vite-plugin-solid. WGSL is handled by Vite's ?raw.

  • [ ] Step 1: Create vite.element.config.ts
import { URL, fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';

// Standalone build of the <physarum-player> custom element: a single
// self-contained ES module. No PWA, no Solid app shell. WGSL is inlined via
// ?raw, so the output needs no external engine assets.
export default defineConfig({
  resolve: {
    alias: { '~': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  assetsInclude: ['**/*.wgsl'],
  build: {
    target: 'es2022',
    sourcemap: true,
    outDir: 'dist-player',
    emptyOutDir: true,
    lib: {
      entry: fileURLToPath(new URL('./src/player/element.ts', import.meta.url)),
      formats: ['es'],
      fileName: () => 'physarum-player.js',
    },
  },
});
  • [ ] Step 2: Add the build script in package.json

In scripts, after the "build" line, add:

    "build:element": "vite build --config vite.element.config.ts",
  • [ ] Step 3: Ignore the element output dir

Append to .gitignore:

dist-player
  • [ ] Step 4: Verify the element bundle builds

Run: npm run build:element Expected: PASS; dist-player/physarum-player.js exists.

Run: ls -la dist-player/physarum-player.js Expected: file present.

  • [ ] Step 5: Commit
git add vite.element.config.ts package.json .gitignore
git commit -m "build(player): standalone custom-element library bundle"

Task 10: Editor "copy embed code" button

Files: - Modify: src/ui/SettingsMenu.tsx - Modify: src/App.tsx

The settings menu (SettingsMenu.tsx) is available on all viewports. Add an always-visible row. flashShareNotice and presetState are already imported in App.tsx; reuse the existing share toast for feedback.

  • [ ] Step 1: Extend SettingsActions in src/ui/SettingsMenu.tsx

Change the interface (around line 20):

export interface SettingsActions {
  onToggleAudio: () => void;
  onToggleRecord: () => void;
  onShare: () => void;
  onCopyEmbed: () => void;
  onExport: () => void;
}
  • [ ] Step 2: Add the menu row in src/ui/SettingsMenu.tsx

Immediately after the Settings eyebrow span (the <span class="phys-eyebrow">Settings</span> block, ~line 109-111), insert:

        <MenuRow
          label="Copy embed code"
          onClick={() => {
            props.onCopyEmbed();
            closePanel();
          }}
        />
        <div class="phys-hr" />
  • [ ] Step 3: Add the handler + import in src/App.tsx

Add the import near the other ~/share import:

import { embedSnippet } from './share/embedCode';

Add the handler right after onShare (after line ~108):

  const onCopyEmbed = async () => {
    if (!navigator.clipboard?.writeText) {
      flashShareNotice('unsupported');
      return;
    }
    try {
      await navigator.clipboard.writeText(embedSnippet(presetState.current));
      flashShareNotice('copied');
    } catch {
      flashShareNotice('failed');
    }
  };
  • [ ] Step 4: Pass the prop to <SettingsMenu>

At the <SettingsMenu ... /> usage (around line 185), add:

              onCopyEmbed={() => void onCopyEmbed()}
  • [ ] Step 5: Typecheck + lint + unit tests

Run: npm run typecheck && npm run lint && npm test Expected: PASS (all existing + new Vitest tests green).

  • [ ] Step 6: Commit
git add src/ui/SettingsMenu.tsx src/App.tsx
git commit -m "feat(editor): copy embed-code action in settings menu"

Task 11: Playwright smoke test

Files: - Create: tests/e2e/player.spec.ts

The Playwright dev server (playwright.config.ts) runs npm run dev at http://localhost:5173; Vite serves /player.html natively. Events are captured before page scripts via addInitScript. Built-in ids dreamy (default) and aurora both exist.

Quirk: keep the addInitScript/evaluate callbacks free of named inner functions to avoid the esbuild __name issue documented in tsx-playwright-evaluate-quirk. The arrows below are anonymous and safe.

  • [ ] Step 1: Write the test
// tests/e2e/player.spec.ts
import { expect, test } from '@playwright/test';

type EventLog = { type: string; detail: unknown };

test.beforeEach(async ({ page }) => {
  await page.addInitScript(() => {
    const w = window as unknown as { __events: EventLog[] };
    w.__events = [];
    const push = (e: Event) =>
      w.__events.push({ type: e.type, detail: (e as CustomEvent).detail });
    window.addEventListener('physarum:ready', push);
    window.addEventListener('physarum:presetchange', push);
    window.addEventListener('physarum:error', push);
  });
});

const types = (page: import('@playwright/test').Page) =>
  page.evaluate(() => (window as unknown as { __events: EventLog[] }).__events.map((e) => e.type));

const changedIds = (page: import('@playwright/test').Page) =>
  page.evaluate(() =>
    (window as unknown as { __events: EventLog[] }).__events
      .filter((e) => e.type === 'physarum:presetchange')
      .map((e) => (e.detail as { id: string }).id),
  );

test('player.html boots a built-in preset and emits ready', async ({ page }) => {
  await page.goto('/player.html?preset=builtin:dreamy&controls=always');
  await expect.poll(() => types(page), { timeout: 15_000 }).toContain('physarum:ready');
  await expect.poll(() => changedIds(page)).toContain('dreamy');
});

test('playlist next advances to the next preset', async ({ page }) => {
  await page.goto(
    '/player.html?playlist=builtin:dreamy,builtin:aurora&controls=always&interval=999',
  );
  await expect.poll(() => types(page), { timeout: 15_000 }).toContain('physarum:ready');
  await page.locator('physarum-player').getByRole('button', { name: 'Next preset' }).click();
  await expect.poll(() => changedIds(page), { timeout: 15_000 }).toContain('aurora');
});
  • [ ] Step 2: Run the smoke test

Run: npm run test:e2e -- tests/e2e/player.spec.ts Expected: PASS (2 tests). If WebGPU isn't available in the runner, the ready event never fires and an error event appears instead — in that case run on a machine with the WebGPU flags from playwright.config.ts (this matches the existing engine e2e setup).

  • [ ] Step 3: Commit
git add tests/e2e/player.spec.ts
git commit -m "test(player): playwright smoke for boot + playlist advance"

Task 12: Deploy the bundle + docs

Files: - Modify: .forgejo/workflows/deploy.yml - Modify: README.md - Modify: AGENTS.md

The embed snippet points <script src> at ${CANONICAL_HOST}/physarum-player.js, so the deploy must publish the element bundle next to the app. The README needs an "Embed" section; AGENTS.md status is stale.

  • [ ] Step 1: Read the deploy workflow

Run: cat .forgejo/workflows/deploy.yml Identify the build step (npm run build) and the directory that gets published to Pages (the app's dist, possibly built with BASE_PATH=/physarum/).

  • [ ] Step 2: Publish the element bundle in the deploy

After the existing npm run build step, add an element build and copy the bundle into the published directory so it resolves at ${CANONICAL_HOST}/physarum-player.js. Concretely, add a run step:

      - name: Build embeddable player bundle
        run: |
          npm run build:element
          cp dist-player/physarum-player.js dist/physarum-player.js
          cp dist-player/physarum-player.js.map dist/physarum-player.js.map || true

Place it immediately after the app build step and before the publish/upload step. Match the existing YAML indentation and the publish directory name (adjust dist if the workflow publishes a different path).

  • [ ] Step 3: Add an "Embed" section to README.md

Add a section documenting both usage paths (place it after the existing usage/feature content):

## Embed

Drop the player onto any page with the custom element:

```html
<script type="module" src="https://pages.jkaindl.de/physarum/physarum-player.js"></script>

<!-- a built-in preset -->
<physarum-player preset="builtin:aurora"></physarum-player>

<!-- your own preset: in the app, open Settings → "Copy embed code" -->
<physarum-player preset="token:…"></physarum-player>

<!-- a cycling playlist -->
<physarum-player playlist="builtin:dreamy,builtin:aurora,builtin:nebula" interval="20"></physarum-player>

Attributes: preset, playlist, interval (s), controls (auto/always/none), poster, asset-base, autoplay, loop. JS API: .play() .pause() .next() .prev() .load(source). Events: physarum:ready, physarum:presetchange, physarum:error.

Or embed via iframe using the hosted route:

<iframe src="https://pages.jkaindl.de/physarum/player.html?preset=builtin:aurora"
        style="border:0;width:100%;height:400px"></iframe>
(Use a normal fenced block in the actual README — the nesting above is only for this plan.)

- [ ] **Step 4: Update the status block in `AGENTS.md`**

Replace the stale "Aktueller Status (Stand 2026-05-25)" header/date and add an entry at the top of the status list noting the embeddable player (web component + embed route + editor embed-code button) shipped on 2026-05-28. Keep the existing released-version history below it.

- [ ] **Step 5: Verify the full suite**

Run: `npm run typecheck && npm run lint && npm test`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add .forgejo/workflows/deploy.yml README.md AGENTS.md
git commit -m "build(player): publish bundle in deploy; document embedding"


Self-Review Notes (author)

  • Spec coverage: distribution both (Task 7–9), controls + switcher (Task 6–7), all four preset sources (Task 3), no audio (PlayerCore never calls setAudio), poster fallback (Task 7), embed-code button (Task 4 + 10), tests (Task 2–4, 11). Build/deploy (Task 8, 9, 12). All covered.
  • Type consistency: resolvePreset(source, opts?), parseSource, embedSnippet(preset, opts?), PlayerCore methods (init/play/pause/next/prev/observe/dispose, getter paused), callbacks (onReady/onPresetChange/onError), event names (physarum:ready|presetchange|error) are used identically across tasks.
  • Playlist json: caveat: the element splits playlist on commas, so inline json: isn't valid in a playlist (documented in the spec); single preset/load supports json:. No task contradicts this.
  • Known soft spots flagged inline: possibly-unused adapter field (Task 5 Step 2) and the Preset re-export (Task 7) — both with explicit fallback instructions if biome/tsc complain.