From 731f4ba90e3aad3f3a48c9e98abde0b357b3fdd1 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 10 Oct 2026 05:35:59 +0500 Subject: [PATCH] refactor: separate registry schema file --- docs/architecture-audit-2026-10.md | 14 +- docs/architecture.md | 11 +- web/src/lib/registry-schema.ts | 769 -------------------------- web/src/lib/registry-schema/aspect.ts | 83 +++ web/src/lib/registry-schema/field.ts | 121 ++++ web/src/lib/registry-schema/index.ts | 5 + web/src/lib/registry-schema/layout.ts | 36 ++ web/src/lib/registry-schema/specs.ts | 250 +++++++++ web/src/lib/registry-schema/values.ts | 291 ++++++++++ 9 files changed, 803 insertions(+), 777 deletions(-) delete mode 100644 web/src/lib/registry-schema.ts create mode 100644 web/src/lib/registry-schema/aspect.ts create mode 100644 web/src/lib/registry-schema/field.ts create mode 100644 web/src/lib/registry-schema/index.ts create mode 100644 web/src/lib/registry-schema/layout.ts create mode 100644 web/src/lib/registry-schema/specs.ts create mode 100644 web/src/lib/registry-schema/values.ts diff --git a/docs/architecture-audit-2026-10.md b/docs/architecture-audit-2026-10.md index fdae775..d5d6ba8 100644 --- a/docs/architecture-audit-2026-10.md +++ b/docs/architecture-audit-2026-10.md @@ -42,13 +42,15 @@ handleFiles, эффекты, workspace-разметка). Декомпозици `running` (`finally` сбрасывает только актуальный); `init()` инкрементом инвалидирует прогоны прошлого хоста. -### H3. `registry-schema.ts` — 723 строки, 45 экспортов, 5 ответственностей +### H3. `registry-schema.ts` — 5 ответственностей — СДЕЛАНО (2026-10) -`web/src/lib/registry-schema.ts` смешивает: (а) типы 15 видов полей (:14-233), -(б) builder-DSL `field.*` (:273-360), (в) layout engine `resolveLayoutGroups` -(:380-406), (г) семантику значений `defaultSchemaParams`/`sanitizeSchemaParams` -(:503-770), (д) aspect-lock геометрию `withAspectLock`/`clampSourceAwareMaxes` -(:434-489). Каждая из (в)-(д) — отдельный модуль. +Файл (769 строк) разрезан на директорию `web/src/lib/registry-schema/`: +`specs.ts` (типы полей, `fieldSpecs`, `ToolSchema`), `field.ts` (builder DSL + +`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`, +`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (дефолты и санитизация); +`index.ts` — баррель, пути импортов не менялись. `docs/architecture.md` (разделы +1, 4) обновлён. JSDoc в `specs.ts` сжат до однострочников — лимит плотности +комментариев (`conventions/comment-format`, 15%). ## Med diff --git a/docs/architecture.md b/docs/architecture.md index 8fa4e11..bdf8e97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,10 @@ web/src/lib/ registry/ # источник истины: инструменты и страницы registry/tools/ # Tool: реализации (id), без адресов и текстов registry/pages/ # Page: адрес, тексты, категория, список шагов - registry-schema.ts # декларативная схема параметров + санитизация дефолтов + registry-schema/ # декларативная схема параметров: specs (типы полей), + # field (builder DSL), layout (группы), aspect (aspect-lock + # и кэпы от source), values (дефолты и санитизация); + # index.ts — баррель, импорты идут по `registry-schema` catalog.ts # группировка страниц и счётчики для /list-tools categories.ts # id/названия категорий tool-icons.ts # slug страницы → иконка @@ -170,7 +173,11 @@ registry/{tools,pages}/*.ts ## 4. Схема параметров -`registry-schema.ts` — единственное место, где описаны виды полей. +`registry-schema/` — единственное место, где описаны виды полей. Модули: +`specs.ts` (типы полей и `ToolSchema`), `field.ts` (фабрика `field.*` и +`toolSchema`), `layout.ts` (`resolveLayoutGroups`), `aspect.ts` (`effectiveMax`, +`clampSourceAwareMaxes`, `withAspectLock`), `values.ts` (`defaultSchemaParams`, +`applySourceDefaults`, `sanitizeSchemaParams`). - **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`, `checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`, diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts deleted file mode 100644 index 3c43d7a..0000000 --- a/web/src/lib/registry-schema.ts +++ /dev/null @@ -1,769 +0,0 @@ -// Typed parameter schema for the UI. -// Compile-time type binding (drift protection): `field.x()` returns -// `Field` whose phantom `__fieldT` ties the runtime spec to the expected -// value type; `toolSchema

(fields)` requires `fields` keys to match `P` -// exactly. Compiler errors catch Params/schema mismatches. - -import type { TextFont } from "./core/domText"; -import type { Position9 } from "./core/textdraw"; -import { clamp } from "./core/math"; - -/** - * Optional field of every spec: the field's label key in the dictionary. - * Rendered via `fieldLabel` (decision C); without a key, `labelOf(id)` is used. - */ -export interface FieldSpecBase { - label?: string; - /** Field visible only when another field equals a value (tool modes). */ - visibleWhen?: { field: string; equals: string }; -} - -export interface NumberSpec extends FieldSpecBase { - kind: "number"; - default: number; - min?: number; - max?: number; - step?: number; - /** Cap from source dimensions (axis); static max is the fallback. */ - maxFromSource?: "width" | "height"; - maxMinus?: number; -} - -export interface SliderSpec extends FieldSpecBase { - kind: "slider"; - default: number; - min: number; - max: number; - step?: number; -} - -export interface ColorSpec extends FieldSpecBase { - kind: "color"; - default: string; -} - -export interface SelectSpec extends FieldSpecBase { - kind: "select"; - default: V; - options: { value: V; label: string }[]; -} - -/** Same as select but rendered as segment buttons (1-click choice). */ -export interface SegmentedSpec< - V extends string = string, -> extends FieldSpecBase { - kind: "segmented"; - default: V; - options: { value: V; label: string }[]; -} - -export interface TextSpec extends FieldSpecBase { - kind: "text"; - default: string; - placeholder?: string; -} - -export interface CheckboxSpec extends FieldSpecBase { - kind: "checkbox"; - default: boolean; -} - -/** Composite "dimensions" field: width + height as one object. */ -export interface Dimension { - width: number; - height: number; -} - -/** Single list of tile presets (squares) for dimension fields. */ -export const SIZE_PRESETS = [16, 32, 64, 128, 256, 512] as const; - -export interface DimensionSpec extends FieldSpecBase { - kind: "dimension"; - /** Shared range for both dimensions. */ - min: number; - max: number; - width: number; - height: number; - defaultFromSource?: boolean; - /** Per-axis cap from the matching source dimension. */ - maxFromSource?: boolean; - /** Id of the "keep aspect ratio" checkbox field. */ - lockAspectWith?: string; - /** Show quick-pick chips from SIZE_PRESETS. */ - presets?: boolean; -} - -export interface SchemaContext { - source?: Dimension; -} - -/** Color pair "from -> to" (gradients, two-color mapping, etc.). */ -export interface ColorPair { - from: string; - to: string; -} - -export interface ColorPairSpec extends FieldSpecBase { - kind: "color-pair"; - from: string; - to: string; -} - -/** Color list (palette): array of hex strings. */ -export type ColorList = string[]; - -export interface ColorListSpec extends FieldSpecBase { - kind: "colors"; - default: string[]; -} - -/** Shape/object offset from center: x + y in percent. */ -export interface Offset { - x: number; - y: number; -} - -export interface OffsetSpec extends FieldSpecBase { - kind: "offset"; - /** Shared range for both axes (in percent). */ - min: number; - max: number; - x: number; - y: number; -} - -/** - * 9-position grid (3x3): top/middle/bottom x left/center/right. - * Values match the `Position9` string type from core/textdraw. - */ -export const POSITION9_VALUES = [ - "top-left", - "top-center", - "top-right", - "middle-left", - "center", - "middle-right", - "bottom-left", - "bottom-center", - "bottom-right", -] as const; - -export interface Position9Spec extends FieldSpecBase { - kind: "position9"; - default: Position9; -} - -/** - * Text caption style: font, size, bold, and color as one object - * (reused by text-to-png, add-text, date-stamp). - */ -export interface FontStyle { - font: TextFont; - size: number; - bold: boolean; - color: string; -} - -export interface FontStyleSpec extends FieldSpecBase { - kind: "font-style"; - /** Font size range. */ - min: number; - max: number; - size: number; - font: TextFont; - bold: boolean; - color: string; -} - -/** - * Backing plate under a text caption: on/off, color, and opacity - * as one object (add-text, date-stamp). - */ -export interface Plate { - enabled: boolean; - color: string; - opacity: number; -} - -export interface PlateSpec extends FieldSpecBase { - kind: "plate"; - enabled: boolean; - color: string; - opacity: number; -} - -/** - * Color gradient: color pair + direction angle. 0 deg: left to right, - * 90 deg: top to bottom (clockwise growth in pixel axes, Y axis down). - */ -export interface Gradient { - from: string; - to: string; - angle: number; -} - -export interface GradientSpec extends FieldSpecBase { - kind: "gradient"; - from: string; - to: string; - /** Gradient direction: 0..360, angle in degrees. */ - angle: number; -} - -/** - * "as const" object: kind -> field spec. Single source of truth for the kind - * list: `FieldSpecKind` = map keys, `FieldSpec` = union of values. A new kind - * added here makes the type-checker flag where it is missing (default/sanitize). - */ -export const fieldSpecs = { - number: {} as NumberSpec, - slider: {} as SliderSpec, - color: {} as ColorSpec, - select: {} as SelectSpec, - segmented: {} as SegmentedSpec, - text: {} as TextSpec, - checkbox: {} as CheckboxSpec, - dimension: {} as DimensionSpec, - "color-pair": {} as ColorPairSpec, - colors: {} as ColorListSpec, - offset: {} as OffsetSpec, - position9: {} as Position9Spec, - "font-style": {} as FontStyleSpec, - plate: {} as PlateSpec, - gradient: {} as GradientSpec, -} as const; - -export type FieldSpecKind = keyof typeof fieldSpecs; -export type FieldSpecOf = (typeof fieldSpecs)[K]; -export type FieldSpec = FieldSpecOf; - -/** `Field`: runtime field spec + phantom type of the expected value (number|string|boolean). */ -export interface Field { - spec: FieldSpec; - /** Phantom, for type safety only; never set or read at runtime. */ - __fieldT?: T; -} - -export interface ToolLayoutGroup { - /** Group title (an empty group is not rendered). */ - title?: string; - /** Grid column count inside the group (default 1). */ - cols?: number; - /** Schema field names belonging to the group. */ - fields: string[]; -} - -export interface ToolSchemaLayout { - /** Widget field groups. Unmentioned fields go to a trailing default group. */ - groups: ToolLayoutGroup[]; -} - -export interface ToolSchemaMeta { - /** Per-tool field layout (grouping/columns). */ - layout?: ToolSchemaLayout; - /** Short tool label for the UI (optional). */ - label?: string; -} - -export interface ToolSchema

{ - fields: { [K in keyof P]: Field }; - layout?: ToolSchemaLayout; - label?: string; -} - -export const field = { - number: (s: Omit): Field => ({ - spec: { kind: "number", ...s }, - }), - slider: (s: Omit): Field => ({ - spec: { kind: "slider", ...s }, - }), - color: (s: Omit): Field => ({ - spec: { kind: "color", ...s }, - }), - select: (s: Omit, "kind">): Field => ({ - spec: { kind: "select", ...s }, - }), - segmented: ( - s: Omit, "kind">, - ): Field => ({ - spec: { kind: "segmented", ...s }, - }), - text: (s: Omit): Field => ({ - spec: { kind: "text", ...s }, - }), - checkbox: (s: Omit): Field => ({ - spec: { kind: "checkbox", ...s }, - }), - dimension: (s: { - label?: string; - min: number; - max: number; - width: number; - height: number; - defaultFromSource?: boolean; - maxFromSource?: boolean; - lockAspectWith?: string; - presets?: boolean; - visibleWhen?: { field: string; equals: string }; - }): Field => ({ - spec: { kind: "dimension", ...s }, - }), - colorPair: (s: { - label?: string; - from: string; - to: string; - }): Field => ({ - spec: { kind: "color-pair", ...s }, - }), - colors: (s: { label?: string; default: string[] }): Field => ({ - spec: { kind: "colors", ...s }, - }), - offset: (s: { - label?: string; - min: number; - max: number; - x: number; - y: number; - }): Field => ({ - spec: { kind: "offset", ...s }, - }), - position9: (s: { label?: string; default: Position9 }): Field => ({ - spec: { kind: "position9", ...s }, - }), - fontStyle: (s: { - label?: string; - min: number; - max: number; - size: number; - font: TextFont; - bold: boolean; - color: string; - }): Field => ({ - spec: { kind: "font-style", ...s }, - }), - plate: (s: { - label?: string; - enabled: boolean; - color: string; - opacity: number; - }): Field => ({ - spec: { kind: "plate", ...s }, - }), - gradient: (s: { - label?: string; - from: string; - to: string; - angle: number; - }): Field => ({ - spec: { kind: "gradient", ...s }, - }), -}; - -/** - * Builds a `ToolSchema

` from declared fields. - * The compiler checks: `fields` keys === keys of `P`, field types match `P[K]`. - */ -export function toolSchema

( - fields: { [K in keyof P]: Field }, - meta?: ToolSchemaMeta, -): ToolSchema

{ - return { fields, ...(meta ?? {}) }; -} - -export interface ResolvedLayoutGroup { - key: string; - title?: string; - cols: number; - fields: string[]; -} - -export function resolveLayoutGroups

( - schema: ToolSchema

, -): ResolvedLayoutGroup[] { - const all = Object.keys(schema.fields); - const groups = schema.layout?.groups ?? []; - const used: Record = {}; - const named: ResolvedLayoutGroup[] = groups - .map((group, index) => { - const fields = group.fields.filter((fieldId) => { - if (used[fieldId] || !(fieldId in schema.fields)) return false; - used[fieldId] = true; - return true; - }); - return { - key: `${group.title ?? "group"}-${index}`, - title: group.title, - cols: Math.max(1, Math.trunc(group.cols ?? 1)), - fields, - } satisfies ResolvedLayoutGroup; - }) - .filter((group) => group.fields.length > 0); - const rest = all.filter((fieldId) => !used[fieldId]); - if (rest.length > 0) { - named.push({ key: "__default", cols: 1, fields: rest }); - } - return named; -} - -/** Field cap: from source dimensions under maxFromSource, else static. */ -export function effectiveMax( - spec: NumberSpec, - source?: Dimension, -): number | undefined { - if (spec.maxFromSource && source) { - return source[spec.maxFromSource] - (spec.maxMinus ?? 0); - } - return spec.max; -} - -function dimensionAxisMax( - spec: DimensionSpec, - axis: "width" | "height", - source?: Dimension, -): number { - return spec.maxFromSource && source ? source[axis] : spec.max; -} - -/** Clamps maxFromSource fields to the current cap (source changed). */ -export function clampSourceAwareMaxes

( - schema: ToolSchema

, - params: Record, - source?: Dimension, -): Record { - if (!source) return params; - let changed = false; - const out = { ...params }; - for (const key of Object.keys(schema.fields) as (keyof P)[]) { - const spec = schema.fields[key].spec; - if (spec.kind === "number" && spec.maxFromSource) { - const v = out[key as string]; - if (typeof v !== "number" || !Number.isFinite(v)) continue; - const clamped = clamp( - v, - spec.min ?? -Infinity, - effectiveMax(spec, source) ?? Infinity, - ); - if (clamped !== v) { - out[key as string] = clamped; - changed = true; - } - } else if (spec.kind === "dimension" && spec.maxFromSource) { - const v = out[key as string] as Partial | undefined; - if (typeof v?.width !== "number" || typeof v?.height !== "number") { - continue; - } - const width = clamp( - v.width, - spec.min ?? -Infinity, - dimensionAxisMax(spec, "width", source), - ); - const height = clamp( - v.height, - spec.min ?? -Infinity, - dimensionAxisMax(spec, "height", source), - ); - if (width !== v.width || height !== v.height) { - out[key as string] = { width, height }; - changed = true; - } - } - } - return changed ? out : params; -} - -/** - * Recompute under lockAspect: the leading axis (last user edit) is kept, - * the other is fitted to the source aspect. - */ -export function withAspectLock( - next: Dimension, - leading: "width" | "height", - aspect: number, -): Dimension { - if (leading === "width") { - return { ...next, height: Math.max(1, Math.round(next.width / aspect)) }; - } - return { ...next, width: Math.max(1, Math.round(next.height * aspect)) }; -} - -function resolveDimensionDefaults( - spec: DimensionSpec, - context?: SchemaContext, -): Dimension { - const source = spec.defaultFromSource ? context?.source : undefined; - return { - width: clamp(source?.width ?? spec.width, spec.min, spec.max), - height: clamp(source?.height ?? spec.height, spec.min, spec.max), - }; -} - -/** Defaults from the schema: the single source of default values for the UI. */ -export function defaultSchemaParams

( - schema: ToolSchema

, - context?: SchemaContext, -): Record { - const out = {} as Record; - for (const key of Object.keys(schema.fields) as (keyof P)[]) { - const spec = schema.fields[key].spec; - if (spec.kind === "dimension") { - out[key] = resolveDimensionDefaults(spec, context); - } else if (spec.kind === "color-pair") { - out[key] = { from: spec.from, to: spec.to }; - } else if (spec.kind === "colors") { - out[key] = [...spec.default]; - } else if (spec.kind === "offset") { - out[key] = { x: spec.x, y: spec.y }; - } else if (spec.kind === "font-style") { - out[key] = { - font: spec.font, - size: spec.size, - bold: spec.bold, - color: spec.color, - }; - } else if (spec.kind === "plate") { - out[key] = { - enabled: spec.enabled, - color: spec.color, - opacity: spec.opacity, - }; - } else if (spec.kind === "gradient") { - out[key] = { - from: spec.from, - to: spec.to, - angle: spec.angle, - }; - } else { - out[key] = spec.default; - } - } - return out; -} - -/** - * Recomputes defaults from a new source, preserving user values. Fields in - * touched (edited manually) are not overwritten even with a source default; - * otherwise loading a new file would wipe configured sizes. - */ -export function applySourceDefaults

( - schema: ToolSchema

, - values: Record, - context: SchemaContext, - touched?: ReadonlySet, -): Record { - const defaults = defaultSchemaParams(schema, context); - for (const key of Object.keys(schema.fields) as (keyof P)[]) { - const spec = schema.fields[key].spec; - const fromSource = spec.kind === "dimension" && spec.defaultFromSource; - if (fromSource && !touched?.has(key as string)) continue; - if (Object.prototype.hasOwnProperty.call(values, key)) { - defaults[key] = values[key as string]; - } - } - return defaults; -} - -/** Validation/normalization of values against the schema. Successor of `sanitizeParams`. */ -export function sanitizeSchemaParams

( - schema: ToolSchema

, - values: Record, - context?: SchemaContext, -): Record { - const out = {} as Record; - for (const key of Object.keys(schema.fields) as (keyof P)[]) { - const spec = schema.fields[key].spec; - const raw = values[key as string]; - switch (spec.kind) { - case "number": - case "slider": { - const n = - typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default; - const max = - spec.kind === "number" - ? effectiveMax(spec, context?.source) - : spec.max; - out[key] = clamp(n, spec.min ?? -Infinity, max ?? Infinity); - break; - } - case "select": - case "segmented": - out[key] = - typeof raw === "string" && spec.options.some((o) => o.value === raw) - ? raw - : spec.default; - break; - case "checkbox": - out[key] = typeof raw === "boolean" ? raw : spec.default; - break; - case "color": - out[key] = - typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw) - ? raw - : spec.default; - break; - case "text": - out[key] = typeof raw === "string" ? raw : spec.default; - break; - case "dimension": { - const r = - typeof raw === "object" && - raw !== null && - "width" in raw && - "height" in raw - ? (raw as Record) - : undefined; - const defaults = resolveDimensionDefaults(spec, context); - const w = - typeof r?.width === "number" && Number.isFinite(r.width) - ? r.width - : defaults.width; - const h = - typeof r?.height === "number" && Number.isFinite(r.height) - ? r.height - : defaults.height; - const sourceSize = - spec.defaultFromSource && context?.source && w === 0 && h === 0 - ? defaults - : { width: w, height: h }; - out[key] = { - width: clamp( - sourceSize.width, - spec.min, - dimensionAxisMax(spec, "width", context?.source), - ), - height: clamp( - sourceSize.height, - spec.min, - dimensionAxisMax(spec, "height", context?.source), - ), - }; - break; - } - case "color-pair": { - const r = - typeof raw === "object" && - raw !== null && - "from" in raw && - "to" in raw - ? (raw as Record) - : undefined; - const from = - typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from) - ? r.from - : spec.from; - const to = - typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to) - ? r.to - : spec.to; - out[key] = { from, to }; - break; - } - case "colors": { - const r = - Array.isArray(raw) && - raw.every((c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c)) - ? (raw as string[]) - : undefined; - out[key] = r && r.length > 0 ? [...r] : [...spec.default]; - break; - } - case "position9": - out[key] = - typeof raw === "string" && - (POSITION9_VALUES as readonly string[]).includes(raw) - ? (raw as Position9) - : spec.default; - break; - case "font-style": { - const r = - typeof raw === "object" && - raw !== null && - "font" in raw && - "size" in raw && - "bold" in raw && - "color" in raw - ? (raw as Record) - : undefined; - out[key] = { - font: - r?.font === "sans" || r?.font === "serif" || r?.font === "mono" - ? r.font - : spec.font, - size: - typeof r?.size === "number" && Number.isFinite(r.size) - ? clamp(r.size, spec.min, spec.max) - : spec.size, - bold: typeof r?.bold === "boolean" ? r.bold : spec.bold, - color: - typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color) - ? r.color - : spec.color, - }; - break; - } - case "plate": { - const r = - typeof raw === "object" && - raw !== null && - "enabled" in raw && - "color" in raw && - "opacity" in raw - ? (raw as Record) - : undefined; - out[key] = { - enabled: typeof r?.enabled === "boolean" ? r.enabled : spec.enabled, - color: - typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color) - ? r.color - : spec.color, - opacity: - typeof r?.opacity === "number" && Number.isFinite(r.opacity) - ? clamp(r.opacity, 0, 100) - : spec.opacity, - }; - break; - } - case "offset": { - const r = - typeof raw === "object" && raw !== null && "x" in raw && "y" in raw - ? (raw as Record) - : undefined; - const x = - typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x; - const y = - typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y; - out[key] = { - x: clamp(x, spec.min, spec.max), - y: clamp(y, spec.min, spec.max), - }; - break; - } - case "gradient": { - const r = - typeof raw === "object" && - raw !== null && - "from" in raw && - "to" in raw && - "angle" in raw - ? (raw as Record) - : undefined; - out[key] = { - from: - typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from) - ? r.from - : spec.from, - to: - typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to) - ? r.to - : spec.to, - angle: - typeof r?.angle === "number" && Number.isFinite(r.angle) - ? clamp(r.angle, 0, 360) - : spec.angle, - }; - break; - } - } - } - return out; -} diff --git a/web/src/lib/registry-schema/aspect.ts b/web/src/lib/registry-schema/aspect.ts new file mode 100644 index 0000000..78b6930 --- /dev/null +++ b/web/src/lib/registry-schema/aspect.ts @@ -0,0 +1,83 @@ +import { clamp } from "../core/math"; +import type { Dimension, DimensionSpec, NumberSpec, ToolSchema } from "./specs"; + +/** Field cap: from source dimensions under maxFromSource, else static. */ +export function effectiveMax( + spec: NumberSpec, + source?: Dimension, +): number | undefined { + if (spec.maxFromSource && source) { + return source[spec.maxFromSource] - (spec.maxMinus ?? 0); + } + return spec.max; +} + +export function dimensionAxisMax( + spec: DimensionSpec, + axis: "width" | "height", + source?: Dimension, +): number { + return spec.maxFromSource && source ? source[axis] : spec.max; +} + +/** Clamps maxFromSource fields to the current cap (source changed). */ +export function clampSourceAwareMaxes

( + schema: ToolSchema

, + params: Record, + source?: Dimension, +): Record { + if (!source) return params; + let changed = false; + const out = { ...params }; + for (const key of Object.keys(schema.fields) as (keyof P)[]) { + const spec = schema.fields[key].spec; + if (spec.kind === "number" && spec.maxFromSource) { + const v = out[key as string]; + if (typeof v !== "number" || !Number.isFinite(v)) continue; + const clamped = clamp( + v, + spec.min ?? -Infinity, + effectiveMax(spec, source) ?? Infinity, + ); + if (clamped !== v) { + out[key as string] = clamped; + changed = true; + } + } else if (spec.kind === "dimension" && spec.maxFromSource) { + const v = out[key as string] as Partial | undefined; + if (typeof v?.width !== "number" || typeof v?.height !== "number") { + continue; + } + const width = clamp( + v.width, + spec.min ?? -Infinity, + dimensionAxisMax(spec, "width", source), + ); + const height = clamp( + v.height, + spec.min ?? -Infinity, + dimensionAxisMax(spec, "height", source), + ); + if (width !== v.width || height !== v.height) { + out[key as string] = { width, height }; + changed = true; + } + } + } + return changed ? out : params; +} + +/** + * Recompute under lockAspect: the leading axis (last user edit) is kept, + * the other is fitted to the source aspect. + */ +export function withAspectLock( + next: Dimension, + leading: "width" | "height", + aspect: number, +): Dimension { + if (leading === "width") { + return { ...next, height: Math.max(1, Math.round(next.width / aspect)) }; + } + return { ...next, width: Math.max(1, Math.round(next.height * aspect)) }; +} diff --git a/web/src/lib/registry-schema/field.ts b/web/src/lib/registry-schema/field.ts new file mode 100644 index 0000000..98634a8 --- /dev/null +++ b/web/src/lib/registry-schema/field.ts @@ -0,0 +1,121 @@ +import type { TextFont } from "../core/domText"; +import type { Position9 } from "../core/textdraw"; +import type { + CheckboxSpec, + ColorList, + ColorPair, + ColorSpec, + Dimension, + Field, + FontStyle, + Gradient, + NumberSpec, + Offset, + Plate, + SegmentedSpec, + SelectSpec, + SliderSpec, + TextSpec, + ToolSchema, + ToolSchemaMeta, +} from "./specs"; + +export const field = { + number: (s: Omit): Field => ({ + spec: { kind: "number", ...s }, + }), + slider: (s: Omit): Field => ({ + spec: { kind: "slider", ...s }, + }), + color: (s: Omit): Field => ({ + spec: { kind: "color", ...s }, + }), + select: (s: Omit, "kind">): Field => ({ + spec: { kind: "select", ...s }, + }), + segmented: ( + s: Omit, "kind">, + ): Field => ({ + spec: { kind: "segmented", ...s }, + }), + text: (s: Omit): Field => ({ + spec: { kind: "text", ...s }, + }), + checkbox: (s: Omit): Field => ({ + spec: { kind: "checkbox", ...s }, + }), + dimension: (s: { + label?: string; + min: number; + max: number; + width: number; + height: number; + defaultFromSource?: boolean; + maxFromSource?: boolean; + lockAspectWith?: string; + presets?: boolean; + visibleWhen?: { field: string; equals: string }; + }): Field => ({ + spec: { kind: "dimension", ...s }, + }), + colorPair: (s: { + label?: string; + from: string; + to: string; + }): Field => ({ + spec: { kind: "color-pair", ...s }, + }), + colors: (s: { label?: string; default: string[] }): Field => ({ + spec: { kind: "colors", ...s }, + }), + offset: (s: { + label?: string; + min: number; + max: number; + x: number; + y: number; + }): Field => ({ + spec: { kind: "offset", ...s }, + }), + position9: (s: { label?: string; default: Position9 }): Field => ({ + spec: { kind: "position9", ...s }, + }), + fontStyle: (s: { + label?: string; + min: number; + max: number; + size: number; + font: TextFont; + bold: boolean; + color: string; + }): Field => ({ + spec: { kind: "font-style", ...s }, + }), + plate: (s: { + label?: string; + enabled: boolean; + color: string; + opacity: number; + }): Field => ({ + spec: { kind: "plate", ...s }, + }), + gradient: (s: { + label?: string; + from: string; + to: string; + angle: number; + }): Field => ({ + spec: { kind: "gradient", ...s }, + }), +}; + +/** + * Builds a `ToolSchema

` from declared fields. + * The compiler checks: `fields` keys === keys of `P`, field types match `P[K]`. + */ +export function toolSchema

( + fields: { [K in keyof P]: Field }, + meta?: ToolSchemaMeta, +): ToolSchema

{ + return { fields, ...(meta ?? {}) }; +} diff --git a/web/src/lib/registry-schema/index.ts b/web/src/lib/registry-schema/index.ts new file mode 100644 index 0000000..48e8760 --- /dev/null +++ b/web/src/lib/registry-schema/index.ts @@ -0,0 +1,5 @@ +export * from "./aspect"; +export * from "./field"; +export * from "./layout"; +export * from "./specs"; +export * from "./values"; diff --git a/web/src/lib/registry-schema/layout.ts b/web/src/lib/registry-schema/layout.ts new file mode 100644 index 0000000..8deb08a --- /dev/null +++ b/web/src/lib/registry-schema/layout.ts @@ -0,0 +1,36 @@ +import type { ToolSchema } from "./specs"; + +export interface ResolvedLayoutGroup { + key: string; + title?: string; + cols: number; + fields: string[]; +} + +export function resolveLayoutGroups

( + schema: ToolSchema

, +): ResolvedLayoutGroup[] { + const all = Object.keys(schema.fields); + const groups = schema.layout?.groups ?? []; + const used: Record = {}; + const named: ResolvedLayoutGroup[] = groups + .map((group, index) => { + const fields = group.fields.filter((fieldId) => { + if (used[fieldId] || !(fieldId in schema.fields)) return false; + used[fieldId] = true; + return true; + }); + return { + key: `${group.title ?? "group"}-${index}`, + title: group.title, + cols: Math.max(1, Math.trunc(group.cols ?? 1)), + fields, + } satisfies ResolvedLayoutGroup; + }) + .filter((group) => group.fields.length > 0); + const rest = all.filter((fieldId) => !used[fieldId]); + if (rest.length > 0) { + named.push({ key: "__default", cols: 1, fields: rest }); + } + return named; +} diff --git a/web/src/lib/registry-schema/specs.ts b/web/src/lib/registry-schema/specs.ts new file mode 100644 index 0000000..4051a6f --- /dev/null +++ b/web/src/lib/registry-schema/specs.ts @@ -0,0 +1,250 @@ +// Typed parameter schema for the UI. Compile-time type binding (drift +// protection): the phantom `Field.__fieldT` ties the runtime spec to the +// expected value type, so the compiler catches Params/schema mismatches. + +import type { TextFont } from "../core/domText"; +import type { Position9 } from "../core/textdraw"; + +/** Optional field of every spec: label key in the dictionary (see `fieldLabel`). */ +export interface FieldSpecBase { + label?: string; + /** Field visible only when another field equals a value (tool modes). */ + visibleWhen?: { field: string; equals: string }; +} + +export interface NumberSpec extends FieldSpecBase { + kind: "number"; + default: number; + min?: number; + max?: number; + step?: number; + /** Cap from source dimensions (axis); static max is the fallback. */ + maxFromSource?: "width" | "height"; + maxMinus?: number; +} + +export interface SliderSpec extends FieldSpecBase { + kind: "slider"; + default: number; + min: number; + max: number; + step?: number; +} + +export interface ColorSpec extends FieldSpecBase { + kind: "color"; + default: string; +} + +export interface SelectSpec extends FieldSpecBase { + kind: "select"; + default: V; + options: { value: V; label: string }[]; +} + +/** Same as select but rendered as segment buttons (1-click choice). */ +export interface SegmentedSpec< + V extends string = string, +> extends FieldSpecBase { + kind: "segmented"; + default: V; + options: { value: V; label: string }[]; +} + +export interface TextSpec extends FieldSpecBase { + kind: "text"; + default: string; + placeholder?: string; +} + +export interface CheckboxSpec extends FieldSpecBase { + kind: "checkbox"; + default: boolean; +} + +/** Composite "dimensions" field: width + height as one object. */ +export interface Dimension { + width: number; + height: number; +} + +/** Single list of tile presets (squares) for dimension fields. */ +export const SIZE_PRESETS = [16, 32, 64, 128, 256, 512] as const; + +export interface DimensionSpec extends FieldSpecBase { + kind: "dimension"; + /** Shared range for both dimensions. */ + min: number; + max: number; + width: number; + height: number; + defaultFromSource?: boolean; + /** Per-axis cap from the matching source dimension. */ + maxFromSource?: boolean; + /** Id of the "keep aspect ratio" checkbox field. */ + lockAspectWith?: string; + /** Show quick-pick chips from SIZE_PRESETS. */ + presets?: boolean; +} + +export interface SchemaContext { + source?: Dimension; +} + +/** Color pair "from -> to" (gradients, two-color mapping, etc.). */ +export interface ColorPair { + from: string; + to: string; +} + +export interface ColorPairSpec extends FieldSpecBase { + kind: "color-pair"; + from: string; + to: string; +} + +/** Color list (palette): array of hex strings. */ +export type ColorList = string[]; + +export interface ColorListSpec extends FieldSpecBase { + kind: "colors"; + default: string[]; +} + +/** Shape/object offset from center: x + y in percent. */ +export interface Offset { + x: number; + y: number; +} + +export interface OffsetSpec extends FieldSpecBase { + kind: "offset"; + /** Shared range for both axes (in percent). */ + min: number; + max: number; + x: number; + y: number; +} + +/** 9-position grid (3x3); values match `Position9` from core/textdraw. */ +export const POSITION9_VALUES = [ + "top-left", + "top-center", + "top-right", + "middle-left", + "center", + "middle-right", + "bottom-left", + "bottom-center", + "bottom-right", +] as const; + +export interface Position9Spec extends FieldSpecBase { + kind: "position9"; + default: Position9; +} + +/** Text caption style: font, size, bold, and color as one object. */ +export interface FontStyle { + font: TextFont; + size: number; + bold: boolean; + color: string; +} + +export interface FontStyleSpec extends FieldSpecBase { + kind: "font-style"; + /** Font size range. */ + min: number; + max: number; + size: number; + font: TextFont; + bold: boolean; + color: string; +} + +/** Backing plate under a text caption: on/off, color, and opacity. */ +export interface Plate { + enabled: boolean; + color: string; + opacity: number; +} + +export interface PlateSpec extends FieldSpecBase { + kind: "plate"; + enabled: boolean; + color: string; + opacity: number; +} + +/** Color gradient: pair + angle in degrees (0: left-to-right, 90: top-to-bottom). */ +export interface Gradient { + from: string; + to: string; + angle: number; +} + +export interface GradientSpec extends FieldSpecBase { + kind: "gradient"; + from: string; + to: string; + /** Gradient direction: 0..360, angle in degrees. */ + angle: number; +} + +/** kind -> field spec map: single source of `FieldSpecKind`/`FieldSpec`. */ +export const fieldSpecs = { + number: {} as NumberSpec, + slider: {} as SliderSpec, + color: {} as ColorSpec, + select: {} as SelectSpec, + segmented: {} as SegmentedSpec, + text: {} as TextSpec, + checkbox: {} as CheckboxSpec, + dimension: {} as DimensionSpec, + "color-pair": {} as ColorPairSpec, + colors: {} as ColorListSpec, + offset: {} as OffsetSpec, + position9: {} as Position9Spec, + "font-style": {} as FontStyleSpec, + plate: {} as PlateSpec, + gradient: {} as GradientSpec, +} as const; + +export type FieldSpecKind = keyof typeof fieldSpecs; +export type FieldSpecOf = (typeof fieldSpecs)[K]; +export type FieldSpec = FieldSpecOf; + +/** `Field`: runtime field spec + phantom type of the expected value (number|string|boolean). */ +export interface Field { + spec: FieldSpec; + /** Phantom, for type safety only; never set or read at runtime. */ + __fieldT?: T; +} + +export interface ToolLayoutGroup { + /** Group title (an empty group is not rendered). */ + title?: string; + /** Grid column count inside the group (default 1). */ + cols?: number; + /** Schema field names belonging to the group. */ + fields: string[]; +} + +export interface ToolSchemaLayout { + /** Widget field groups. Unmentioned fields go to a trailing default group. */ + groups: ToolLayoutGroup[]; +} + +export interface ToolSchemaMeta { + /** Per-tool field layout (grouping/columns). */ + layout?: ToolSchemaLayout; + /** Short tool label for the UI (optional). */ + label?: string; +} + +export interface ToolSchema

{ + fields: { [K in keyof P]: Field }; + layout?: ToolSchemaLayout; + label?: string; +} diff --git a/web/src/lib/registry-schema/values.ts b/web/src/lib/registry-schema/values.ts new file mode 100644 index 0000000..9e83d4b --- /dev/null +++ b/web/src/lib/registry-schema/values.ts @@ -0,0 +1,291 @@ +import { clamp } from "../core/math"; +import type { Position9 } from "../core/textdraw"; +import { dimensionAxisMax, effectiveMax } from "./aspect"; +import { + POSITION9_VALUES, + type Dimension, + type DimensionSpec, + type SchemaContext, + type ToolSchema, +} from "./specs"; + +function resolveDimensionDefaults( + spec: DimensionSpec, + context?: SchemaContext, +): Dimension { + const source = spec.defaultFromSource ? context?.source : undefined; + return { + width: clamp(source?.width ?? spec.width, spec.min, spec.max), + height: clamp(source?.height ?? spec.height, spec.min, spec.max), + }; +} + +/** Defaults from the schema: the single source of default values for the UI. */ +export function defaultSchemaParams

( + schema: ToolSchema

, + context?: SchemaContext, +): Record { + const out = {} as Record; + for (const key of Object.keys(schema.fields) as (keyof P)[]) { + const spec = schema.fields[key].spec; + if (spec.kind === "dimension") { + out[key] = resolveDimensionDefaults(spec, context); + } else if (spec.kind === "color-pair") { + out[key] = { from: spec.from, to: spec.to }; + } else if (spec.kind === "colors") { + out[key] = [...spec.default]; + } else if (spec.kind === "offset") { + out[key] = { x: spec.x, y: spec.y }; + } else if (spec.kind === "font-style") { + out[key] = { + font: spec.font, + size: spec.size, + bold: spec.bold, + color: spec.color, + }; + } else if (spec.kind === "plate") { + out[key] = { + enabled: spec.enabled, + color: spec.color, + opacity: spec.opacity, + }; + } else if (spec.kind === "gradient") { + out[key] = { + from: spec.from, + to: spec.to, + angle: spec.angle, + }; + } else { + out[key] = spec.default; + } + } + return out; +} + +/** + * Recomputes defaults from a new source, preserving user values. Fields in + * touched (edited manually) are not overwritten even with a source default; + * otherwise loading a new file would wipe configured sizes. + */ +export function applySourceDefaults

( + schema: ToolSchema

, + values: Record, + context: SchemaContext, + touched?: ReadonlySet, +): Record { + const defaults = defaultSchemaParams(schema, context); + for (const key of Object.keys(schema.fields) as (keyof P)[]) { + const spec = schema.fields[key].spec; + const fromSource = spec.kind === "dimension" && spec.defaultFromSource; + if (fromSource && !touched?.has(key as string)) continue; + if (Object.prototype.hasOwnProperty.call(values, key)) { + defaults[key] = values[key as string]; + } + } + return defaults; +} + +/** Validation/normalization of values against the schema. Successor of `sanitizeParams`. */ +export function sanitizeSchemaParams

( + schema: ToolSchema

, + values: Record, + context?: SchemaContext, +): Record { + const out = {} as Record; + for (const key of Object.keys(schema.fields) as (keyof P)[]) { + const spec = schema.fields[key].spec; + const raw = values[key as string]; + switch (spec.kind) { + case "number": + case "slider": { + const n = + typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default; + const max = + spec.kind === "number" + ? effectiveMax(spec, context?.source) + : spec.max; + out[key] = clamp(n, spec.min ?? -Infinity, max ?? Infinity); + break; + } + case "select": + case "segmented": + out[key] = + typeof raw === "string" && spec.options.some((o) => o.value === raw) + ? raw + : spec.default; + break; + case "checkbox": + out[key] = typeof raw === "boolean" ? raw : spec.default; + break; + case "color": + out[key] = + typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw) + ? raw + : spec.default; + break; + case "text": + out[key] = typeof raw === "string" ? raw : spec.default; + break; + case "dimension": { + const r = + typeof raw === "object" && + raw !== null && + "width" in raw && + "height" in raw + ? (raw as Record) + : undefined; + const defaults = resolveDimensionDefaults(spec, context); + const w = + typeof r?.width === "number" && Number.isFinite(r.width) + ? r.width + : defaults.width; + const h = + typeof r?.height === "number" && Number.isFinite(r.height) + ? r.height + : defaults.height; + const sourceSize = + spec.defaultFromSource && context?.source && w === 0 && h === 0 + ? defaults + : { width: w, height: h }; + out[key] = { + width: clamp( + sourceSize.width, + spec.min, + dimensionAxisMax(spec, "width", context?.source), + ), + height: clamp( + sourceSize.height, + spec.min, + dimensionAxisMax(spec, "height", context?.source), + ), + }; + break; + } + case "color-pair": { + const r = + typeof raw === "object" && + raw !== null && + "from" in raw && + "to" in raw + ? (raw as Record) + : undefined; + const from = + typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from) + ? r.from + : spec.from; + const to = + typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to) + ? r.to + : spec.to; + out[key] = { from, to }; + break; + } + case "colors": { + const r = + Array.isArray(raw) && + raw.every((c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c)) + ? (raw as string[]) + : undefined; + out[key] = r && r.length > 0 ? [...r] : [...spec.default]; + break; + } + case "position9": + out[key] = + typeof raw === "string" && + (POSITION9_VALUES as readonly string[]).includes(raw) + ? (raw as Position9) + : spec.default; + break; + case "font-style": { + const r = + typeof raw === "object" && + raw !== null && + "font" in raw && + "size" in raw && + "bold" in raw && + "color" in raw + ? (raw as Record) + : undefined; + out[key] = { + font: + r?.font === "sans" || r?.font === "serif" || r?.font === "mono" + ? r.font + : spec.font, + size: + typeof r?.size === "number" && Number.isFinite(r.size) + ? clamp(r.size, spec.min, spec.max) + : spec.size, + bold: typeof r?.bold === "boolean" ? r.bold : spec.bold, + color: + typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color) + ? r.color + : spec.color, + }; + break; + } + case "plate": { + const r = + typeof raw === "object" && + raw !== null && + "enabled" in raw && + "color" in raw && + "opacity" in raw + ? (raw as Record) + : undefined; + out[key] = { + enabled: typeof r?.enabled === "boolean" ? r.enabled : spec.enabled, + color: + typeof r?.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color) + ? r.color + : spec.color, + opacity: + typeof r?.opacity === "number" && Number.isFinite(r.opacity) + ? clamp(r.opacity, 0, 100) + : spec.opacity, + }; + break; + } + case "offset": { + const r = + typeof raw === "object" && raw !== null && "x" in raw && "y" in raw + ? (raw as Record) + : undefined; + const x = + typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x; + const y = + typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y; + out[key] = { + x: clamp(x, spec.min, spec.max), + y: clamp(y, spec.min, spec.max), + }; + break; + } + case "gradient": { + const r = + typeof raw === "object" && + raw !== null && + "from" in raw && + "to" in raw && + "angle" in raw + ? (raw as Record) + : undefined; + out[key] = { + from: + typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from) + ? r.from + : spec.from, + to: + typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to) + ? r.to + : spec.to, + angle: + typeof r?.angle === "number" && Number.isFinite(r.angle) + ? clamp(r.angle, 0, 360) + : spec.angle, + }; + break; + } + } + } + return out; +}