Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9b1e72d
chore: untrack .env and add to .gitignore
pulsedemon May 19, 2026
26e9f6e
fix: prevent DOM-XSS via mix.name in VirusLab saved-mixes list
pulsedemon May 19, 2026
037efcb
fix: validate lab iframe primary/secondary params against allowlist
pulsedemon May 19, 2026
0f317fa
chore: add baseline security response headers
pulsedemon May 19, 2026
d8c018e
fix: bind `this` correctly when calling fullscreen methods
pulsedemon May 19, 2026
f1f4697
fix: draggable falls back to 0 when computed top/left is auto
pulsedemon May 19, 2026
425e1bd
fix: make randomIntBetween inclusive of max bound
pulsedemon May 19, 2026
d115f51
refactor: use addEventListener for global key handlers
pulsedemon May 19, 2026
f86c7ad
fix: regenerate playlist on out-of-bounds currentIndex
pulsedemon May 19, 2026
9a88c61
chore: remove orphan virus directories
pulsedemon May 19, 2026
4984620
refactor: track teleportMenu position by index, not string match
pulsedemon May 19, 2026
8e0cc82
refactor: shuffleTitle uses proper shuffle and returns stop handle
pulsedemon May 19, 2026
448f6ed
refactor: VirusThumbnailOverlay reuses the live Playlist
pulsedemon May 19, 2026
27a0ae8
perf: lazy-load thumbnail iframes via IntersectionObserver
pulsedemon May 19, 2026
ab5f7ef
perf: bump VirusLoader safety timeout from 5s to 12s
pulsedemon May 19, 2026
658279d
chore: drop redundant clearInterval in toggleLab close path
pulsedemon May 19, 2026
09b4fef
chore: remove dead exports and wire VirusLab.cleanup
pulsedemon May 19, 2026
ac4a491
chore: delete empty jsconfig.json
pulsedemon May 19, 2026
65c19c2
chore: tighten tsconfig include and excludes
pulsedemon May 19, 2026
ef3c4ac
refactor: extract shared handlebars Vite plugin
pulsedemon May 19, 2026
a990404
chore: type test casts on VirusLab internals
pulsedemon May 19, 2026
bf6543f
chore: declare node engine in package.json
pulsedemon May 19, 2026
d5eb30c
chore: pull VirusLoader magic numbers into constants.ts
pulsedemon May 19, 2026
bba64ed
chore: gate noisy production console.logs behind DEV
pulsedemon May 19, 2026
466c608
docs: document URL routing redirect and lab allowlist
pulsedemon May 19, 2026
45c90b5
security: validate iframe data-src against safe virus path
pulsedemon May 19, 2026
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
3 changes: 0 additions & 3 deletions .env

This file was deleted.

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ coverage
CLAUDE.md
.superpowers/
docs/
.env

11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,22 @@ sass/ → Global SCSS styles

Each virus lives in its own `viruses/<name>/` directory with an `index.html` entry point that gets loaded into an iframe by the main app.

### URL routing

Direct hits on `/viruses/<name>` are redirected to `/?virus=<name>` in dev (see `vite.config.ts`) so the parent shell always frames the virus. Iframe loads bypass the redirect via `sec-fetch-dest`.

### Virus Lab

`viruses/lab/` is a static HTML mixer that loads two virus iframes and blends them with `mix-blend-mode: screen`. The `?primary=` and `?secondary=` URL params must be in the allowlist hardcoded at the top of `viruses/lab/index.html` — keep that list in sync with `Playlist.viruses`. A drift test in `viruses/lab/__tests__/` enforces it.

## How to Add a New Virus

1. Create a new directory: `viruses/your-virus-name/`
2. Add an `index.html` file as the entry point
3. Write your animation (TypeScript, CSS, whatever you want)
4. Add the virus name to the `viruses` array in `components/Playlist.ts`
5. Run `yarn dev` and navigate to test it
5. Add it to the `ALLOWED_VIRUSES` set in `viruses/lab/index.html` if it should be mixable
6. Run `yarn dev` and navigate to test it

## Installation

Expand Down
39 changes: 39 additions & 0 deletions build-utils/handlebars-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { readFileSync } from 'fs';
import type { Plugin } from 'vite';

// Shared by vite.config.ts and vitest.config.ts so the two stay in sync.
// Templates are tiny string literals — we wrap them in a function so callers
// get a value identical to handlebars' template signature. The escape step
// neutralizes characters that would otherwise let a template inject script
// markup when interpolated back into HTML, plus U+2028 / U+2029 which
// break JS string literals when emitted as-is.
function escapeUnsafeChars(str: string): string {
return str.replace(/[<>/\u2028\u2029]/g, ch => {
const map: Record<string, string> = {
'<': '\\u003C',
'>': '\\u003E',
'/': '\\u002F',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
};
return map[ch] ?? ch;
});
}

export function handlebarsLoader(): Plugin {
return {
name: 'handlebars-loader',
transform(_code: string, id: string) {
if (id.endsWith('.hbs')) {
const template = readFileSync(id, 'utf-8');
return {
code: `export default function() { return ${escapeUnsafeChars(
JSON.stringify(template)
)}; }`,
map: null,
};
}
return null;
},
};
}
8 changes: 3 additions & 5 deletions components/Playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,11 @@ export default class Playlist {
}
if (typeof this.playlist[this.currentIndex] === 'undefined') {
console.warn(
`Playlist.current(): currentIndex ${this.currentIndex} out of bounds (length: ${this.playlist.length}), resetting to 0`
`Playlist.current(): currentIndex ${this.currentIndex} out of bounds (length: ${this.playlist.length}), regenerating`
);
this.currentIndex = 0;
this.generatePlaylist();
}
const current = this.playlist[this.currentIndex];
return current;
return this.playlist[this.currentIndex];
}

prev(): string {
Expand Down Expand Up @@ -148,7 +147,6 @@ export default class Playlist {
* This allows the mix to be properly referenced by playlist.current()
*/
setCurrentVirus(virusId: string): void {
// Find the index of this virus in the playlist
const index = this.playlist.indexOf(virusId);

if (index !== -1) {
Expand Down
3 changes: 2 additions & 1 deletion components/TVStaticLoading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ export default class TVStaticLoading {
if (!this.watermark.currentWord) {
const randomIndex = Math.floor(Math.random() * this.WORDS.length);
this.watermark.currentWord = this.WORDS[randomIndex];
console.log(`Your fortune: "${this.watermark.currentWord}" `);
if (import.meta.env.DEV)
console.log(`Your fortune: "${this.watermark.currentWord}" `);
}
const randomWord = this.watermark.currentWord;

Expand Down
27 changes: 16 additions & 11 deletions components/VirusLab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,20 +234,25 @@ export default class VirusLab {
this.savedMixes.forEach(mix => {
const mixElement = document.createElement('div');
mixElement.className = 'saved-mix';
mixElement.innerHTML = `
<span>${mix.name}</span>
<div class="saved-mix-actions">
<button class="load-mix" data-id="${mix.id}">Load</button>
<button class="delete-mix" data-id="${mix.id}">Delete</button>
</div>
`;

const loadButton = mixElement.querySelector('.load-mix');
const deleteButton = mixElement.querySelector('.delete-mix');
const label = document.createElement('span');
label.textContent = mix.name ?? '';

loadButton?.addEventListener('click', () => this.loadMix(mix));
deleteButton?.addEventListener('click', () => this.deleteMix(mix.id!));
const actions = document.createElement('div');
actions.className = 'saved-mix-actions';

const loadButton = document.createElement('button');
loadButton.className = 'load-mix';
loadButton.textContent = 'Load';
loadButton.addEventListener('click', () => this.loadMix(mix));

const deleteButton = document.createElement('button');
deleteButton.className = 'delete-mix';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', () => this.deleteMix(mix.id!));

actions.append(loadButton, deleteButton);
mixElement.append(label, actions);
savedMixesList.appendChild(mixElement);
});
}
Expand Down
31 changes: 20 additions & 11 deletions components/VirusLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import VirusLab from './VirusLab';
import { VirusLoaderInterface } from '../types/VirusLoaderInterface';
import { createStyledIframe } from '../utils/iframe';
import { randomIntBetween } from '../utils/random';
import {
LOAD_SAFETY_TIMEOUT_MS,
MIN_LOAD_ANIMATION_MS,
NAVIGATION_LOCK_MS,
RANDOMIZATION_MAX_S,
RANDOMIZATION_MIN_S,
} from './constants';
import { safeGtag } from '../utils/gtag';
import { createLabButton, createThumbnailButton } from '../ui/floating-buttons';

Expand Down Expand Up @@ -124,7 +131,7 @@ export default class VirusLoader implements VirusLoaderInterface {
this.loadingAnim = new Flash(this.loadingAnimEl);
}

console.log('Loading virus:', name);
if (import.meta.env.DEV) console.log('Loading virus:', name);
this.loadingAnim.start();
this.loadingAnimStartTime = Date.now();
this.setSourceCodeLinkVisible(false);
Expand All @@ -144,7 +151,7 @@ export default class VirusLoader implements VirusLoaderInterface {
console.warn(msg);
Sentry.captureMessage(msg, 'warning');
this._delayedIframeLoaded(generation);
}, 5000);
}, LOAD_SAFETY_TIMEOUT_MS);

try {
if (this.playlist.isMixedVirus(name)) {
Expand Down Expand Up @@ -249,7 +256,7 @@ export default class VirusLoader implements VirusLoaderInterface {
private _delayedIframeLoaded(generation: number) {
if (generation !== this._loadGeneration) return;

const minDuration = 500;
const minDuration = MIN_LOAD_ANIMATION_MS;
const elapsed = Date.now() - this.loadingAnimStartTime;
if (elapsed >= minDuration) {
this._iframeLoaded();
Expand Down Expand Up @@ -321,13 +328,14 @@ export default class VirusLoader implements VirusLoaderInterface {

const virus =
direction === 'next' ? this.playlist.next() : this.playlist.prev();
console.log(`Skipping to ${direction} virus:`, virus);
if (import.meta.env.DEV)
console.log(`Skipping to ${direction} virus:`, virus);
this.loadVirus(virus);
this.startRandomization();

setTimeout(() => {
this.isNavigating = false;
}, 300);
}, NAVIGATION_LOCK_MS);
}

get isLabOpen(): boolean {
Expand All @@ -340,11 +348,12 @@ export default class VirusLoader implements VirusLoaderInterface {

/**
* (Re)starts the random virus rotation. Picks a single random interval
* (2-11s) that stays fixed until the next call.
* (2-12s, both inclusive) that stays fixed until the next call.
*/
startRandomization() {
clearInterval(this.loadRandomInterval);
const randomTime = randomIntBetween(2, 12) * 1000;
const randomTime =
randomIntBetween(RANDOMIZATION_MIN_S, RANDOMIZATION_MAX_S) * 1000;

this.loadRandomInterval = setInterval(() => {
this.removeMixContainer();
Expand All @@ -364,6 +373,7 @@ export default class VirusLoader implements VirusLoaderInterface {
if (this.virusLab) {
// Close lab
const currentMix = this.virusLab.getCurrentMix();
this.virusLab.cleanup();
const labContainer = document.getElementById('virus-lab');
if (labContainer) {
labContainer.remove();
Expand All @@ -380,8 +390,6 @@ export default class VirusLoader implements VirusLoaderInterface {
}
safeGtag('event', 'close_lab');

clearInterval(this.loadRandomInterval);

if (currentMix && currentMix.id) {
this.loadVirus(`mixed:${currentMix.id}`);
const mixId = `mixed:${currentMix.id}`;
Expand Down Expand Up @@ -434,12 +442,13 @@ export default class VirusLoader implements VirusLoaderInterface {
this.removeMixContainer();

const currentVirus = this.playlist.current();
console.log('Reloading current virus:', currentVirus);
if (import.meta.env.DEV)
console.log('Reloading current virus:', currentVirus);
this.loadVirus(currentVirus);
this.startRandomization();

setTimeout(() => {
this.isNavigating = false;
}, 300);
}, NAVIGATION_LOCK_MS);
}
}
65 changes: 57 additions & 8 deletions components/VirusThumbnailOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ interface VirusThumbnailOverlayOptions {
onSelect: (virus: string) => void;
onClose: () => void;
virusLoader?: VirusLoaderInterface;
playlist: Playlist;
}

export class VirusThumbnailOverlay {
Expand All @@ -89,6 +90,7 @@ export class VirusThumbnailOverlay {
private currentFocusIndex = -1;
private abortController: AbortController;
private observer: MutationObserver;
private iframeObserver: IntersectionObserver | null = null;
private onSelect: (virus: string) => void;
private onClose: () => void;
private virusLoader?: VirusLoaderInterface;
Expand All @@ -115,8 +117,8 @@ export class VirusThumbnailOverlay {
const existing = document.getElementById('virus-thumbnail-overlay');
if (existing) existing.remove();

// Get virus list
const playlist = new Playlist();
// Re-use the live Playlist so newly saved mixes show without reload.
const playlist = options.playlist;
const viruses = playlist.viruses.map(virus => ({
value: virus,
label: formatVirusName(virus),
Expand Down Expand Up @@ -164,6 +166,11 @@ export class VirusThumbnailOverlay {
// Add overlay to DOM
document.body.appendChild(this.overlay);

// Lazy-load iframe srcs only when their thumbnail intersects the
// viewport. Without this, every virus iframe (three.js/rapier)
// boots simultaneously on overlay open.
this.setupIframeLazyLoad();

// Prevent body scroll when overlay is open
document.body.style.overflow = 'hidden';

Expand Down Expand Up @@ -218,7 +225,7 @@ export class VirusThumbnailOverlay {
<div class="virus-thumbnail-item" data-virus="${virus.value}" tabindex="0" role="button" aria-label="Select ${virus.label} virus">
<div class="virus-thumbnail-preview">
<iframe
src="/viruses/${virus.value}/"
data-src="/viruses/${virus.value}/"
title="${virus.label} preview"
frameborder="0"
loading="lazy"
Expand Down Expand Up @@ -503,20 +510,62 @@ export class VirusThumbnailOverlay {
this.currentFocusIndex = index;
}

private setupIframeLazyLoad(): void {
const iframes = Array.from(
this.overlay.querySelectorAll<HTMLIFrameElement>('iframe[data-src]')
);
if (iframes.length === 0) return;

// Strict pattern check: src must look like `/viruses/<slug>/` where
// <slug> is lowercase kebab. Rejects anything containing `..`, `//`,
// scheme characters, or HTML metacharacters before it ever reaches
// iframe.src — satisfies CodeQL's "DOM text reinterpreted as HTML"
// and stops a tampered data-src from navigating off-origin.
const SAFE_VIRUS_PATH = /^\/viruses\/[a-z][a-z0-9-]*\/$/;
const assignSafeSrc = (iframe: HTMLIFrameElement): void => {
const src = iframe.getAttribute('data-src');
iframe.removeAttribute('data-src');
if (src && SAFE_VIRUS_PATH.test(src)) {
iframe.src = src;
}
};

// IntersectionObserver isn't available in every test environment.
// Fall back to immediate src assignment so jsdom-only tests still work.
if (typeof IntersectionObserver === 'undefined') {
iframes.forEach(assignSafeSrc);
return;
}

this.iframeObserver = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const iframe = entry.target as HTMLIFrameElement;
assignSafeSrc(iframe);
this.iframeObserver?.unobserve(iframe);
});
},
{ root: this.overlay, rootMargin: '100px' }
);

iframes.forEach(iframe => this.iframeObserver?.observe(iframe));
}

destroy(): void {
if (this._destroyed) return;
this._destroyed = true;
this.abortController.abort();
this.observer.disconnect();
this.iframeObserver?.disconnect();
this.iframeObserver = null;
document.body.style.overflow = '';
this.overlay.remove();
}
}

export function showVirusThumbnailOverlay(options: {
onSelect: (virus: string) => void;
onClose: () => void;
virusLoader?: VirusLoaderInterface;
}): VirusThumbnailOverlay {
export function showVirusThumbnailOverlay(
options: VirusThumbnailOverlayOptions
): VirusThumbnailOverlay {
return new VirusThumbnailOverlay(options);
}
2 changes: 1 addition & 1 deletion components/__tests__/Playlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('Playlist', () => {
expect(current).toBe(playlist.playlist[0]);
});

it('should reset index if it is out of bounds', () => {
it('should regenerate the playlist if currentIndex is out of bounds', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn());
const playlist = new Playlist();
playlist.currentIndex = 999999;
Expand Down
Loading
Loading