Files
oikos/web/vendor/src/config.js
dtoro 8ff382a50d
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
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.
2026-08-08 21:41:51 +02:00

688 lines
18 KiB
JavaScript

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));
}