0.28.1 — vendor @joan/procedural-glyph-engine for portable SPA builds (fixes deploy)
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:
687
web/vendor/src/config.js
vendored
Normal file
687
web/vendor/src/config.js
vendored
Normal 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
207
web/vendor/src/control-help.js
vendored
Normal file
@@ -0,0 +1,207 @@
|
||||
export const FIELD_HELP = Object.freeze({
|
||||
__recipe__:
|
||||
"Uses the selected state’s 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 cell’s 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 theme’s 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
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
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
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
63
web/vendor/src/preview-layout.js
vendored
Normal 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
82
web/vendor/src/preview-thumbnails.js
vendored
Normal 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
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
287
web/vendor/src/state-director.js
vendored
Normal 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
649
web/vendor/src/state-sequence.js
vendored
Normal 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
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
4273
web/vendor/src/styles.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9
web/vendor/src/web-component-register.js
vendored
Normal file
9
web/vendor/src/web-component-register.js
vendored
Normal 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
308
web/vendor/src/web-component.js
vendored
Normal 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;
|
||||
Reference in New Issue
Block a user