Web Component Spec — <perlin-noise> + shared render engine¶
Status: approved design, ready for implementation. Date: 2026-05-28.
Goal¶
Ship an embeddable, renderer-only custom element <perlin-noise> that draws
a looping Perlin animation onto a canvas — no control panel. Primary use case:
a decorative hero / background on a third-party page. Configured via a small
set of HTML attributes plus a full JavaScript property.
To avoid duplicating the worker + animation orchestration that already lives in
perlin-studio.js, that logic is extracted into a shared headless engine
(PerlinEngine) consumed by both the studio controller and the new element.
This is a DRY refactor of working code, so the studio must stay behaviourally
identical.
Hard constraints (from AGENTS.md)¶
- Zero build step. No bundler, no TypeScript, no framework.
file://must work. Double-clickingindex.htmlmust keep working, so no ES modules (module scripts are subject to CORS underfile://). Every layer is a plain script attaching to awindowglobal, exactly like the currentMTPerlinIIFE.- No new dependencies.
- UI strings / comments / identifiers stay English.
perlin.jsmath stays byte-identical — the smoke tests gate CI deploy.
Architecture — five plain-script layers¶
perlin.js → window.MTPerlin math + WORKER_SOURCE [unchanged]
perlin-presets.js → window.MTPerlinPresets GRADIENTS, BUILTIN_PRESETS,
resolveParams() [NEW]
perlin-engine.js → window.PerlinEngine headless renderer class [NEW]
perlin-element.js → customElements.define <perlin-noise> [NEW]
perlin-studio.js → studio controller consumes presets + engine [CHANGED]
Load order in index.html: perlin.js → perlin-presets.js → perlin-engine.js
→ perlin-studio.js. The element script is not loaded by the studio page;
it is exercised by a standalone demo page (examples/embed.html).
Why perlin-presets.js¶
The element supports preset="lava" (a built-in name). To render that it needs
both the preset definitions and gradient resolution: a preset with
gradientName: "fire" must be expanded to its concrete [{pos,color}] stops
before the params reach the worker. That resolution lives inline in
perlin-studio.js today (gatherParams). Extracting GRADIENTS,
BUILTIN_PRESETS, and a resolveParams() helper into a shared module lets the
studio and the element share one copy.
Module: perlin-presets.js¶
Exposes window.MTPerlinPresets = { GRADIENTS, BUILTIN_PRESETS, resolveParams }.
GRADIENTS— moved verbatim fromperlin-studio.js.BUILTIN_PRESETS— moved verbatim, including theLOOP_DEFAULTS/WARP_OFF/LAYER2_OFFspread bases.resolveParams(preset) → params— returns a new, fully-resolved params object the engine/worker can consume directly:- Deep-ish clone (so the input is never mutated).
- If
colorModeisgradientorposterizeandparams.gradientis absent butgradientNameis set: injectGRADIENTS[gradientName](fallbackviridis). IfgradientName === "custom"andparams.gradientis already a valid stop array, pass it through unchanged. - Leave all other fields as-is. No clamping here — clamping stays a studio/UI concern (the element trusts preset JSON authored by the studio).
This module must be require()-able under Node (guard the window assignment)
so the smoke test can import resolveParams. Mirror the perlin.js pattern:
(function (root) {
// … definitions …
root.MTPerlinPresets = { GRADIENTS, BUILTIN_PRESETS, resolveParams };
})(typeof window !== "undefined" ? window : globalThis);
if (typeof module !== "undefined") module.exports = { GRADIENTS, BUILTIN_PRESETS, resolveParams };
Module: perlin-engine.js¶
window.PerlinEngine — a class that owns a canvas, the worker lifecycle, the
main-thread fallback, and the RAF animation loop. No DOM-id lookups, no UI
strings, no mask/recording/preset knowledge.
Constructor¶
new PerlinEngine(canvas, {
onFrame, // ({ position, fps, isMain }) => void — after each painted frame
transformFrame, // (imageData) => void — mutate pixels before paint (e.g. mask)
})
Both callbacks are optional.
Public methods / properties¶
| Member | Behaviour |
|---|---|
setParams(params) |
Store the current (resolved) params. Does not render. |
renderOnce() |
Render the current params once at the current position. |
play() |
Start the RAF loop. No-op if already playing. |
pause() |
Stop the RAF loop. |
get playing |
Boolean. |
seek(value) |
Set the time position (loop progress in [0,1) when params.loop, else free t). Re-anchors the wall-clock origin so a running animation continues smoothly. |
get position |
Current time position. |
renderFrameAndWait(params) |
Promise resolving when that exact frame has painted (deterministic, for WebM recording). |
get fps |
Smoothed FPS (EMA). |
destroy() |
Terminate the worker, cancel RAF, drop pending resolves. |
Internals (moved from perlin-studio.js, behaviour-preserving)¶
- Worker from
MTPerlin.WORKER_SOURCEvia Blob URL;onerror→ null worker → main-thread fallback (MTPerlin.generatePerlinImage). requestFrame: skip ifworkerBusy; bumplatestJobId; postMessage.onWorkerMessage: drop stale jobs (id < latestJobId); buildImageData; calltransformFrame(imgData)if provided (this is the mask hook); resize canvas if needed;putImageData; update FPS EMA; resolve matchingpendingResolves; fireonFrame.- Main-thread fallback path likewise calls
transformFramebefore paint and firesonFramewithisMain: true. - Wall-clock → position math (from
rafTick):animStartTime,animStartSlider; loop mode uses((start + elapsed/loopDuration) % 1 + 1) % 1, linear mode usesstart + elapsed. The engine writesloopProgress/tinto the params it sends and reports the newpositionviaonFrame; the consumer decides what to do with it (studio updates the slider; element ignores it).
The same WORKER_SOURCE, skip-if-busy, and stale-drop logic are reused
verbatim — that is the byte-equivalence (regression) guarantee.
Module: perlin-element.js — <perlin-noise>¶
A renderer-only custom element. One PerlinEngine instance per element,
pointed at an internal shadow-DOM canvas.
Configuration API¶
<perlin-noise preset="lava" autoplay></perlin-noise>
<perlin-noise src="my-preset.json" autoplay max-resolution="1024"></perlin-noise>
- Observed attributes:
preset,src,autoplay,max-resolution(default1280),static(force a single still frame). - Properties:
el.params(get/set, fully-resolved object),el.play(),el.pause(),el.playing. - Precedence: explicitly-set
.params>srcJSON >presetbuilt-in > internal default (thedefaultbuilt-in). attributeChangedCallbackforpreset/srcre-resolves params and re-renders;autoplay/staticare evaluated on connect and on change.
Shadow DOM¶
A single<canvas> filling the host box.
Responsive sizing + resolution cap¶
- A
ResizeObserveron the host computes the render resolution:renderEdge = min(cssEdge × devicePixelRatio, maxResolution)per axis. The canvas backing store is set to the capped pixel size; CSS scales it to fill the host. This prevents a full-viewport hero from saturating the CPU. - Resolution-independent composition. The preset declares a reference
width/height. The element scales the noise coordinates so the preset's look is preserved across resolutions: it sendswidth = renderW,height = renderH, andscaleX' = scaleX × (refW / renderW),scaleY' = scaleY × (refH / renderH). Feature size therefore tracks the composition, not the raw pixel count.scaleT,loopRadius, octaves, etc. are already resolution-independent and pass through unchanged.
Accessibility & performance defaults¶
prefers-reduced-motion: reduce→ never autoplay; render one static frame. AmatchMediachange listener pauses/resumes live if the user toggles the OS setting.IntersectionObserver→ pause the engine when the element is fully off-screen, resume when visible — but only when the element is in its autoplay/playing state. Saves battery/CPU for below-the-fold heroes.- One worker per element instance — simplest, isolated. Fine for a single hero; many instances spawn many workers (documented limitation, acceptable for v1).
file:// behaviour¶
presetand.paramswork everywhere (no network).srcJSON is fetched withfetch(), which may be blocked by CORS underfile://. Documented:srcis the HTTP(S) embedding path; underfile://usepresetor.params. On fetch failure the element falls back to thedefaultbuilt-in and logs a console warning.
perlin-studio.js changes (consume engine + presets)¶
- Import
GRADIENTS/BUILTIN_PRESETSfromMTPerlinPresetsinstead of the local consts (delete the local copies). - Replace the inline worker/RAF orchestration (
initWorker,renderInMain,onWorkerMessage,requestFrame,renderFrameAndWait,rafTick, theanimStart*vars, the worker fields onstate) with a singlePerlinEngineinstance. onFrame: ({ position, fps }) =>update the slider readout (setTValue) and the FPS counter (els.fpsCounter).transformFrame: (imgData) => applyMaskToImageData(imgData)— the mask hook.renderOnce()→engine.setParams(gatherParams-resolved); engine.renderOnce().schedulePreview, sliderinput, loop-mode change → driveengine.seek/engine.renderOnce/ re-anchor.play/pause/togglePlay→ delegate toengine.play()/engine.pause().recordWebm→ useengine.renderFrameAndWait(params)per frame; keep the MediaRecorder/paramsForRecordingFrame/progress UI in the studio.- The studio passes resolved params to the engine. Today
gatherParamsalready resolves the gradient inline; route that throughMTPerlinPresets.resolveParams(or keep gatherParams' resolution and just hand the result to the engine) so studio and element resolve identically.
The studio's UI, presets UI, mask, recording, theme, and gatherParams DOM
reads all stay in perlin-studio.js.
Demo page: examples/embed.html¶
A minimal standalone page (top-level examples/, separate from the media in
docs/examples/) demonstrating:
- <perlin-noise preset="…"> declarative embed,
- a full-bleed responsive hero,
- src="…" JSON loading (works when served over HTTP),
- a note on reduced-motion behaviour,
- the four <script> includes in order (perlin.js, perlin-presets.js,
perlin-engine.js, perlin-element.js).
Standalone single-file build (final, delineated step)¶
scripts/build-standalone.js — a Node concatenation script in the style of the
existing scripts/render-*.js (no bundler). It concatenates perlin.js,
perlin-presets.js, perlin-engine.js, and perlin-element.js into a single
dist/perlin-noise.js, so third parties can embed with one <script> + one
<perlin-noise> tag. Drift risk is mitigated by regenerating in the Forgejo
deploy workflow (or at release time). This is a clearly separate final step,
not part of the core element work.
Verification¶
The controller has no automated tests, so the refactor's safety net is manual browser QA plus byte-equivalent reuse of the worker source.
node --checkon every JS file.node scripts/smoke-test.js— guards the math (perlin.js unchanged).- New
resolveParamssmoke check (pure logic, Node-importable): named gradient → correct stops; custom stops passed through; non-gradient modes untouched. Added as a new assertion block inscripts/smoke-test.js. - Manual studio QA (regression safety net): play/pause/Space; slider scrub in loop and linear mode; all five colour modes; custom-gradient editor; warp; layer 2; tile; mask load/apply/invert/clear; preset chips; preset JSON save/load round-trip; PNG export; WebM record + download; theme toggle; worker vs. main-thread fallback (FPS readout shows "(main)").
- Element QA:
preset,src,.params; autoplay; responsive resize; reduced-motion; off-screen pause; double-click underfile://.
Sandbox note: python3 -m http.server may be blocked from the maintainer's
workspace. If so, hand the QA checklist to the maintainer to run locally.
Risks¶
- Controller regression (no automated controller tests) → mitigated by
byte-equivalent engine internals (same
WORKER_SOURCE, same skip/stale logic) and the manual QA checklist. srcblocked underfile://→ documented;preset/.paramsare thefile://-safe paths; graceful fallback to the default built-in.- Standalone drift → deferred; regenerate in CI if built.
Out of scope (v1)¶
- Full
<perlin-studio>(controls-in-shadow-DOM) element. - Per-attribute exposure of all ~40 params.
- Shared worker across multiple element instances.
- A
<perlin-noise>instance on the studio page itself.
Documentation touch-list¶
CHANGELOG.md (new feature), README.md (roadmap tick + embedding section),
docs/explanation/architecture.md (the five-layer split + engine/element), docs/how-to/embed-in-a-web-page.md
(embedding usage), AGENTS.md (file-layout table + element notes).