0.28.1 — vendor @joan/procedural-glyph-engine for portable SPA builds (fixes deploy)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

The procedural-glyph-engine dep pointed at a non-portable file:/private/tmp/orby-pkg
path, breaking npm ci in Docker and every main deploy since v0.20.0 (the build
cache masked it until it busted ~Aug 5). Vendor Orby v5.0.0 into web/vendor/,
switch the dep to file:../vendor, and use npm install in the web Dockerfile
(file: deps need install, not ci). Cherry-picked from 3cd4cf9.
This commit is contained in:
2026-08-05 17:27:04 +02:00
parent fa79c1ea25
commit 8ff382a50d
41 changed files with 27242 additions and 326 deletions

687
web/vendor/src/config.js vendored Normal file
View File

@@ -0,0 +1,687 @@
import { FIELD_ALIASES, FIELD_IDS } from "./fields.js";
import { SPRITE_ALIASES, SPRITE_IDS } from "./sprites.js";
export const TRANSITION_IDS = Object.freeze([
"field-morph",
"seeded-dissolve",
"radial-cascade",
"angular-sweep",
"scanline",
"contour-trace",
"path-draw",
"axis-flip",
"cluster-dissolve",
"neighbor-ignite",
"glitch-bands",
"instant",
]);
export const PIXEL_SWITCH_IDS = Object.freeze([
"ordered-dither",
"temporal-blue-noise",
"threshold-hysteresis",
"sdf-wavefront",
"contour-trace",
"curl-advect",
"neighbor-propagation",
"radial-cascade",
"path-draw",
"axis-flip",
"seeded-dissolve",
"field-morph",
]);
export const PIXEL_SHAPE_IDS = Object.freeze([
"disc",
"square",
"diamond",
"capsule",
"line",
"ring",
"cross",
"square-cross",
"square-cross-ring",
]);
const TRANSITION_ALIASES = Object.freeze({
dissolve: "seeded-dissolve",
radial: "radial-cascade",
bloom: "radial-cascade",
spiral: "angular-sweep",
wave: "scanline",
shutter: "axis-flip",
glitch: "glitch-bands",
"sdf-wavefront": "contour-trace",
"neighbor-propagation": "neighbor-ignite",
});
const PIXEL_SWITCH_ALIASES = Object.freeze({
"cluster-dissolve": "seeded-dissolve",
"neighbor-ignite": "neighbor-propagation",
});
const PIXEL_SHAPE_ALIASES = Object.freeze({
circle: "disc",
"rounded-square": "square",
dot: "disc",
});
const ENGINE_OPTION_KEYS = Object.freeze([
"ariaLive",
"autoResize",
"autoplay",
"background",
"canvas",
"contrast",
"density",
"direction",
"dprMax",
"field",
"fieldMode",
"fps",
"gridSize",
"interactive",
"nonErrorPalette",
"offPixels",
"orbBoundary",
"orbBackgroundColor",
"orbBackgroundMode",
"package",
"palette",
"paletteOverride",
"pixelShape",
"pixelSwitch",
"previewMode",
"progress",
"quality",
"reducedMotion",
"seed",
"speed",
"sprite",
"targetGlyph",
"transition",
"version",
]);
const NUMBER_OPTIONS = Object.freeze({
contrast: { minimum: 0.25, maximum: 2 },
density: { minimum: 0.35, maximum: 1.6 },
dprMax: { minimum: 0.25, maximum: 8 },
fps: { minimum: 1, maximum: 120, integer: true },
gridSize: { minimum: 8, maximum: 96, integer: true },
progress: { minimum: 0, maximum: 1 },
speed: { minimum: 0.05, maximum: 5 },
});
const BOOLEAN_OPTIONS = Object.freeze([
"autoResize",
"autoplay",
"background",
"interactive",
"offPixels",
]);
const normalizeKey = (value) =>
String(value ?? "")
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-");
function distanceBetween(left, right) {
const a = [...String(left)];
const b = [...String(right)];
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
const current = new Array(b.length + 1);
for (let row = 1; row <= a.length; row += 1) {
current[0] = row;
for (let column = 1; column <= b.length; column += 1) {
current[column] = Math.min(
current[column - 1] + 1,
previous[column] + 1,
previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
);
}
for (let column = 0; column <= b.length; column += 1) {
previous[column] = current[column];
}
}
return previous[b.length];
}
export function nearestName(value, candidates, options = {}) {
const input = normalizeKey(value);
if (!input || !Array.isArray(candidates) || candidates.length === 0) {
return null;
}
let nearest = null;
let nearestDistance = Infinity;
for (const candidate of candidates) {
const distance = distanceBetween(input, normalizeKey(candidate));
if (distance < nearestDistance) {
nearest = candidate;
nearestDistance = distance;
}
}
const maximum =
options.maxDistance ?? Math.max(2, Math.floor(input.length * 0.34));
return nearestDistance <= maximum ? nearest : null;
}
export class JoanConfigurationError extends RangeError {
constructor(message, details = {}) {
super(message);
this.name = "JoanConfigurationError";
this.code = details.code || "invalid_configuration";
this.path = details.path || null;
this.value = details.value;
this.suggestion = details.suggestion || null;
this.allowed = details.allowed ? [...details.allowed] : null;
}
toIssue() {
return {
code: this.code,
message: this.message,
path: this.path,
value: this.value,
suggestion: this.suggestion,
severity: "error",
};
}
}
function aliasLookup(aliases) {
return new Map(
Object.entries(aliases || {}).map(([alias, canonical]) => [
normalizeKey(alias),
canonical,
]),
);
}
export function validateKnownValue(value, allowed, options = {}) {
const label = options.label || "value";
const path = options.path || label;
const normalized = normalizeKey(value);
const canonical = new Map(
allowed.map((candidate) => [normalizeKey(candidate), candidate]),
);
if (canonical.has(normalized)) return canonical.get(normalized);
const aliases = aliasLookup(options.aliases);
if (aliases.has(normalized)) return aliases.get(normalized);
const accepted = [...allowed, ...Object.keys(options.aliases || {})];
const suggestion = nearestName(value, accepted);
const suffix = suggestion ? ` Did you mean "${suggestion}"?` : "";
throw new JoanConfigurationError(
`Unknown ${label} "${String(value)}".${suffix}`,
{
code: `unknown_${label.replaceAll(" ", "_")}`,
path,
value,
suggestion,
allowed,
},
);
}
export const validateSpriteId = (value) =>
validateKnownValue(value, SPRITE_IDS, {
aliases: SPRITE_ALIASES,
label: "sprite",
path: "sprite",
});
export const validateFieldId = (value) =>
validateKnownValue(value, FIELD_IDS, {
aliases: FIELD_ALIASES,
label: "field",
path: "field",
});
export const validateTransition = (value) =>
validateKnownValue(
typeof value === "string"
? value
: value?.name || value?.type || value?.enter,
TRANSITION_IDS,
{
aliases: TRANSITION_ALIASES,
label: "transition",
path: "transition",
},
);
export const validatePixelShape = (value) =>
validateKnownValue(
typeof value === "string" ? value : value?.shape || value?.name,
PIXEL_SHAPE_IDS,
{
aliases: PIXEL_SHAPE_ALIASES,
label: "pixel shape",
path: "pixelShape",
},
);
export const validatePixelSwitch = (value) =>
validateKnownValue(
typeof value === "string"
? value
: value?.mode || value?.name || value?.type,
PIXEL_SWITCH_IDS,
{
aliases: PIXEL_SWITCH_ALIASES,
label: "pixel switch",
path: "pixelSwitch",
},
);
function issueFrom(error, fallbackPath) {
if (error instanceof JoanConfigurationError) return error.toIssue();
return {
code: "invalid_configuration",
message: error?.message || "Invalid engine configuration.",
path: fallbackPath || null,
value: undefined,
suggestion: null,
severity: "error",
};
}
function inspectNumber(key, value, definition, coerce) {
if (!coerce && typeof value !== "number") {
throw new JoanConfigurationError(`${key} must be a number.`, {
code: "invalid_type",
path: key,
value,
});
}
let next = coerce ? Number(value) : value;
if (!Number.isFinite(next)) {
throw new JoanConfigurationError(`${key} must be a finite number.`, {
code: "invalid_number",
path: key,
value,
});
}
if (definition.integer && !coerce && !Number.isInteger(next)) {
throw new JoanConfigurationError(`${key} must be an integer.`, {
code: "invalid_number",
path: key,
value,
});
}
if (definition.integer) next = Math.round(next);
if (next < definition.minimum || next > definition.maximum) {
if (!coerce) {
throw new JoanConfigurationError(
`${key} must be between ${definition.minimum} and ${definition.maximum}.`,
{
code: "out_of_range",
path: key,
value,
},
);
}
next = Math.min(Math.max(next, definition.minimum), definition.maximum);
}
return next;
}
function validatePalette(value, path, { allowVariants = true } = {}) {
if (value === null) return null;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new JoanConfigurationError(`${path} must be a palette object or null.`, {
code: "invalid_type",
path,
value,
});
}
const allowed = new Set([
"name",
"background",
"shadow",
"off",
"ink",
"accent",
"glow",
...(allowVariants ? ["variants"] : []),
]);
const next = {};
for (const [key, color] of Object.entries(value)) {
if (!allowed.has(key)) {
const suggestion = nearestName(key, [...allowed]);
throw new JoanConfigurationError(
`Unknown ${path} channel "${key}".${
suggestion ? ` Did you mean "${suggestion}"?` : ""
}`,
{
code: "unknown_palette_channel",
path: `${path}.${key}`,
value: color,
suggestion,
},
);
}
if (key === "variants") {
if (!color || typeof color !== "object" || Array.isArray(color)) {
throw new JoanConfigurationError(`${path}.variants must be an object.`, {
code: "invalid_type",
path: `${path}.variants`,
value: color,
});
}
next.variants = Object.fromEntries(
Object.entries(color).map(([name, palette]) => [
name,
validatePalette(palette, `${path}.variants.${name}`, {
allowVariants: false,
}),
]),
);
continue;
}
if (typeof color !== "string" || !color.trim()) {
throw new JoanConfigurationError(`${path}.${key} must be a CSS color string.`, {
code: "invalid_type",
path: `${path}.${key}`,
value: color,
});
}
next[key] = color.trim();
}
return next;
}
const PORTABLE_COLOR_KEYWORDS = new Set([
"aqua",
"black",
"blue",
"fuchsia",
"gray",
"green",
"grey",
"lime",
"maroon",
"navy",
"olive",
"orange",
"purple",
"red",
"silver",
"teal",
"transparent",
"white",
"yellow",
]);
function validateOptionalColor(value, path) {
if (value === null) return null;
if (typeof value !== "string" || !value.trim()) {
throw new JoanConfigurationError(`${path} must be a CSS color string or null.`, {
code: "invalid_type",
path,
value,
});
}
const candidate = value.trim();
if (candidate.toLowerCase() === "transparent") return null;
const functionMatch =
/^(rgb|rgba|hsl|hsla)\(([\d\s.,%+/-]+)\)$/i.exec(candidate);
const portable =
/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(candidate) ||
PORTABLE_COLOR_KEYWORDS.has(candidate.toLowerCase()) ||
(Boolean(functionMatch) && /\d/.test(functionMatch[2]));
if (!portable) {
throw new JoanConfigurationError(
`${path} must be a portable CSS color (hex, rgb, hsl, a supported keyword) or null.`,
{
code: "invalid_color",
path,
value,
},
);
}
return candidate;
}
export function inspectEngineOptions(input, options = {}) {
const coerce = options.coerce !== false;
if (!input || typeof input !== "object" || Array.isArray(input)) {
const error = new JoanConfigurationError(
"Engine options must be an object.",
{ code: "invalid_type", path: "options", value: input },
);
return { ok: false, value: {}, issues: [error.toIssue()] };
}
const value = { ...input };
const issues = [];
const allowedKeys = new Set(ENGINE_OPTION_KEYS);
if (options.allowUnknown !== true) {
for (const key of Object.keys(value)) {
if (allowedKeys.has(key)) continue;
const suggestion = nearestName(key, ENGINE_OPTION_KEYS);
issues.push({
code: "unknown_option",
message: `Unknown engine option "${key}".${
suggestion ? ` Did you mean "${suggestion}"?` : ""
}`,
path: key,
value: value[key],
suggestion,
severity: "error",
});
delete value[key];
}
}
const apply = (key, callback) => {
if (!Object.hasOwn(value, key) || value[key] === undefined) return;
try {
value[key] = callback(value[key]);
} catch (error) {
issues.push(issueFrom(error, key));
delete value[key];
}
};
apply("sprite", (sprite) =>
sprite && typeof sprite === "object" ? validateRecipe(sprite) : validateSpriteId(sprite),
);
apply("field", (field) => (field === null ? null : validateFieldId(field)));
apply("transition", (transition) =>
transition === null
? null
: transition && typeof transition === "object"
? {
...transition,
name: validateTransition(
transition.name || transition.type || transition.enter,
),
}
: validateTransition(transition),
);
apply("pixelShape", (shape) =>
shape === null ? null : validatePixelShape(shape),
);
apply("pixelSwitch", (pixelSwitch) =>
pixelSwitch === null
? null
: pixelSwitch && typeof pixelSwitch === "object"
? {
...pixelSwitch,
mode: validatePixelSwitch(
pixelSwitch.mode || pixelSwitch.name || pixelSwitch.type,
),
}
: validatePixelSwitch(pixelSwitch),
);
apply("fieldMode", (mode) =>
validateKnownValue(mode, ["recipe", "override"], {
label: "field mode",
path: "fieldMode",
}),
);
apply("quality", (quality) =>
validateKnownValue(quality, ["low", "balanced", "high", "auto"], {
label: "quality",
path: "quality",
}),
);
apply("previewMode", (mode) =>
validateKnownValue(mode, ["fit", "actual"], {
label: "preview mode",
path: "previewMode",
}),
);
apply("orbBoundary", (boundary) =>
validateKnownValue(boundary, ["defined", "gestalt"], {
label: "orb boundary",
path: "orbBoundary",
}),
);
apply("orbBackgroundColor", (color) =>
validateOptionalColor(color, "orbBackgroundColor"),
);
apply("orbBackgroundMode", (mode) =>
validateKnownValue(mode, ["none", "solid", "pixelated"], {
label: "orb background mode",
path: "orbBackgroundMode",
}),
);
apply("reducedMotion", (mode) => {
if (typeof mode === "boolean" || mode === "system") return mode;
throw new JoanConfigurationError(
'reducedMotion must be true, false, or "system".',
{ code: "invalid_type", path: "reducedMotion", value: mode },
);
});
for (const [key, definition] of Object.entries(NUMBER_OPTIONS)) {
apply(key, (number) => inspectNumber(key, number, definition, coerce));
}
for (const key of BOOLEAN_OPTIONS) {
apply(key, (boolean) => {
if (typeof boolean === "boolean") return boolean;
if (coerce && (boolean === "true" || boolean === "false")) {
return boolean === "true";
}
throw new JoanConfigurationError(`${key} must be a boolean.`, {
code: "invalid_type",
path: key,
value: boolean,
});
});
}
for (const key of ["palette", "paletteOverride", "nonErrorPalette"]) {
apply(key, (palette) => validatePalette(palette, key));
}
return { ok: issues.length === 0, value, issues };
}
export function validateEngineOptions(input, options = {}) {
const strict = options.strict !== false;
const result = inspectEngineOptions(input, {
...options,
coerce: options.coerce ?? !strict,
});
if (!result.ok && strict) {
const issue = result.issues[0];
throw new JoanConfigurationError(issue.message, issue);
}
return result.value;
}
export function validateRecipe(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new JoanConfigurationError("A recipe must be an object.", {
code: "invalid_type",
path: "recipe",
value: input,
});
}
if (typeof input.id !== "string" || !input.id.trim()) {
throw new JoanConfigurationError("A recipe requires a non-empty id.", {
code: "missing_recipe_id",
path: "recipe.id",
value: input.id,
});
}
if (typeof input.glyph !== "string" || !input.glyph.trim()) {
throw new JoanConfigurationError("A recipe requires a glyph id.", {
code: "missing_recipe_glyph",
path: "recipe.glyph",
value: input.glyph,
});
}
const recipe = { ...input, id: input.id.trim(), glyph: input.glyph.trim() };
if (recipe.field !== undefined) recipe.field = validateFieldId(recipe.field);
if (Array.isArray(recipe.fieldMix)) {
recipe.fieldMix = recipe.fieldMix.map((layer, index) => {
if (!layer || typeof layer !== "object") {
throw new JoanConfigurationError(`recipe.fieldMix[${index}] must be an object.`, {
code: "invalid_type",
path: `recipe.fieldMix[${index}]`,
value: layer,
});
}
return { ...layer, field: validateFieldId(layer.field) };
});
} else if (!recipe.field) {
throw new JoanConfigurationError("A recipe requires field or fieldMix.", {
code: "missing_recipe_field",
path: "recipe.field",
value: recipe.field,
});
}
if (recipe.pixelShape !== undefined) {
recipe.pixelShape = validatePixelShape(recipe.pixelShape);
}
if (recipe.pixel?.shape !== undefined) {
recipe.pixel = {
...recipe.pixel,
shape: validatePixelShape(recipe.pixel.shape),
};
}
if (recipe.pixelSwitch !== undefined) {
if (typeof recipe.pixelSwitch === "string") {
recipe.pixelSwitch = validatePixelSwitch(recipe.pixelSwitch);
} else if (recipe.pixelSwitch?.mode !== undefined) {
recipe.pixelSwitch = {
...recipe.pixelSwitch,
mode: validatePixelSwitch(recipe.pixelSwitch.mode),
};
}
}
if (recipe.transition !== undefined) {
if (typeof recipe.transition === "string") {
recipe.transition = validateTransition(recipe.transition);
} else if (recipe.transition && typeof recipe.transition === "object") {
const transition = { ...recipe.transition };
for (const key of ["name", "type", "enter", "exit"]) {
if (transition[key] !== undefined) {
transition[key] = validateTransition(transition[key]);
}
}
recipe.transition = transition;
}
}
return recipe;
}
function deepFreeze(value, seen = new WeakSet()) {
if (!value || typeof value !== "object" || seen.has(value)) return value;
seen.add(value);
for (const nested of Object.values(value)) deepFreeze(nested, seen);
return Object.freeze(value);
}
export function defineRecipe(recipe) {
return deepFreeze(validateRecipe(recipe));
}

207
web/vendor/src/control-help.js vendored Normal file
View File

@@ -0,0 +1,207 @@
export const FIELD_HELP = Object.freeze({
__recipe__:
"Uses the selected states authored blend of fields, preserving its intended visual character.",
fbm: "Builds soft, organic variation by layering several scales of smooth gradient noise.",
ridged:
"Folds layered noise into sharp ridges and filaments for a more etched texture.",
"domain-warp":
"Distorts layered noise with two moving noise fields, creating fluid folds and swirls.",
curl: "Turns the curl magnitude of animated noise into turbulent, smoke-like density.",
flow: "Carries animated ribbons along a curl-noise direction for a flowing texture.",
worley:
"Creates bright cellular islands around moving feature points, separated by darker gaps.",
voronoi:
"Smoothly morphs between seeded cellular layouts while keeping their moving division lines crisp.",
plasma:
"Combines layered sine waves with noise-driven phase shifts for an energetic plasma pattern.",
interference:
"Overlaps circular waves from moving emitters to create traveling beats and bands.",
vortex:
"Spins noisy spiral arms around the center for a rotating whirlpool-like field.",
metaballs:
"Blends several seeded moving blobs so nearby forms merge into one liquid mass.",
caustics:
"Produces sharp cellular highlights that resemble refracted light moving across water.",
strata:
"Stacks noise-warped sediment-like bands with a directional slope and sharp edges.",
radar:
"Combines a rotating beam, concentric rings, and seeded target blips.",
constellation:
"Connects twinkling cellular stars with faint Voronoi filaments.",
liquid:
"Layers domain-warped waves and highlights into a gently moving water surface.",
electric:
"Generates repeating branching lightning channels with pulses and traveling sparks.",
ripple: "Sends noise-distorted concentric rings outward from the center.",
kaleidoscope:
"Folds rotating polar noise into mirrored, mandala-like symmetry.",
});
export const TRANSITION_HELP = Object.freeze({
"field-morph":
"Morphs cells on a soft schedule combining seeded randomness, radius, and vertical position.",
"seeded-dissolve":
"Changes pixels in a stable, seed-determined random order.",
"radial-cascade":
"Moves the new state outward from the origin with slight seeded variation.",
"angular-sweep":
"Rotates the new state around the origin with a subtle outward offset.",
scanline:
"Reveals the new state from top to bottom in a lightly rippled scan.",
"contour-trace":
"Starts on a circular mid-radius contour, then spreads inward and outward.",
"path-draw":
"Draws the new state diagonally from the upper left toward the lower right.",
"axis-flip":
"Staggers alternating columns while advancing top to bottom, producing a shutter-like flip.",
"cluster-dissolve":
"Changes coarse pixel clusters in a seeded random order for a chunkier dissolve.",
"neighbor-ignite":
"Spreads the new state in a connected-looking wave from the origin.",
"glitch-bands":
"Switches horizontal bands in a scrambled order for a brief glitch effect.",
instant: "Applies the new state immediately with no spatial interpolation.",
});
export const PIXEL_SHAPE_HELP = Object.freeze({
disc: "Draws each cell as a filled round dot.",
square: "Draws each cell as a filled square for a crisp grid texture.",
diamond: "Draws each cell as a compact square rotated 45 degrees.",
capsule: "Draws each cell as a rounded horizontal pill.",
line: "Draws each cell as a thin bar aligned tangentially around the center.",
ring: "Draws each cell as a hollow circular outline.",
cross: "Draws each cell as a compact plus sign.",
"square-cross":
"Uses filled squares for dim sampled signal cells and crosses for bright cells.",
"square-cross-ring":
"Uses filled squares, crosses, then rings as sampled signal lightness rises.",
});
export const PIXEL_SWITCH_HELP = Object.freeze({
"ordered-dither":
"Uses a fixed Bayer pattern for stable, evenly distributed pixel activation.",
"temporal-blue-noise":
"Blends an ordered grid with time-varying noise for lively, fine-grained switching.",
"threshold-hysteresis":
"Uses a uniform midpoint threshold with hysteresis for calm, stable switching.",
"sdf-wavefront":
"Adds a moving radial wavefront to ordered thresholds so activation travels in rings.",
"contour-trace":
"Modulates thresholds along repeating field-value contours so similar intensity bands switch together.",
"curl-advect":
"Offsets ordered thresholds with a traveling wave so activation appears to drift diagonally.",
"neighbor-propagation":
"Lowers a cells threshold when adjacent cells are on, encouraging activation to spread locally.",
"radial-cascade":
"Biases ordered activation from the center outward.",
"path-draw":
"Biases ordered activation along a top-left-to-bottom-right path.",
"axis-flip":
"Staggers alternating columns and compresses cells vertically as they turn on, creating a flip.",
"seeded-dissolve":
"Gives each cell a stable, seed-determined random threshold for a repeatable dissolve texture.",
"field-morph":
"Blends ordered and seed-random thresholds for both structure and organic variation.",
});
export const SELECT_CONTROL_HELP = Object.freeze({
fieldSelect: Object.freeze({
summary: "Chooses the procedural texture sampled inside the glyph.",
options: FIELD_HELP,
}),
transitionSelect: Object.freeze({
summary:
"Controls the spatial order used when moving between semantic states.",
options: TRANSITION_HELP,
}),
pixelShapeSelect: Object.freeze({
summary: "Changes the silhouette used to draw each active cell.",
options: PIXEL_SHAPE_HELP,
}),
pixelSwitchSelect: Object.freeze({
summary:
"Controls how continuous field intensity becomes persistent on/off pixels.",
options: PIXEL_SWITCH_HELP,
}),
orbBackgroundModeSelect: Object.freeze({
summary: "Chooses how the optional orb background disk is constructed.",
options: Object.freeze({
solid:
"Fills one smooth circle behind the orb while preserving the grid above it.",
pixelated:
"Builds the disk from full grid-aligned cells so its edge matches the selected resolution.",
}),
}),
});
export const VALUE_CONTROL_HELP = Object.freeze({
resolutionRange:
"Sets the number of cells per side. Higher values add detail but increase rendering work roughly with the square.",
speedRange:
"Multiplies the procedural animation clock, making textures and sprite motion run slower or faster.",
densityRange:
"Biases activation so higher values keep more pixels on and lower values make the signal sparser.",
seedInput:
"A stable text key that determines noise and per-cell randomness. Reuse it with the same settings to reproduce the signal.",
randomizeButton:
"Creates a new seed and a new deterministic variation of the current signal.",
backgroundColorInput:
"Sets the canvas fill behind the grid across non-error states.",
offColorInput:
"Sets the faint color of grid cells that are currently off across non-error states.",
inkColorInput:
"Sets the base color of active pixels across non-error states.",
accentColorInput:
"Sets the color active pixels move toward as sampled luminance rises.",
glowEnabledInput:
"Turns the halo around active pixels on or off across non-error states.",
orbBoundaryInput:
"Defined draws the authored rim. Gestalt removes the continuous outline and lets clustered edge pixels suggest the circle through proximity, closure, and continuing arcs.",
orbBackgroundEnabledInput:
"Adds or removes the optional orb background layer in supported presence orbs. It is separate from the canvas background.",
orbBackgroundColorInput:
"Sets the optional color behind supported orb pixels. The color carries across states but stays visually inert for non-orb recipes.",
glowColorInput:
"Sets the halo around active pixels. Glow renders in high quality at resolutions up to 48×48.",
resetPaletteButton:
"Clears custom color overrides and returns non-error states to the current themes defaults.",
});
const clamp = (value, minimum, maximum) =>
Math.min(Math.max(value, minimum), maximum);
export function placeControlTooltip(
anchor,
tooltip,
viewport,
options = {},
) {
const gap = Number(options.gap) || 8;
const gutter = Number(options.gutter) || 12;
const maximumLeft = Math.max(
gutter,
Number(viewport.width) - Number(tooltip.width) - gutter,
);
const centeredLeft =
Number(anchor.left) +
Number(anchor.width) / 2 -
Number(tooltip.width) / 2;
const left = clamp(centeredLeft, gutter, maximumLeft);
const below = Number(anchor.bottom) + gap;
const above = Number(anchor.top) - Number(tooltip.height) - gap;
const fitsBelow =
below + Number(tooltip.height) <= Number(viewport.height) - gutter;
const fitsAbove = above >= gutter;
const maximumTop = Math.max(
gutter,
Number(viewport.height) - Number(tooltip.height) - gutter,
);
const placement = !fitsBelow && fitsAbove ? "top" : "bottom";
const preferredTop = placement === "top" ? above : below;
return {
left: Math.round(left),
top: Math.round(clamp(preferredTop, gutter, maximumTop)),
placement,
};
}

1323
web/vendor/src/fields.js vendored Normal file

File diff suppressed because it is too large Load Diff

1127
web/vendor/src/glyphs.js vendored Normal file

File diff suppressed because it is too large Load Diff

4309
web/vendor/src/joan-engine.js vendored Normal file

File diff suppressed because it is too large Load Diff

63
web/vendor/src/preview-layout.js vendored Normal file
View File

@@ -0,0 +1,63 @@
export const PREVIEW_MODES = Object.freeze(["fit", "actual"]);
const DEFAULT_RESOLUTION = 68;
const MIN_RESOLUTION = 8;
const MAX_RESOLUTION = 96;
const FIT_INSET = 0.075;
function finite(value, fallback) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
}
export function normalizePreviewMode(mode) {
return mode === "actual" ? "actual" : "fit";
}
export function normalizePreviewResolution(resolution) {
return Math.min(
MAX_RESOLUTION,
Math.max(
MIN_RESOLUTION,
Math.round(finite(resolution, DEFAULT_RESOLUTION)),
),
);
}
export function previewModeDetails(mode, resolution) {
const normalizedMode = normalizePreviewMode(mode);
const normalizedResolution = normalizePreviewResolution(resolution);
return {
mode: normalizedMode,
resolution: normalizedResolution,
cellSize: normalizedMode === "actual" ? 1 : null,
footprint: normalizedMode === "actual" ? normalizedResolution : null,
};
}
export function canvasGridLayout(mode, resolution, width, height) {
const details = previewModeDetails(mode, resolution);
const safeWidth = Math.max(1, finite(width, details.resolution));
const safeHeight = Math.max(1, finite(height, details.resolution));
if (details.mode === "actual") {
const stageSize = details.resolution;
return {
...details,
stageSize,
cellSize: 1,
offsetX: Math.round((safeWidth - stageSize) / 2),
offsetY: Math.round((safeHeight - stageSize) / 2),
};
}
const shortSide = Math.min(safeWidth, safeHeight);
const margin = shortSide * FIT_INSET;
const stageSize = shortSide - margin * 2;
return {
...details,
stageSize,
cellSize: stageSize / details.resolution,
offsetX: (safeWidth - stageSize) / 2,
offsetY: (safeHeight - stageSize) / 2,
};
}

82
web/vendor/src/preview-thumbnails.js vendored Normal file
View File

@@ -0,0 +1,82 @@
const DEFAULT_PREVIEW_PROGRESS = 0.48;
function clamp01(value) {
return Math.min(1, Math.max(0, Number(value) || 0));
}
function representativeTime(recipe) {
const phase = clamp01(recipe?.reducedMotion?.phase ?? 0.25);
const cycleMs =
Number(recipe?.timeline?.macroCycleMs) ||
Number(recipe?.timeline?.durationMs) ||
4000;
return (Math.max(1, cycleMs) / 1000) * phase;
}
/**
* One-shot recipes may advance to a different state while their card is active.
* Atlas previews retain the requested recipe so their identity and resting frame
* always match the card label.
*/
export function createThumbnailRecipe(recipe) {
if (!recipe?.timeline?.next) return recipe;
return {
...recipe,
timeline: {
...recipe.timeline,
next: null,
},
};
}
export function shouldAnimateThumbnail(
id,
{
selectedId = null,
mainRunning = false,
hoveredIds = new Set(),
focusedIds = new Set(),
} = {},
) {
return (
(id === selectedId && mainRunning) ||
hoveredIds.has(id) ||
focusedIds.has(id)
);
}
/**
* Render a deterministic, authored representative phase without leaving the
* preview in reduced-motion mode. JoanGlyphEngine maps time zero to each
* recipe's reducedMotion.phase while reduced motion is enabled.
*/
export function renderThumbnailRestFrame(
preview,
{ progress = DEFAULT_PREVIEW_PROGRESS } = {},
) {
if (!preview || preview.destroyed) return null;
const nextProgress = clamp01(progress);
if (Math.abs(preview.progress - nextProgress) > 1e-6) {
preview.setProgress(nextProgress);
}
// Neighbor-propagation previews otherwise retain their previous gate buffer,
// making the supposedly static pose depend on how long the card was hovered.
preview.stateElapsed = representativeTime(preview.currentRecipe);
preview.gates?.fill(0);
preview.neighborBuffer?.fill(0);
preview.dwell?.fill(0);
const previousReducedMotion = preview.reducedMotion;
preview.reducedMotion = true;
let stats;
try {
stats = preview.renderOnce(0);
} finally {
preview.reducedMotion = previousReducedMotion;
}
if (preview.canvas?.dataset) preview.canvas.dataset.previewState = "ready";
return stats;
}

1766
web/vendor/src/sprites.js vendored Normal file

File diff suppressed because it is too large Load Diff

287
web/vendor/src/state-director.js vendored Normal file
View File

@@ -0,0 +1,287 @@
function detailEvent(type, detail) {
if (typeof CustomEvent === "function") {
return new CustomEvent(type, { detail });
}
const event = new Event(type);
Object.defineProperty(event, "detail", { value: detail });
return event;
}
function abortError(signal) {
if (signal?.reason instanceof Error) return signal.reason;
const message = signal?.reason ? String(signal.reason) : "The operation was aborted.";
if (typeof DOMException === "function") {
return new DOMException(message, "AbortError");
}
const error = new Error(message);
error.name = "AbortError";
return error;
}
function stateId(sprite) {
return typeof sprite === "string" ? sprite : sprite?.id || String(sprite);
}
/**
* Optional semantic timing helper. The host explicitly begins and completes work;
* the director only handles presentation thresholds and never infers AI state.
*/
export class StateDirector extends EventTarget {
constructor(engine, options = {}) {
super();
if (!engine?.setSprite) throw new TypeError("StateDirector requires an engine.");
this.engine = engine;
this.options = {
deepThinkingAfterMs: 7000,
stillWorkingAfterMs: 18000,
transition: "field-morph",
...options,
};
this.timers = new Set();
this.state = engine.currentRecipe?.id || "ai.idle";
this.operation = 0;
this.destroyed = false;
}
clearTimers() {
for (const timer of this.timers) clearTimeout(timer);
this.timers.clear();
}
schedule(callback, delay) {
const timer = setTimeout(() => {
this.timers.delete(timer);
callback();
}, Math.max(0, Number(delay) || 0));
this.timers.add(timer);
return timer;
}
changeState(sprite, engineOptions = {}, metadata = {}) {
if (metadata.clearTimers !== false) this.clearTimers();
const from = this.state;
const to = stateId(sprite);
this.state = to;
this.engine.setSprite(sprite, engineOptions);
this.dispatchEvent(
detailEvent("statechange", {
state: to,
from,
to,
reason: metadata.reason || "set",
}),
);
return this;
}
set(sprite, options = {}) {
this.operation += 1;
return this.changeState(
sprite,
{
transition: options.transition || this.options.transition,
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason: options.reason || "set" },
);
}
startThinking(options, operation) {
this.changeState(
"ai.thinking",
{
transition: options.transition || this.options.transition,
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason: options.reason || "thinking-start" },
);
this.schedule(() => {
if (this.operation !== operation || this.state !== "ai.thinking") return;
this.changeState(
"ai.thinking-deep",
{ transition: "neighbor-ignite", preservePhase: true },
{ clearTimers: false, reason: "thinking-escalation" },
);
}, options.deepThinkingAfterMs ?? this.options.deepThinkingAfterMs);
this.schedule(() => {
if (
this.operation !== operation ||
!["ai.thinking", "ai.thinking-deep"].includes(this.state)
) {
return;
}
this.changeState(
"ai.still-working",
{ transition: "radial-cascade", preservePhase: true },
{ clearTimers: false, reason: "thinking-escalation" },
);
}, options.stillWorkingAfterMs ?? this.options.stillWorkingAfterMs);
return this;
}
beginThinking(options = {}) {
const operation = ++this.operation;
return this.startThinking(options, operation);
}
finish(options = {}, reason = "complete") {
const sprite = options.sprite || "status.success";
this.changeState(
sprite,
{
transition: options.transition || "path-draw",
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason },
);
this.engine.signal?.("complete", { energy: 1 });
return this;
}
complete(options = {}) {
this.operation += 1;
return this.finish(options, options.reason || "complete");
}
reject(options = {}, reason = "failure") {
return this.changeState(
options.sprite || "status.error",
{
transition: options.transition || "glitch-bands",
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason },
);
}
fail(options = {}) {
this.operation += 1;
return this.reject(options, options.reason || "failure");
}
cancel(options = {}) {
this.operation += 1;
return this.changeState(
options.sprite || "status.cancelled",
{
transition: options.transition || "seeded-dissolve",
preservePhase: options.preservePhase ?? true,
duration: options.duration,
},
{ reason: options.reason || "cancelled" },
);
}
reset(options = {}) {
this.operation += 1;
return this.changeState(
options.sprite || "ai.idle",
{
transition: options.transition || "field-morph",
preservePhase: options.preservePhase ?? false,
duration: options.duration,
},
{ reason: options.reason || "reset" },
);
}
/**
* Present one asynchronous task through thinking escalation and a terminal
* state. The work receives `{ signal, director, engine }`; existing zero-arg
* functions remain valid because JavaScript ignores extra arguments.
*/
async run(work, options = {}) {
if (this.destroyed) throw new Error("StateDirector has been destroyed.");
if (typeof work !== "function" && !work?.then) {
throw new TypeError("StateDirector.run requires a function or Promise.");
}
const signal = options.signal;
const operation = ++this.operation;
if (signal?.aborted) {
if (this.operation === operation) {
this.changeState(
options.cancel?.sprite || options.cancelledSprite || "status.cancelled",
{
transition: options.cancel?.transition || "seeded-dissolve",
preservePhase: options.cancel?.preservePhase ?? true,
duration: options.cancel?.duration,
},
{ reason: "run-aborted" },
);
}
throw abortError(signal);
}
this.startThinking(options.thinking || options, operation);
let removeAbortListener = () => {};
let task;
try {
task =
typeof work === "function"
? Promise.resolve().then(() =>
work({ signal, director: this, engine: this.engine }),
)
: Promise.resolve(work);
if (signal) {
const aborted = new Promise((_, reject) => {
const onAbort = () => reject(abortError(signal));
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
});
task = Promise.race([task, aborted]);
}
const result = await task;
if (this.operation === operation) {
this.finish(
{
...(options.complete || {}),
sprite: options.complete?.sprite || options.successSprite,
},
"run-complete",
);
}
return result;
} catch (error) {
if (this.operation === operation) {
if (signal?.aborted || error?.name === "AbortError") {
this.changeState(
options.cancel?.sprite || options.cancelledSprite || "status.cancelled",
{
transition: options.cancel?.transition || "seeded-dissolve",
preservePhase: options.cancel?.preservePhase ?? true,
duration: options.cancel?.duration,
},
{ reason: "run-aborted" },
);
} else {
this.reject(
{
...(options.fail || {}),
sprite: options.fail?.sprite || options.errorSprite,
},
"run-failed",
);
}
}
throw error;
} finally {
removeAbortListener();
}
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.operation += 1;
this.clearTimers();
}
}
export default StateDirector;

649
web/vendor/src/state-sequence.js vendored Normal file
View File

@@ -0,0 +1,649 @@
export const STATE_SEQUENCE_SCHEMA_VERSION = 1;
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
function detailEvent(type, detail) {
if (typeof CustomEvent === "function") {
return new CustomEvent(type, { detail });
}
const event = new Event(type);
Object.defineProperty(event, "detail", { value: detail });
return event;
}
function abortError(source) {
if (source?.reason instanceof Error) return source.reason;
const reason = source?.reason ?? source;
const message = reason ? String(reason) : "The state sequence was aborted.";
if (typeof DOMException === "function") {
return new DOMException(message, "AbortError");
}
const error = new Error(message);
error.name = "AbortError";
return error;
}
function stateId(sprite) {
return typeof sprite === "string" ? sprite : sprite?.id || String(sprite);
}
function normalizeTransition(value, path) {
if (value === undefined || value === false || typeof value === "string") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${path} must be false, a transition name, or an options object.`);
}
return Object.freeze({ ...value });
}
function normalizeOptions(value, path) {
if (value === undefined) return undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${path} must be an options object.`);
}
return Object.freeze({ ...value });
}
function normalizeStep(input, index) {
const source =
typeof input === "string"
? { sprite: input }
: input && typeof input === "object"
? input
: null;
const sprite = source?.sprite;
if (
!source ||
(typeof sprite === "string"
? !sprite.trim()
: !sprite || typeof sprite !== "object")
) {
throw new TypeError(`State sequence step ${index} requires a sprite.`);
}
const rawHold = source.holdMs ?? source.durationMs ?? 0;
const holdMs = Number(rawHold);
if (!Number.isFinite(holdMs) || holdMs < 0) {
throw new RangeError(`State sequence step ${index} holdMs must be a non-negative number.`);
}
const step = {
sprite: source.sprite,
holdMs,
};
if (hasOwn(source, "transition")) {
step.transition = normalizeTransition(
source.transition,
`State sequence step ${index} transition`,
);
}
if (hasOwn(source, "options")) {
step.options = normalizeOptions(
source.options,
`State sequence step ${index} options`,
);
}
if (hasOwn(source, "label")) step.label = String(source.label);
if (hasOwn(source, "metadata")) {
step.metadata = normalizeOptions(
source.metadata,
`State sequence step ${index} metadata`,
);
}
return Object.freeze(step);
}
/**
* Create a frozen named sequence definition. Definitions that use sprite IDs
* and data-only metadata/options can be serialized directly as JSON.
*
* @example
* const readyThenDone = defineStateSequence("ready-then-done", [
* { sprite: "ai.idle", holdMs: 5000 },
* { sprite: "status.success", transition: "path-draw" },
* ]);
*/
export function defineStateSequence(nameOrDefinition, steps, options = {}) {
const source =
nameOrDefinition && typeof nameOrDefinition === "object" && !Array.isArray(nameOrDefinition)
? nameOrDefinition
: { ...options, name: nameOrDefinition, steps };
const name = String(source.name || "").trim();
if (!name) throw new TypeError("A state sequence requires a non-empty name.");
if (
source.schemaVersion !== undefined &&
Number(source.schemaVersion) !== STATE_SEQUENCE_SCHEMA_VERSION
) {
throw new RangeError(
`Unsupported state sequence schema version "${String(source.schemaVersion)}".`,
);
}
if (!Array.isArray(source.steps) || source.steps.length === 0) {
throw new TypeError(`State sequence "${name}" requires at least one step.`);
}
const sequence = {
schemaVersion: STATE_SEQUENCE_SCHEMA_VERSION,
name,
steps: Object.freeze(source.steps.map(normalizeStep)),
transitionFirst: source.transitionFirst === true,
};
if (hasOwn(source, "transition")) {
sequence.transition = normalizeTransition(
source.transition,
`State sequence "${name}" transition`,
);
}
if (hasOwn(source, "metadata")) {
sequence.metadata = normalizeOptions(
source.metadata,
`State sequence "${name}" metadata`,
);
}
return Object.freeze(sequence);
}
function createClock(clock) {
if (
clock &&
(typeof clock.now !== "function" ||
typeof clock.setTimeout !== "function" ||
typeof clock.clearTimeout !== "function")
) {
throw new TypeError(
"A custom sequence clock requires now(), setTimeout(), and clearTimeout().",
);
}
const now =
clock
? () => clock.now()
: () =>
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
const schedule =
clock
? (callback, delay) => clock.setTimeout(callback, delay)
: (callback, delay) => setTimeout(callback, delay);
const cancel =
clock
? (timer) => clock.clearTimeout(timer)
: (timer) => clearTimeout(timer);
return Object.freeze({ now, setTimeout: schedule, clearTimeout: cancel });
}
function transitionOptions(specification, preservePhase, stepOptions, signal) {
const options = { preservePhase };
if (typeof specification === "string") {
options.transition = specification;
} else if (specification && typeof specification === "object") {
Object.assign(options, specification);
if (!options.transition && typeof options.name === "string") {
options.transition = options.name;
delete options.name;
}
}
Object.assign(options, stepOptions || {});
options.signal = signal;
return options;
}
/**
* Plays serial state definitions against any target implementing setSprite().
* transitionTo() is awaited when available; otherwise setSprite() is used.
*/
export class StateSequencePlayer extends EventTarget {
constructor(target, options = {}) {
super();
if (!target || typeof target.setSprite !== "function") {
throw new TypeError("StateSequencePlayer requires a setSprite-capable target.");
}
this.target = target;
this.clock = createClock(options.clock);
this.options = Object.freeze({
transition: hasOwn(options, "transition")
? normalizeTransition(options.transition, "Default transition")
: "field-morph",
transitionFirst: options.transitionFirst === true,
preservePhase: options.preservePhase !== false,
});
this.sequences = new Map();
this.status = "idle";
this.state = target.currentRecipe?.id || null;
this.destroyed = false;
this.finished = Promise.resolve(null);
this._active = null;
this._serial = 0;
if (Array.isArray(options.sequences)) {
for (const sequence of options.sequences) this.register(sequence);
} else if (options.sequences && typeof options.sequences === "object") {
for (const [name, sequence] of Object.entries(options.sequences)) {
this.register(
Array.isArray(sequence)
? defineStateSequence(name, sequence)
: defineStateSequence({ ...sequence, name: sequence.name || name }),
);
}
}
}
get currentSequence() {
return this._active?.sequence || null;
}
get currentStepIndex() {
return this._active?.index ?? -1;
}
get isPlaying() {
return this.status === "running" || this.status === "paused";
}
get isPaused() {
return this.status === "paused";
}
register(nameOrDefinition, steps, options) {
if (this.destroyed) throw new Error("StateSequencePlayer has been destroyed.");
const sequence = defineStateSequence(nameOrDefinition, steps, options);
this.sequences.set(sequence.name, sequence);
return sequence;
}
unregister(name) {
return this.sequences.delete(String(name));
}
getSequence(name) {
return this.sequences.get(String(name)) || null;
}
listSequences() {
return Object.freeze([...this.sequences.values()]);
}
_resolveSequence(input, options) {
if (typeof input === "string") {
const sequence = this.getSequence(input);
if (!sequence) throw new RangeError(`Unknown state sequence "${input}".`);
return sequence;
}
if (Array.isArray(input)) {
return defineStateSequence(options.name || `sequence-${this._serial + 1}`, input);
}
return defineStateSequence(input);
}
_normalizePlayOptions(options) {
const normalized = {
signal: options.signal,
preservePhase: options.preservePhase ?? this.options.preservePhase,
transitionFirst: options.transitionFirst,
};
if (hasOwn(options, "transition")) {
normalized.transition = normalizeTransition(options.transition, "Play transition");
}
return normalized;
}
play(sequenceOrName, options = {}) {
if (this.destroyed) {
return Promise.reject(new Error("StateSequencePlayer has been destroyed."));
}
let sequence;
let playOptions;
try {
sequence = this._resolveSequence(sequenceOrName, options);
playOptions = this._normalizePlayOptions(options);
if (playOptions.signal?.aborted) throw abortError(playOptions.signal);
} catch (error) {
return Promise.reject(error);
}
if (this._active) this._stopRun(this._active, "superseded");
const run = {
id: ++this._serial,
sequence,
options: playOptions,
controller: new AbortController(),
index: -1,
completedSteps: 0,
paused: false,
delay: null,
gates: new Set(),
terminal: null,
removeExternalAbort: () => {},
};
this._active = run;
this.status = "running";
if (playOptions.signal) {
const onAbort = () => this._cancelRun(run, playOptions.signal);
playOptions.signal.addEventListener("abort", onAbort, { once: true });
run.removeExternalAbort = () =>
playOptions.signal.removeEventListener("abort", onAbort);
if (playOptions.signal.aborted) this._cancelRun(run, playOptions.signal);
}
this._emit("sequencestart", run, { totalSteps: sequence.steps.length });
const finished = this._execute(run).then(
() => {
if (!run.terminal) this._completeRun(run);
return this._result(run);
},
(error) => {
if (run.terminal?.status === "stopped") return this._result(run);
if (run.terminal?.status === "cancelled") throw run.terminal.error;
this._errorRun(run, error);
throw error;
},
).finally(() => this._finalizeRun(run));
run.finished = finished;
this.finished = finished;
return finished;
}
run(sequenceOrName, options = {}) {
return this.play(sequenceOrName, options);
}
async _execute(run) {
for (let index = 0; index < run.sequence.steps.length; index += 1) {
if (run.controller.signal.aborted) throw abortError(run.controller.signal);
const step = run.sequence.steps[index];
run.index = index;
this._emit("stepstart", run, {
step,
sprite: stateId(step.sprite),
});
await this._applyStep(run, step, index);
await this._pauseGate(run);
if (run.controller.signal.aborted) throw abortError(run.controller.signal);
const from = this.state;
const to = stateId(step.sprite);
this.state = to;
this._emit("statechange", run, { from, to, state: to, step });
this._emit("stepenter", run, { from, to, step, sprite: to });
if (step.holdMs > 0) await this._delay(run, step.holdMs);
await this._pauseGate(run);
if (run.controller.signal.aborted) throw abortError(run.controller.signal);
run.completedSteps = index + 1;
this._emit("stepcomplete", run, { step, sprite: to });
}
}
_transitionFor(run, step, index) {
if (hasOwn(step, "transition")) return step.transition;
const transitionFirst =
run.options.transitionFirst ??
(run.sequence.transitionFirst || this.options.transitionFirst);
if (index === 0 && !transitionFirst) return false;
if (hasOwn(run.options, "transition")) return run.options.transition;
if (hasOwn(run.sequence, "transition")) return run.sequence.transition;
return this.options.transition;
}
async _applyStep(run, step, index) {
const specification = this._transitionFor(run, step, index);
if (specification === false) {
const options = { immediate: true, ...(step.options || {}) };
this.target.setSprite(step.sprite, options);
return;
}
if (typeof this.target.transitionTo !== "function") {
const options = transitionOptions(
specification,
run.options.preservePhase,
step.options,
run.controller.signal,
);
delete options.signal;
this.target.setSprite(step.sprite, options);
return;
}
const options = transitionOptions(
specification,
run.options.preservePhase,
step.options,
run.controller.signal,
);
let transition;
try {
transition = this.target.transitionTo(step.sprite, options);
} catch (error) {
throw error;
}
await this._raceAbort(transition, run);
}
_raceAbort(value, run) {
if (run.controller.signal.aborted) {
return Promise.reject(abortError(run.controller.signal));
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, result) => {
if (settled) return;
settled = true;
run.controller.signal.removeEventListener("abort", onAbort);
callback(result);
};
const onAbort = () => finish(reject, abortError(run.controller.signal));
run.controller.signal.addEventListener("abort", onAbort, { once: true });
Promise.resolve(value).then(
(result) => finish(resolve, result),
(error) => finish(reject, error),
);
});
}
_pauseGate(run) {
if (!run.paused) return Promise.resolve();
if (run.controller.signal.aborted) {
return Promise.reject(abortError(run.controller.signal));
}
return new Promise((resolve, reject) => {
let settled = false;
const gate = {
resolve: () => finish(resolve),
};
const finish = (callback, value) => {
if (settled) return;
settled = true;
run.gates.delete(gate);
run.controller.signal.removeEventListener("abort", onAbort);
callback(value);
};
const onAbort = () => finish(reject, abortError(run.controller.signal));
run.gates.add(gate);
run.controller.signal.addEventListener("abort", onAbort, { once: true });
});
}
_delay(run, durationMs) {
return new Promise((resolve, reject) => {
let settled = false;
const delay = {
timer: null,
remainingMs: durationMs,
startedAt: 0,
pause: () => {
if (delay.timer === null) return;
const elapsed = Math.max(0, this.clock.now() - delay.startedAt);
delay.remainingMs = Math.max(0, delay.remainingMs - elapsed);
this.clock.clearTimeout(delay.timer);
delay.timer = null;
},
resume: () => {
if (settled || run.paused || delay.timer !== null) return;
delay.startedAt = this.clock.now();
delay.timer = this.clock.setTimeout(finish, delay.remainingMs);
},
};
const cleanup = () => {
if (delay.timer !== null) this.clock.clearTimeout(delay.timer);
delay.timer = null;
if (run.delay === delay) run.delay = null;
run.controller.signal.removeEventListener("abort", onAbort);
};
const finish = () => {
if (settled) return;
settled = true;
cleanup();
resolve();
};
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(abortError(run.controller.signal));
};
run.delay = delay;
run.controller.signal.addEventListener("abort", onAbort, { once: true });
delay.resume();
});
}
/**
* Pause sequence progression and the active hold clock. A visual transition
* already running in the target may finish, but no step is entered or
* advanced until resume() is called.
*/
pause() {
const run = this._active;
if (!run || run.terminal || run.paused) return this;
run.paused = true;
run.delay?.pause();
this.status = "paused";
this._emit("sequencepause", run);
return this;
}
resume() {
const run = this._active;
if (!run || run.terminal || !run.paused) return this;
run.paused = false;
this.status = "running";
this._emit("sequenceresume", run);
run.delay?.resume();
for (const gate of [...run.gates]) gate.resolve();
return this;
}
stop(reason = "stopped") {
if (this._active) this._stopRun(this._active, reason);
return this;
}
_stopRun(run, reason) {
if (run.terminal) return;
run.terminal = { status: "stopped", reason: String(reason || "stopped") };
run.controller.abort(abortError(reason));
if (this._active === run) this.status = "idle";
this._emit("sequencestop", run, { reason: run.terminal.reason });
}
_cancelRun(run, signal) {
if (run.terminal) return;
const error = abortError(signal);
run.terminal = { status: "cancelled", reason: signal?.reason, error };
run.controller.abort(error);
if (this._active === run) this.status = "idle";
this._emit("sequencecancel", run, { reason: signal?.reason, error });
}
_completeRun(run) {
run.terminal = { status: "completed", reason: "completed" };
if (this._active === run) this.status = "idle";
this._emit("sequencecomplete", run, {
completedSteps: run.completedSteps,
});
}
_errorRun(run, error) {
if (run.terminal) return;
run.terminal = { status: "error", reason: "error", error };
if (this._active === run) this.status = "idle";
this._emit("sequenceerror", run, { error });
}
_result(run) {
return Object.freeze({
runId: run.id,
name: run.sequence.name,
sequence: run.sequence,
status: run.terminal?.status || "completed",
reason: run.terminal?.reason,
completedSteps: run.completedSteps,
});
}
_emit(type, run, extra = {}) {
const detail = Object.freeze({
runId: run.id,
name: run.sequence.name,
sequence: run.sequence,
index: run.index,
...extra,
});
this.dispatchEvent(detailEvent(type, detail));
return detail;
}
_finalizeRun(run) {
run.removeExternalAbort();
run.delay?.pause();
run.delay = null;
for (const gate of [...run.gates]) gate.resolve();
run.gates.clear();
if (this._active === run) {
this._active = null;
if (!this.destroyed) this.status = "idle";
}
}
destroy() {
if (this.destroyed) return;
if (this._active) this._stopRun(this._active, "destroyed");
this.destroyed = true;
this.status = "destroyed";
this.sequences.clear();
}
}
/**
* Ergonomic one-off playback. The returned player is also the pause/resume/stop
* handle; await player.finished for completion.
*
* @example
* const playback = playStateSequence(engine, [
* { sprite: "ai.ambient-idle", holdMs: 1200 },
* { sprite: "ai.progress", transition: "contour-trace" },
* ], { signal });
* await playback.finished;
*/
export function playStateSequence(target, steps, options = {}) {
const player = new StateSequencePlayer(target, {
clock: options.clock,
transition: hasOwn(options, "transition") ? options.transition : "field-morph",
transitionFirst: options.transitionFirst,
preservePhase: options.preservePhase,
});
const sequence = defineStateSequence(options.name || "one-off", steps, {
metadata: options.metadata,
});
player.finished = player.play(sequence, { signal: options.signal });
return player;
}
export default StateSequencePlayer;

3033
web/vendor/src/studio.js vendored Normal file

File diff suppressed because it is too large Load Diff

4273
web/vendor/src/styles.css vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
import JoanGlyphElement, {
JOAN_GLYPH_TAG_NAME,
defineJoanGlyph,
} from "./web-component.js";
defineJoanGlyph(JOAN_GLYPH_TAG_NAME);
export { JOAN_GLYPH_TAG_NAME, JoanGlyphElement, defineJoanGlyph };
export default JoanGlyphElement;

308
web/vendor/src/web-component.js vendored Normal file
View File

@@ -0,0 +1,308 @@
import JoanGlyphEngine from "./joan-engine.js";
export const JOAN_GLYPH_TAG_NAME = "joan-glyph";
const observed = [
"sprite",
"seed",
"resolution",
"speed",
"density",
"field",
"pixel-shape",
"pixel-switch",
"orb-boundary",
"orb-background-color",
"orb-background-mode",
"non-error-background",
"non-error-off",
"non-error-ink",
"non-error-accent",
"non-error-glow",
"noninteractive",
"paused",
"transparent",
];
const paletteAttributes = {
"non-error-background": "background",
"non-error-off": "off",
"non-error-ink": "ink",
"non-error-accent": "accent",
"non-error-glow": "glow",
};
// Keeping the module evaluable without DOM globals lets SSR builds inspect and
// bundle it. Instances are still browser elements and should only be created
// after a real HTMLElement implementation exists.
const HTMLElementBase =
typeof globalThis.HTMLElement === "undefined"
? class JoanGlyphSsrBase extends EventTarget {}
: globalThis.HTMLElement;
export class JoanGlyphElement extends HTMLElementBase {
static observedAttributes = observed;
constructor() {
super();
const root = this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = `
:host {
display: inline-block;
inline-size: 68px;
block-size: 68px;
aspect-ratio: 1;
contain: layout paint style;
}
canvas {
display: block;
inline-size: 100%;
block-size: 100%;
image-rendering: pixelated;
touch-action: none;
}
`;
this.canvas = document.createElement("canvas");
root.append(style, this.canvas);
}
connectedCallback() {
if (this._engine) return;
this._engine = new JoanGlyphEngine(this.canvas, this.readOptions());
}
disconnectedCallback() {
this._engine?.destroy();
this._engine = null;
}
attributeChangedCallback(name, oldValue, newValue) {
if (!this._engine || oldValue === newValue) return;
switch (name) {
case "sprite":
this._engine.setSprite(newValue || "ai.idle");
break;
case "seed":
this._engine.setSeed(newValue || "joan-v5");
break;
case "resolution":
this._engine.setResolution(Number(newValue) || 68);
break;
case "field":
if (newValue === null) this._engine.useRecipeFields();
else this._engine.setField(newValue);
break;
case "pixel-shape":
if (newValue === null) this._engine.setOptions({ pixelShape: null });
else this._engine.setPixelShape(newValue);
break;
case "pixel-switch":
if (newValue === null) this._engine.setOptions({ pixelSwitch: null });
else this._engine.setPixelSwitch(newValue);
break;
case "orb-boundary":
this._engine.setOptions({
orbBoundary:
String(newValue || "").trim().toLowerCase() === "gestalt"
? "gestalt"
: "defined",
});
break;
case "orb-background-color":
case "orb-background-mode":
this._engine.setOptions(this.readOrbBackground());
break;
case "non-error-background":
case "non-error-off":
case "non-error-ink":
case "non-error-accent":
case "non-error-glow":
this._engine.setNonErrorPalette(this.readNonErrorPalette());
break;
case "paused":
if (newValue === null) this._engine.play();
else this._engine.pause();
break;
case "noninteractive":
this._engine.setOptions({ interactive: newValue === null });
break;
case "transparent":
this._engine.setOptions({ background: newValue === null });
break;
case "speed":
this._engine.setOptions({ speed: Number(newValue) || 1 });
break;
case "density":
this._engine.setOptions({ density: Number(newValue) || 1 });
break;
default:
break;
}
}
readOptions() {
return {
sprite: this.getAttribute("sprite") || "ai.idle",
seed: this.getAttribute("seed") || "joan-v5",
gridSize: Number(this.getAttribute("resolution")) || 68,
speed: Number(this.getAttribute("speed")) || 1,
density: Number(this.getAttribute("density")) || 1,
field: this.getAttribute("field") || undefined,
pixelShape: this.getAttribute("pixel-shape") || undefined,
pixelSwitch: this.getAttribute("pixel-switch") || undefined,
orbBoundary:
String(this.getAttribute("orb-boundary") || "").trim().toLowerCase() ===
"gestalt"
? "gestalt"
: "defined",
...this.readOrbBackground(),
nonErrorPalette: this.readNonErrorPalette(),
autoplay: !this.hasAttribute("paused"),
interactive: !this.hasAttribute("noninteractive"),
background: !this.hasAttribute("transparent"),
};
}
readOrbBackground() {
const colorAttribute = this.getAttribute("orb-background-color")?.trim();
const orbBackgroundColor =
colorAttribute && colorAttribute.toLowerCase() !== "transparent"
? colorAttribute
: null;
const modeAttribute = this.getAttribute("orb-background-mode");
const requestedMode = String(modeAttribute || "").trim().toLowerCase();
const orbBackgroundMode =
modeAttribute === null
? orbBackgroundColor
? "solid"
: "none"
: ["none", "solid", "pixelated"].includes(requestedMode)
? requestedMode
: "none";
return { orbBackgroundColor, orbBackgroundMode };
}
readNonErrorPalette() {
const palette = {};
for (const [attribute, key] of Object.entries(paletteAttributes)) {
const value = this.getAttribute(attribute)?.trim();
if (value) palette[key] = value;
}
return Object.keys(palette).length ? palette : null;
}
get engine() {
return this._engine;
}
setSprite(sprite, options) {
return this._engine?.setSprite(sprite, options);
}
setProgress(value) {
return this._engine?.setProgress(value);
}
setNonErrorPalette(palette) {
return this._engine?.setNonErrorPalette(palette);
}
setSeed(seed) {
return this._engine?.setSeed(seed);
}
setResolution(size) {
return this._engine?.setResolution(size);
}
setField(field) {
return this._engine?.setField(field);
}
useRecipeFields() {
return this._engine?.useRecipeFields();
}
setPixelShape(shape) {
return this._engine?.setPixelShape(shape);
}
setPixelSwitch(pixelSwitch) {
return this._engine?.setPixelSwitch(pixelSwitch);
}
setOptions(options) {
return this._engine?.setOptions(options);
}
configure(options, configureOptions) {
return this._engine?.configure(options, configureOptions);
}
setAudioLevel(value) {
return this._engine?.setAudioLevel(value);
}
activate(options) {
return this._engine?.activate(options);
}
resume(options) {
return this._engine?.resume(options);
}
play() {
return this._engine?.play();
}
pause() {
return this._engine?.pause();
}
toggle() {
return this._engine?.toggle();
}
renderOnce(time) {
return this._engine?.renderOnce(time);
}
transitionTo(sprite, options) {
return this._engine?.transitionTo(sprite, options);
}
whenTransitionComplete(options) {
return this._engine?.whenTransitionComplete(options);
}
inspect() {
return this._engine?.inspect();
}
on(type, listener, options) {
return this._engine?.on(type, listener, options);
}
exportConfig() {
return this._engine?.exportConfig();
}
signal(type, payload) {
return this._engine?.signal(type, payload);
}
}
export function defineJoanGlyph(tagName = JOAN_GLYPH_TAG_NAME) {
const registry = globalThis.customElements;
if (!registry) return JoanGlyphElement;
if (!registry.get(tagName)) registry.define(tagName, JoanGlyphElement);
return JoanGlyphElement;
}
// Preserve the original import-and-register behavior. Applications that prefer
// an explicit side-effect entry can import `web-component/register` instead.
if (typeof globalThis.customElements !== "undefined") defineJoanGlyph();
export default JoanGlyphElement;