diff --git a/CHANGELOG.md b/CHANGELOG.md index feb1b2d0..9676bef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ a git tag (see [RELEASING.md](RELEASING.md)). ## [Unreleased] +### Deprecated + +- **`@fundamental-engine/core` + `@fundamental-engine/dom` — the recipe → `Pattern` rename reaches the internal helper surface (phase 4, additive).** The remaining `recipe`-named exported helpers are renamed to `Pattern`, each keeping a `@deprecated` alias of the old name (removed at `1.0`): `BodyPattern` (was `BodyRecipe`), `RelationshipPattern`, `AccessibilityPattern`, `PatternTier`, `PatternStatus`, `PatternProblem`, `PatternTierGroup`, `PatternRenderPlan`, `PatternBodyRegistration`, `PatternRelationshipRegistration`, `PatternFeedbackBinding`, `PatternReducedMotionPlan`, `PatternAuthoring`, `PatternFieldTarget` (dom), the consts `PATTERN_TIERS` / `PATTERN_CONTRACTS`, and the functions `validatePattern` / `serializePattern` / `patternById` / `patternBodyAttributes` / `patternRenderPlan` / `patternToMarkup` / `patternAuthoring`. `bindData` gains a `pattern` option (the `recipe` option is still read as a deprecated fallback). Every old name still works. Intentionally left unchanged (invisible plumbing, not concept surface): the `recipes/` source folder, `apply-recipe.ts`, `data/recipes.json`, and the `RelationshipSource` `'recipe'` value tag. + ## [0.9.4] — 2026-07-10 ### Added diff --git a/packages/core/src/focus-substrate.test.ts b/packages/core/src/focus-substrate.test.ts index 9de19cfd..85179164 100644 --- a/packages/core/src/focus-substrate.test.ts +++ b/packages/core/src/focus-substrate.test.ts @@ -14,7 +14,7 @@ import { createField } from './engine/field.ts'; import type { FieldHost } from './engine/host.ts'; import type { FocusEvent } from './engine/types.ts'; import { FOCUS_WELL } from './recipes/focus.ts'; -import { validateRecipe, EXPERIMENTAL_PATTERNS } from './recipes/index.ts'; +import { validatePattern, EXPERIMENTAL_PATTERNS } from './recipes/index.ts'; import { FIELD_PATTERNS } from './recipes/catalog.ts'; function drivableHost(reducedMotion = false): { host: FieldHost; step: (frames: number) => void } { @@ -212,7 +212,7 @@ test('focus well (active): a focused attract body gathers matter tighter than an }); test('FOCUS_WELL Pattern validates and lives in EXPERIMENTAL_PATTERNS, not the locked 64', () => { - assert.deepEqual(validateRecipe(FOCUS_WELL), [], 'the focus-well Pattern is valid'); + assert.deepEqual(validatePattern(FOCUS_WELL), [], 'the focus-well Pattern is valid'); assert.ok(EXPERIMENTAL_PATTERNS.some((p) => p.id === 'focus-well'), 'surfaced in EXPERIMENTAL_PATTERNS'); assert.ok(!FIELD_PATTERNS.some((p) => p.id === 'focus-well'), 'NEVER in the locked 64 FIELD_PATTERNS'); }); diff --git a/packages/core/src/inspect/report.ts b/packages/core/src/inspect/report.ts index 7162bf09..3a0bea1f 100644 --- a/packages/core/src/inspect/report.ts +++ b/packages/core/src/inspect/report.ts @@ -9,7 +9,7 @@ import { CONTRACTS } from '../contracts/index.ts'; import { PASSPORTS } from '../contracts/passport.ts'; import { AGENT_CONTRACTS } from '../agents/index.ts'; import { VISUAL_CONTRACTS } from '../visual/index.ts'; -import { RECIPE_CONTRACTS, FIELD_RECIPES } from '../recipes/index.ts'; +import { PATTERN_CONTRACTS, FIELD_RECIPES } from '../recipes/index.ts'; import { EXPERIMENTS } from '../conformance/experiments.ts'; import { allForces } from '../conformance/run.ts'; @@ -35,7 +35,7 @@ export function systemReport(): SystemReport { forces: tokens.length, passports: Object.keys(PASSPORTS).length, conformanceExperiments: EXPERIMENTS.length, - contracts: CONTRACTS.length + AGENT_CONTRACTS.length + VISUAL_CONTRACTS.length + RECIPE_CONTRACTS.length, + contracts: CONTRACTS.length + AGENT_CONTRACTS.length + VISUAL_CONTRACTS.length + PATTERN_CONTRACTS.length, agentTypes: AGENT_CONTRACTS.length, recipes: FIELD_RECIPES.length, forcesMissingPassport: tokens.filter((t) => !PASSPORTS[t]), diff --git a/packages/core/src/recipes/catalog.ts b/packages/core/src/recipes/catalog.ts index 7f844e41..e493a0dc 100644 --- a/packages/core/src/recipes/catalog.ts +++ b/packages/core/src/recipes/catalog.ts @@ -1,17 +1,17 @@ /** * The field-recipe catalog (authoring-and-recipes §7) — the data file for the 64 portable * `FieldRecipe`s that map the four-field translation model onto practical interface patterns, grouped - * into four tiers (`RECIPE_TIERS`). Each doubles as a worked example and a conformance fixture: every + * into four tiers (`PATTERN_TIERS`). Each doubles as a worked example and a conformance fixture: every * runtime token is a real passported force, every render layer + diagnostic is a known mode, the * declared primitives match the body tokens, and no primitive is a diagnostic/metric/concept/condition - * — so `validateRecipe` passes for all of them (the conformance test enforces this). + * — so `validatePattern` passes for all of them (the conformance test enforces this). * * Lanes are kept separate: `primitives` are strict runtime tokens; `concepts`/`conditions` (layered in * by id below) are product language and activation logic; `metrics`/`diagnostics` are measured state * and inspection modes. These are classification + authoring artifacts — they compose existing * primitives and add NO new engine behavior. Eight ids are the recommended first-release set. */ -import type { FieldRecipe, RecipeTier } from './schema.ts'; +import type { FieldRecipe, PatternTier } from './schema.ts'; import { EXPERIMENTAL_RECIPES } from './wayfinding.ts'; // ── Gravity: priority, convergence, hierarchy ─────────────────────────────────────── @@ -1500,7 +1500,7 @@ const CONDITIONS: Readonly> = { }; /** Layer tier + status + the concept/condition lanes onto a tier's raw records. */ -const decorate = (recipes: readonly FieldRecipe[], tier: RecipeTier): FieldRecipe[] => +const decorate = (recipes: readonly FieldRecipe[], tier: PatternTier): FieldRecipe[] => recipes.map((r) => ({ ...r, tier, @@ -1509,14 +1509,14 @@ const decorate = (recipes: readonly FieldRecipe[], tier: RecipeTier): FieldRecip ...(CONDITIONS[r.id] ? { conditions: [...CONDITIONS[r.id]!] } : {}), })); -export interface RecipeTierGroup { - key: RecipeTier; +export interface PatternTierGroup { + key: PatternTier; label: string; recipes: readonly FieldRecipe[]; } /** The four catalog tiers, in order — the navigable structure over {@link FIELD_RECIPES}. */ -export const RECIPE_TIERS: readonly RecipeTierGroup[] = [ +export const PATTERN_TIERS: readonly PatternTierGroup[] = [ { key: 'core', label: 'Core — interface & accessibility', recipes: decorate(TIER_CORE, 'core') }, { key: 'applied', label: 'Applied — product, workflow & collaboration', recipes: decorate(TIER_PRODUCT, 'applied') }, { key: 'systems', label: 'Systems — safety, provenance & governance', recipes: decorate(TIER_SYSTEMS, 'systems') }, @@ -1524,7 +1524,7 @@ export const RECIPE_TIERS: readonly RecipeTierGroup[] = [ ]; /** The full field-pattern catalog (authoring §7) — 64 patterns across four tiers, in catalog order. */ -export const FIELD_PATTERNS: readonly FieldRecipe[] = RECIPE_TIERS.flatMap((t) => t.recipes); +export const FIELD_PATTERNS: readonly FieldRecipe[] = PATTERN_TIERS.flatMap((t) => t.recipes); /** @deprecated Renamed to {@link FIELD_PATTERNS} (recipe → Pattern); removed at 1.0. */ export const FIELD_RECIPES: readonly FieldRecipe[] = FIELD_PATTERNS; @@ -1555,6 +1555,11 @@ export const FIRST_RELEASE_RECIPES: readonly FieldRecipe[] = FIRST_RELEASE_RECIP ); /** Look up a recipe by id — the canonical 64 first, then the experimental set (undefined if unknown). */ -export function recipeById(id: string): FieldRecipe | undefined { +export function patternById(id: string): FieldRecipe | undefined { return FIELD_RECIPES.find((r) => r.id === id) ?? EXPERIMENTAL_RECIPES.find((r) => r.id === id); } + +// ── Deprecated aliases (recipe → Pattern rename, phase 4; removed at 1.0) ────────────────────── +/** @deprecated Renamed to {@link PatternTierGroup}. */ export type RecipeTierGroup = PatternTierGroup; +/** @deprecated Renamed to {@link PATTERN_TIERS}. */ export const RECIPE_TIERS = PATTERN_TIERS; +/** @deprecated Renamed to {@link patternById}. */ export const recipeById = patternById; diff --git a/packages/core/src/recipes/charge.test.ts b/packages/core/src/recipes/charge.test.ts index 93983955..f3003e3f 100644 --- a/packages/core/src/recipes/charge.test.ts +++ b/packages/core/src/recipes/charge.test.ts @@ -5,15 +5,15 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { validateRecipe, serializeRecipe, parseRecipe, primitivesOf, FIELD_MODES } from './schema.ts'; +import { validatePattern, serializePattern, parseRecipe, primitivesOf, FIELD_MODES } from './schema.ts'; import { CONTOUR_CHARGE, CHARGE_RECIPES } from './charge.ts'; import { EXPERIMENTAL_RECIPES } from './wayfinding.ts'; -import { FIELD_RECIPES, recipeById } from './catalog.ts'; +import { FIELD_RECIPES, patternById } from './catalog.ts'; import { compileRecipe } from './compile.ts'; import { passportFor } from '../contracts/passport.ts'; test('contour-charge validates: real tokens, known layers, primitives match body tokens', () => { - const problems = validateRecipe(CONTOUR_CHARGE); + const problems = validatePattern(CONTOUR_CHARGE); assert.deepEqual(problems, [], problems.map((p) => `${p.path} ${p.issue}`).join(', ')); assert.deepEqual(CONTOUR_CHARGE.primitives, primitivesOf(CONTOUR_CHARGE.bodies)); for (const p of CONTOUR_CHARGE.primitives) { @@ -30,7 +30,7 @@ test('the lane rule holds: the recipe is contour-charge, never bare "charge" (th test('registered experimentally: in EXPERIMENTAL_RECIPES, resolvable by id, outside the 64', () => { assert.ok(EXPERIMENTAL_RECIPES.some((r) => r.id === 'contour-charge')); - assert.equal(recipeById('contour-charge'), CONTOUR_CHARGE); + assert.equal(patternById('contour-charge'), CONTOUR_CHARGE); assert.ok(!FIELD_RECIPES.includes(CONTOUR_CHARGE), 'the canonical 64 is undisturbed'); assert.equal(CHARGE_RECIPES.length, 1); }); @@ -38,7 +38,7 @@ test('registered experimentally: in EXPERIMENTAL_RECIPES, resolvable by id, outs test('compiles and round-trips', () => { const plan = compileRecipe(CONTOUR_CHARGE); assert.ok(plan.bodies.length === 1, 'one vessel body'); - assert.deepEqual(parseRecipe(serializeRecipe(CONTOUR_CHARGE)), CONTOUR_CHARGE); + assert.deepEqual(parseRecipe(serializePattern(CONTOUR_CHARGE)), CONTOUR_CHARGE); }); test('the recipe is self-executing: the vessel body compiles with its data-when gate (#370)', () => { diff --git a/packages/core/src/recipes/compile.ts b/packages/core/src/recipes/compile.ts index f48b0853..745aedce 100644 --- a/packages/core/src/recipes/compile.ts +++ b/packages/core/src/recipes/compile.ts @@ -10,7 +10,7 @@ * bindings; diagnostics become inspector/render toggles; conditions become activation gates; * accessibility becomes a reduced-motion output plan. */ -import type { BodyRecipe, FieldRecipe } from './schema.ts'; +import type { BodyPattern, FieldRecipe } from './schema.ts'; /** The CSS feedback variable a metric writes to (`attention` → `--field-attention`). */ export function metricVar(metric: string): string { @@ -18,7 +18,7 @@ export function metricVar(metric: string): string { } /** The data-* attributes a single recipe body authors onto an element (token lane → behavior). */ -export function recipeBodyAttributes(b: BodyRecipe): Record { +export function patternBodyAttributes(b: BodyPattern): Record { const a: Record = { 'data-body': b.body }; if (b.strength != null) a['data-strength'] = String(b.strength); if (b.range != null) a['data-range'] = String(b.range); @@ -33,7 +33,7 @@ export function recipeBodyAttributes(b: BodyRecipe): Record { * layer drives. One matter mode owns the underlay; overlay readings stack additively; heatmap * is its own toggle. Layers with no executable surface yet are NAMED in `unapplied` — never * silently dropped (the no-silent-caps rule). */ -export interface RecipeRenderPlan { +export interface PatternRenderPlan { /** the underlay matter mode (`setRender`), or null to leave the field's current mode alone. */ underlay: string | null; /** the additive overlay reading stack (`setOverlay`); empty = 'off'. */ @@ -52,7 +52,7 @@ const OVERLAY_READINGS = new Set(['streamlines', 'force-vectors', 'field-lines', /** Derive the render plan from a recipe's declared layers (pure; `particles` is the base matter * layer and maps to `dots`). `streamlines` prefers the overlay (it reads over content) unless it * is the recipe's ONLY layer, where the underlay render mode of the same name serves. */ -export function recipeRenderPlan(layers: readonly string[]): RecipeRenderPlan { +export function patternRenderPlan(layers: readonly string[]): PatternRenderPlan { let underlay: string | null = null; const overlay: string[] = []; let heatmap = false; @@ -74,13 +74,13 @@ export function recipeRenderPlan(layers: readonly string[]): RecipeRenderPlan { } /** One compiled body: the attribute set + the runtime tokens it carries. */ -export interface RecipeBodyRegistration { +export interface PatternBodyRegistration { attributes: Record; tokens: string[]; } /** A relationship the recipe declares (from/to are conceptual endpoints, resolved at apply time). */ -export interface RecipeRelationshipRegistration { +export interface PatternRelationshipRegistration { from: string; to: string; type: string; @@ -88,13 +88,13 @@ export interface RecipeRelationshipRegistration { } /** A metric → feedback-variable binding (the metric lane becoming measurable state). */ -export interface RecipeFeedbackBinding { +export interface PatternFeedbackBinding { metric: string; var: string; } /** The reduced-motion output plan — what the runtime renders when motion is reduced (not just prose). */ -export interface RecipeReducedMotionPlan { +export interface PatternReducedMotionPlan { reducedMotion: string; meaningWithoutMotion: string; /** the static surfaces the runtime should render in place of motion. */ @@ -105,15 +105,15 @@ export interface RecipeReducedMotionPlan { export interface CompiledPattern { id: string; recipe: FieldRecipe; - bodies: RecipeBodyRegistration[]; - relationships: RecipeRelationshipRegistration[]; - feedback: RecipeFeedbackBinding[]; + bodies: PatternBodyRegistration[]; + relationships: PatternRelationshipRegistration[]; + feedback: PatternFeedbackBinding[]; diagnostics: string[]; metrics: string[]; conditions: string[]; /** the executable render plan (#370) — what applyRecipe drives when given a field target. */ - render: RecipeRenderPlan; - reducedMotion: RecipeReducedMotionPlan; + render: PatternRenderPlan; + reducedMotion: PatternReducedMotionPlan; } /** @deprecated Renamed to {@link CompiledPattern} (recipe → Pattern); removed at 1.0. */ @@ -143,13 +143,13 @@ export function compilePattern(r: FieldRecipe): CompiledPattern { return { id: r.id, recipe: r, - bodies: r.bodies.map((b) => ({ attributes: recipeBodyAttributes(b), tokens: tokensOf(b.body) })), + bodies: r.bodies.map((b) => ({ attributes: patternBodyAttributes(b), tokens: tokensOf(b.body) })), relationships: (r.relationships ?? []).map((rel) => ({ from: rel.from, to: rel.to, type: rel.type, strength: rel.strength })), feedback: r.metrics.map((m) => ({ metric: m, var: metricVar(m) })), diagnostics: [...r.diagnostics], metrics: [...r.metrics], conditions: [...(r.conditions ?? [])], - render: recipeRenderPlan(r.render ?? []), + render: patternRenderPlan(r.render ?? []), reducedMotion: { reducedMotion: r.accessibility.reducedMotion, meaningWithoutMotion: r.accessibility.meaningWithoutMotion, @@ -167,8 +167,8 @@ const attrsToString = (a: Record): string => * Emit the `[data-body]` markup a recipe authors — a `` plus one element per body. This is * the copy-paste authoring for a recipe; drop it on a page and the field runs the recipe. */ -export function recipeToMarkup(r: FieldRecipe): string { - const bodies = r.bodies.map((b) => `
`).join('\n'); +export function patternToMarkup(r: FieldRecipe): string { + const bodies = r.bodies.map((b) => `
`).join('\n'); return `\n${bodies}`; } @@ -176,19 +176,31 @@ const pascal = (id: string): string => id.split(/[^a-z0-9]+/i).filter(Boolean).map((s) => s[0]!.toUpperCase() + s.slice(1)).join('') || 'Field'; /** A recipe's copy-paste authoring across the three surfaces. */ -export interface RecipeAuthoring { +export interface PatternAuthoring { html: string; webComponent: string; react: string; } /** Emit a recipe's authoring for native HTML, the web component, and React (pure). */ -export function recipeAuthoring(r: FieldRecipe): RecipeAuthoring { - const bodyLines = r.bodies.map((b) => `
`).join('\n'); - const reactBodies = r.bodies.map((b) => `
`).join('\n'); +export function patternAuthoring(r: FieldRecipe): PatternAuthoring { + const bodyLines = r.bodies.map((b) => `
`).join('\n'); + const reactBodies = r.bodies.map((b) => `
`).join('\n'); return { - html: recipeToMarkup(r), + html: patternToMarkup(r), webComponent: `\n\n\n${bodyLines}`, react: `import { FieldField } from '@fundamental-engine/react';\n\nexport default function ${pascal(r.id)}() {\n return (\n \n${reactBodies}\n \n );\n}`, }; } + +// ── Deprecated aliases (recipe → Pattern rename, phase 4; removed at 1.0) ────────────────────── +/** @deprecated Renamed to {@link PatternRenderPlan}. */ export type RecipeRenderPlan = PatternRenderPlan; +/** @deprecated Renamed to {@link PatternBodyRegistration}. */ export type RecipeBodyRegistration = PatternBodyRegistration; +/** @deprecated Renamed to {@link PatternRelationshipRegistration}. */ export type RecipeRelationshipRegistration = PatternRelationshipRegistration; +/** @deprecated Renamed to {@link PatternFeedbackBinding}. */ export type RecipeFeedbackBinding = PatternFeedbackBinding; +/** @deprecated Renamed to {@link PatternReducedMotionPlan}. */ export type RecipeReducedMotionPlan = PatternReducedMotionPlan; +/** @deprecated Renamed to {@link PatternAuthoring}. */ export type RecipeAuthoring = PatternAuthoring; +/** @deprecated Renamed to {@link patternBodyAttributes}. */ export const recipeBodyAttributes = patternBodyAttributes; +/** @deprecated Renamed to {@link patternRenderPlan}. */ export const recipeRenderPlan = patternRenderPlan; +/** @deprecated Renamed to {@link patternToMarkup}. */ export const recipeToMarkup = patternToMarkup; +/** @deprecated Renamed to {@link patternAuthoring}. */ export const recipeAuthoring = patternAuthoring; diff --git a/packages/core/src/recipes/gravity.test.ts b/packages/core/src/recipes/gravity.test.ts index d3ea618c..060c610b 100644 --- a/packages/core/src/recipes/gravity.test.ts +++ b/packages/core/src/recipes/gravity.test.ts @@ -5,15 +5,15 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { validateRecipe, serializeRecipe, parseRecipe, primitivesOf, FIELD_MODES } from './schema.ts'; +import { validatePattern, serializePattern, parseRecipe, primitivesOf, FIELD_MODES } from './schema.ts'; import { GRAVITY_FIELD, GRAVITY_RECIPES } from './gravity.ts'; import { EXPERIMENTAL_RECIPES } from './wayfinding.ts'; -import { FIELD_RECIPES, recipeById } from './catalog.ts'; +import { FIELD_RECIPES, patternById } from './catalog.ts'; import { compileRecipe } from './compile.ts'; import { passportFor } from '../contracts/passport.ts'; test('gravity-field validates: real tokens, known layers, primitives match body tokens', () => { - const problems = validateRecipe(GRAVITY_FIELD); + const problems = validatePattern(GRAVITY_FIELD); assert.deepEqual(problems, [], problems.map((p) => `${p.path} ${p.issue}`).join(', ')); assert.deepEqual(GRAVITY_FIELD.primitives, primitivesOf(GRAVITY_FIELD.bodies)); for (const p of GRAVITY_FIELD.primitives) { @@ -36,7 +36,7 @@ test('it is built on the real natural field: gravity, with orbital threading fro test('registered experimentally: in EXPERIMENTAL_RECIPES, resolvable by id, outside the 64', () => { assert.ok(EXPERIMENTAL_RECIPES.some((r) => r.id === 'gravity-field')); - assert.equal(recipeById('gravity-field'), GRAVITY_FIELD); + assert.equal(patternById('gravity-field'), GRAVITY_FIELD); assert.ok(!FIELD_RECIPES.includes(GRAVITY_FIELD), 'the canonical 64 is undisturbed'); assert.equal(FIELD_RECIPES.length, 64, 'the gallery stays a locked 64'); assert.equal(GRAVITY_RECIPES.length, 1); @@ -47,5 +47,5 @@ test('compiles to a plan that traces the field lines over the particle underlay, assert.equal(plan.bodies.length, 1, 'one well body'); assert.deepEqual(plan.bodies[0]!.tokens.sort(), ['gravity', 'swirl'], 'the well carries both tokens'); assert.ok(plan.render.overlay.includes('field-lines'), 'the field-lines reading is in the overlay stack'); - assert.deepEqual(parseRecipe(serializeRecipe(GRAVITY_FIELD)), GRAVITY_FIELD); + assert.deepEqual(parseRecipe(serializePattern(GRAVITY_FIELD)), GRAVITY_FIELD); }); diff --git a/packages/core/src/recipes/index.ts b/packages/core/src/recipes/index.ts index cb81d5a8..10848bd0 100644 --- a/packages/core/src/recipes/index.ts +++ b/packages/core/src/recipes/index.ts @@ -17,20 +17,20 @@ export * from './explain.ts'; export * from './compile.ts'; /** The Recipe / Authoring contracts (system-contracts §13). */ -export const RECIPE_CONTRACTS: readonly ContractMeta[] = [ +export const PATTERN_CONTRACTS: readonly ContractMeta[] = [ { name: 'Field Recipe Contract', mustExist: 'id, name, intent, primitives, bodies, render, metrics, diagnostics, accessibility (+ optional naturalField, relationships, budget, expected, notes)', mayMutate: 'nothing — a recipe is data; applying it builds a scene', sideEffectFree: 'serialize / parse / validate are pure', - testable: 'validateRecipe rejects unknown tokens, render layers, diagnostics, and fields; primitives must match the body tokens; round-trips through JSON', + testable: 'validatePattern rejects unknown tokens, render layers, diagnostics, and fields; primitives must match the body tokens; round-trips through JSON', inspectable: 'the recipe is plain serializable data; explainScene renders it in plain language', }, { name: 'Recipe Runtime Contract', mustExist: 'compileRecipe(recipe) → a runtime plan (bodies/relationships/feedback/diagnostics/metrics/conditions/reducedMotion); applyRecipe(root, recipe) registers it and returns an inspectable, destroyable handle (@fundamental-engine/dom)', mayMutate: 'applyRecipe mutates the DOM it is given (registers bodies, writes --field-* vars); compileRecipe is pure', - sideEffectFree: 'compileRecipe / recipeToMarkup are pure; applyRecipe owns its lifecycle and cleans up on destroy()', + sideEffectFree: 'compileRecipe / patternToMarkup are pure; applyRecipe owns its lifecycle and cleans up on destroy()', testable: 'compiled bodies carry only real tokens (concepts never become tokens); metrics map to --field-* feedback; every shipped recipe compiles a reduced-motion output path', inspectable: 'applied.inspect() returns live measurements, relationships, metric values, and lint', }, @@ -43,3 +43,6 @@ export const RECIPE_CONTRACTS: readonly ContractMeta[] = [ inspectable: 'the compiled data-body / data-render the author can read back', }, ]; + +/** @deprecated Renamed to {@link PATTERN_CONTRACTS} (recipe → Pattern rename); removed at 1.0. */ +export const RECIPE_CONTRACTS = PATTERN_CONTRACTS; diff --git a/packages/core/src/recipes/recipes.test.ts b/packages/core/src/recipes/recipes.test.ts index 218c04a6..40288fb8 100644 --- a/packages/core/src/recipes/recipes.test.ts +++ b/packages/core/src/recipes/recipes.test.ts @@ -8,8 +8,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - validateRecipe, - serializeRecipe, + validatePattern, + serializePattern, parseRecipe, primitivesOf, FIELD_MODES, @@ -21,18 +21,18 @@ import { ESSENTIAL_RECIPES, FIRST_RELEASE_RECIPE_IDS, FIRST_RELEASE_RECIPES, - RECIPE_TIERS, - recipeById, + PATTERN_TIERS, + patternById, } from './gallery.ts'; import { explainScene, fieldDiff } from './explain.ts'; -import { compileRecipe, recipeToMarkup, recipeBodyAttributes, metricVar, recipeAuthoring } from './compile.ts'; +import { compileRecipe, patternToMarkup, patternBodyAttributes, metricVar, patternAuthoring } from './compile.ts'; import { passportFor } from '../contracts/passport.ts'; import { RENDER_MODES } from '../visual/visualization.ts'; import { FUNDAMENTAL_FIELDS } from '../config/manual.ts'; test('every field recipe is valid (real tokens, known render layers + diagnostics, matching primitives)', () => { for (const r of FIELD_RECIPES) { - const problems = validateRecipe(r); + const problems = validatePattern(r); assert.deepEqual(problems, [], `${r.name}: ${problems.map((p) => `${p.path} ${p.issue}`).join(', ')}`); } }); @@ -46,13 +46,13 @@ test('the catalog is the canonical 64 with unique kebab ids', () => { }); test('the four tiers (core/applied/systems/operational) each hold 16 and concatenate in order', () => { - assert.equal(RECIPE_TIERS.length, 4); - assert.deepEqual(RECIPE_TIERS.map((t) => t.key), ['core', 'applied', 'systems', 'operational']); - for (const t of RECIPE_TIERS) { + assert.equal(PATTERN_TIERS.length, 4); + assert.deepEqual(PATTERN_TIERS.map((t) => t.key), ['core', 'applied', 'systems', 'operational']); + for (const t of PATTERN_TIERS) { assert.equal(t.recipes.length, 16, `tier ${t.key} has 16`); for (const r of t.recipes) assert.equal(r.tier, t.key, `${r.id} carries tier ${t.key}`); } - const flattened = RECIPE_TIERS.flatMap((t) => t.recipes); + const flattened = PATTERN_TIERS.flatMap((t) => t.recipes); assert.deepEqual(flattened.map((r) => r.id), FIELD_RECIPES.map((r) => r.id), 'tiers == FIELD_RECIPES order'); }); @@ -71,17 +71,17 @@ test('no recipe primitive is ever a diagnostic, metric, concept, or condition (l } }); -test('validateRecipe gives a lane-aware error when a non-token slips into primitives', () => { +test('validatePattern gives a lane-aware error when a non-token slips into primitives', () => { const mk = (token: string): FieldRecipe => ({ id: 'x', name: 'X', intent: 'i', primitives: [token], bodies: [{ body: token }], render: ['particles'], metrics: [], diagnostics: [], accessibility: { reducedMotion: 'r', meaningWithoutMotion: 'm' }, }) as unknown as FieldRecipe; - assert.ok(validateRecipe(mk('potential')).some((p) => p.path.startsWith('primitives') && /diagnostic/.test(p.issue))); - assert.ok(validateRecipe(mk('mass')).some((p) => p.path.startsWith('primitives') && /metric/.test(p.issue))); - assert.ok(validateRecipe(mk('orbit')).some((p) => p.path.startsWith('primitives') && /concept/.test(p.issue))); - assert.ok(validateRecipe(mk('dwell')).some((p) => p.path.startsWith('primitives') && /condition/.test(p.issue))); + assert.ok(validatePattern(mk('potential')).some((p) => p.path.startsWith('primitives') && /diagnostic/.test(p.issue))); + assert.ok(validatePattern(mk('mass')).some((p) => p.path.startsWith('primitives') && /metric/.test(p.issue))); + assert.ok(validatePattern(mk('orbit')).some((p) => p.path.startsWith('primitives') && /concept/.test(p.issue))); + assert.ok(validatePattern(mk('dwell')).some((p) => p.path.startsWith('primitives') && /condition/.test(p.issue))); }); test('declared primitives match the distinct body tokens for every recipe', () => { @@ -112,11 +112,11 @@ test('FIELD_MODES covers every render mode in the visualization catalog', () => test('the first-release set is eight recipes that all resolve', () => { assert.equal(FIRST_RELEASE_RECIPE_IDS.length, 8); assert.equal(FIRST_RELEASE_RECIPES.length, 8); - for (const id of FIRST_RELEASE_RECIPE_IDS) assert.ok(recipeById(id), `${id} exists`); + for (const id of FIRST_RELEASE_RECIPE_IDS) assert.ok(patternById(id), `${id} exists`); assert.deepEqual(FIRST_RELEASE_RECIPES.map((r) => r.id), [...FIRST_RELEASE_RECIPE_IDS]); }); -test('validateRecipe rejects unknown tokens, render layers, diagnostics, and fields', () => { +test('validatePattern rejects unknown tokens, render layers, diagnostics, and fields', () => { const bad = { id: 'x', name: 'X', @@ -129,14 +129,14 @@ test('validateRecipe rejects unknown tokens, render layers, diagnostics, and fie diagnostics: ['mythology'], accessibility: { reducedMotion: 'r', meaningWithoutMotion: 'm' }, } as unknown as FieldRecipe; - const problems = validateRecipe(bad); + const problems = validatePattern(bad); assert.ok(problems.some((p) => /unknown force token "wormhole"/.test(p.issue))); assert.ok(problems.some((p) => /unknown render layer "hologram"/.test(p.issue))); assert.ok(problems.some((p) => /unknown diagnostic mode "mythology"/.test(p.issue))); assert.ok(problems.some((p) => /unknown fundamental field "mystery"/.test(p.issue))); }); -test('validateRecipe flags primitives that drift from the body tokens', () => { +test('validatePattern flags primitives that drift from the body tokens', () => { const drift = { id: 'drift', name: 'Drift', @@ -148,10 +148,10 @@ test('validateRecipe flags primitives that drift from the body tokens', () => { diagnostics: [], accessibility: { reducedMotion: 'r', meaningWithoutMotion: 'm' }, } as FieldRecipe; - assert.ok(validateRecipe(drift).some((p) => p.path === 'primitives')); + assert.ok(validatePattern(drift).some((p) => p.path === 'primitives')); }); -test('validateRecipe requires the accessibility equivalent', () => { +test('validatePattern requires the accessibility equivalent', () => { const noA11y = { id: 'na', name: 'No a11y', @@ -162,12 +162,12 @@ test('validateRecipe requires the accessibility equivalent', () => { metrics: [], diagnostics: [], } as unknown as FieldRecipe; - assert.ok(validateRecipe(noA11y).some((p) => p.path === 'accessibility')); + assert.ok(validatePattern(noA11y).some((p) => p.path === 'accessibility')); }); test('a recipe round-trips through JSON', () => { - const r = recipeById('guided-flow')!; - const back = parseRecipe(serializeRecipe(r)); + const r = patternById('guided-flow')!; + const back = parseRecipe(serializePattern(r)); assert.deepEqual(back, r); }); @@ -194,7 +194,7 @@ test('compileIntent applies intensity, range, feedback, and high-risk thermal', }); test('explainScene names the forces and the fieldflow transport caveat', () => { - const text = explainScene(recipeById('guided-flow')!); // magnetism + fieldflow + field-lines + const text = explainScene(patternById('guided-flow')!); // magnetism + fieldflow + field-lines assert.match(text, /magnetism/i); assert.match(text, /fieldflow/i); assert.match(text, /because of fieldflow/i); @@ -230,20 +230,20 @@ test('accessibility conformance: every shipped recipe compiles a reduced-motion } }); -test('recipeToMarkup + recipeBodyAttributes emit real data-body authoring', () => { - const gf = recipeById('guided-flow')!; - const markup = recipeToMarkup(gf); +test('patternToMarkup + patternBodyAttributes emit real data-body authoring', () => { + const gf = patternById('guided-flow')!; + const markup = patternToMarkup(gf); assert.match(markup, /<\/field-root>/); for (const b of gf.bodies) assert.ok(markup.includes(`data-body="${b.body}"`), `markup has ${b.body}`); - const attrs = recipeBodyAttributes({ body: 'attract', strength: 1.2, range: 320, feedback: true }); + const attrs = patternBodyAttributes({ body: 'attract', strength: 1.2, range: 320, feedback: true }); assert.equal(attrs['data-body'], 'attract'); assert.equal(attrs['data-strength'], '1.2'); assert.equal(attrs['data-range'], '320'); assert.ok('data-feedback' in attrs); }); -test('recipeAuthoring emits html / web-component / react surfaces with real tokens', () => { - const a = recipeAuthoring(recipeById('priority-well')!); +test('patternAuthoring emits html / web-component / react surfaces with real tokens', () => { + const a = patternAuthoring(patternById('priority-well')!); assert.match(a.html, /<\/field-root>/); assert.match(a.html, /data-body="attract"/); assert.match(a.webComponent, /@fundamental-engine\/elements/); diff --git a/packages/core/src/recipes/render-plan.test.ts b/packages/core/src/recipes/render-plan.test.ts index f5f11dd2..702d71bd 100644 --- a/packages/core/src/recipes/render-plan.test.ts +++ b/packages/core/src/recipes/render-plan.test.ts @@ -7,12 +7,12 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { recipeRenderPlan } from './compile.ts'; -import { validateRecipe } from './schema.ts'; +import { patternRenderPlan } from './compile.ts'; +import { validatePattern } from './schema.ts'; import type { FieldRecipe } from './schema.ts'; test('matter + overlay + heatmap split onto their surfaces', () => { - assert.deepEqual(recipeRenderPlan(['particles', 'streamlines', 'heatmap']), { + assert.deepEqual(patternRenderPlan(['particles', 'streamlines', 'heatmap']), { underlay: 'dots', overlay: ['streamlines'], heatmap: true, @@ -21,16 +21,16 @@ test('matter + overlay + heatmap split onto their surfaces', () => { }); test('one underlay only — a second matter mode is named, not silently dropped', () => { - const plan = recipeRenderPlan(['trails', 'metaballs', 'field-lines']); + const plan = patternRenderPlan(['trails', 'metaballs', 'field-lines']); assert.equal(plan.underlay, 'trails'); assert.deepEqual(plan.overlay, ['field-lines']); assert.deepEqual(plan.unapplied, ['metaballs']); }); test('streamlines alone serves as the underlay render mode; with matter it reads over content', () => { - assert.equal(recipeRenderPlan(['streamlines']).underlay, 'streamlines'); - assert.deepEqual(recipeRenderPlan(['streamlines']).overlay, []); - const both = recipeRenderPlan(['dots', 'streamlines']); + assert.equal(patternRenderPlan(['streamlines']).underlay, 'streamlines'); + assert.deepEqual(patternRenderPlan(['streamlines']).overlay, []); + const both = patternRenderPlan(['dots', 'streamlines']); assert.equal(both.underlay, 'dots'); assert.deepEqual(both.overlay, ['streamlines']); }); @@ -43,8 +43,8 @@ test('an unknown body.when gate is a validation error — never a silent never-p render: ['particles'], metrics: [], diagnostics: [], accessibility: { reducedMotion: 'static', meaningWithoutMotion: 'static' }, }; - const problems = validateRecipe(r); + const problems = validatePattern(r); assert.ok(problems.some((p) => p.path === 'bodies[0].when' && /unknown condition/.test(p.issue)), JSON.stringify(problems)); r.bodies[0]!.when = 'active'; - assert.deepEqual(validateRecipe(r), []); + assert.deepEqual(validatePattern(r), []); }); diff --git a/packages/core/src/recipes/schema.ts b/packages/core/src/recipes/schema.ts index be621075..899a1cc4 100644 --- a/packages/core/src/recipes/schema.ts +++ b/packages/core/src/recipes/schema.ts @@ -45,7 +45,7 @@ export type DiagnosticMode = | 'prediction'; /** One body in a recipe: a force token (or space-separated tokens) + attributes. */ -export interface BodyRecipe { +export interface BodyPattern { body: string; strength?: number; range?: number; @@ -54,20 +54,20 @@ export interface BodyRecipe { feedback?: boolean; scope?: 'local' | 'global'; /** condition gate for this body (`data-when`) — must be a REGISTERED engine condition id - * (active, fast, slow, hot, cool, scrolling); validateRecipe rejects unknown ids so a + * (active, fast, slow, hot, cool, scrolling); validatePattern rejects unknown ids so a * recipe can never declare a gate that silently never passes (#370). Recipe-level * `conditions` remain the activation vocabulary; `when` is the executable per-body gate. */ when?: string; } -export interface RelationshipRecipe { +export interface RelationshipPattern { from: string; to: string; type: string; strength?: number; } -export interface AccessibilityRecipe { +export interface AccessibilityPattern { /** what replaces motion under prefers-reduced-motion. */ reducedMotion: string; /** how meaning survives without color/motion. */ @@ -81,10 +81,10 @@ export interface ExpectedMetrics { } /** Which catalog tier a recipe belongs to. */ -export type RecipeTier = 'core' | 'applied' | 'systems' | 'operational'; +export type PatternTier = 'core' | 'applied' | 'systems' | 'operational'; /** A recipe's implementation status. */ -export type RecipeStatus = 'shipped' | 'experimental' | 'planned' | 'conceptual'; +export type PatternStatus = 'shipped' | 'experimental' | 'planned' | 'conceptual'; /** * A portable field recipe (authoring §5) — the reusable unit that connects the natural-field model, @@ -96,7 +96,7 @@ export interface FieldPattern { name: string; intent: string; /** which catalog tier the recipe sits in (injected during assembly). */ - tier?: RecipeTier; + tier?: PatternTier; /** the fundamental field this recipe translates (gravity / electromagnetic / strong / weak), if one dominates. */ naturalField?: FundamentalField; /** a short natural-field translation phrase (conceptual), e.g. "priority, convergence, hierarchy". */ @@ -112,12 +112,12 @@ export interface FieldPattern { diagnostics: string[]; /** CONDITIONS: activation logic (dwell, threshold, stale, in-view, focused) — not forces. */ conditions?: string[]; - bodies: BodyRecipe[]; - relationships?: RelationshipRecipe[]; + bodies: BodyPattern[]; + relationships?: RelationshipPattern[]; render: RenderLayer[]; /** the reduced-motion + meaning-without-motion equivalent — required: no recipe is motion-only. */ - accessibility: AccessibilityRecipe; - status?: RecipeStatus; + accessibility: AccessibilityPattern; + status?: PatternStatus; budget?: Partial; expected?: ExpectedMetrics; notes?: string; @@ -151,8 +151,8 @@ const DIAGNOSTIC_MODES: ReadonlySet = new Set([ export const FIELD_MODES: ReadonlySet = new Set([...RENDER_LAYERS, ...DIAGNOSTIC_MODES]); const VALID_FIELDS: ReadonlySet = new Set(FUNDAMENTAL_FIELDS); -const VALID_TIERS: ReadonlySet = new Set(['core', 'applied', 'systems', 'operational']); -const VALID_STATUS: ReadonlySet = new Set(['shipped', 'experimental', 'planned', 'conceptual']); +const VALID_TIERS: ReadonlySet = new Set(['core', 'applied', 'systems', 'operational']); +const VALID_STATUS: ReadonlySet = new Set(['shipped', 'experimental', 'planned', 'conceptual']); /** * Well-known words that belong to a DIFFERENT lane than runtime tokens. None of these is a passported @@ -179,7 +179,7 @@ const OTHER_LANE: Readonly> = { }; /** The distinct engine primitives used across a recipe's bodies, in first-seen order. */ -export function primitivesOf(bodies: readonly BodyRecipe[]): string[] { +export function primitivesOf(bodies: readonly BodyPattern[]): string[] { const seen = new Set(); const out: string[] = []; for (const b of bodies ?? []) @@ -191,7 +191,7 @@ export function primitivesOf(bodies: readonly BodyRecipe[]): string[] { return out; } -export interface RecipeProblem { +export interface PatternProblem { path: string; issue: string; } @@ -201,8 +201,8 @@ export interface RecipeProblem { * must be a real, passported force; each render layer + diagnostic must be a known mode; the declared * primitives must match the body tokens; the natural field must be one of the four. */ -export function validateRecipe(r: FieldRecipe): RecipeProblem[] { - const problems: RecipeProblem[] = []; +export function validatePattern(r: FieldRecipe): PatternProblem[] { + const problems: PatternProblem[] = []; if (!r.id) problems.push({ path: 'id', issue: 'required' }); if (!r.name) problems.push({ path: 'name', issue: 'required' }); if (!Array.isArray(r.bodies) || r.bodies.length === 0) @@ -249,14 +249,24 @@ export function validateRecipe(r: FieldRecipe): RecipeProblem[] { } /** Serialize a recipe to canonical JSON. */ -export function serializeRecipe(r: FieldRecipe): string { +export function serializePattern(r: FieldRecipe): string { return JSON.stringify(r, null, 2); } /** Parse + validate a recipe from JSON; throws on invalid shape. */ export function parseRecipe(json: string): FieldRecipe { const r = JSON.parse(json) as FieldRecipe; - const problems = validateRecipe(r); + const problems = validatePattern(r); if (problems.length) throw new Error(`invalid recipe: ${problems.map((p) => `${p.path} (${p.issue})`).join('; ')}`); return r; } + +// ── Deprecated aliases (recipe → Pattern rename, phase 4; removed at 1.0) ────────────────────── +/** @deprecated Renamed to {@link BodyPattern}. */ export type BodyRecipe = BodyPattern; +/** @deprecated Renamed to {@link RelationshipPattern}. */ export type RelationshipRecipe = RelationshipPattern; +/** @deprecated Renamed to {@link AccessibilityPattern}. */ export type AccessibilityRecipe = AccessibilityPattern; +/** @deprecated Renamed to {@link PatternTier}. */ export type RecipeTier = PatternTier; +/** @deprecated Renamed to {@link PatternStatus}. */ export type RecipeStatus = PatternStatus; +/** @deprecated Renamed to {@link PatternProblem}. */ export type RecipeProblem = PatternProblem; +/** @deprecated Renamed to {@link validatePattern}. */ export const validateRecipe = validatePattern; +/** @deprecated Renamed to {@link serializePattern}. */ export const serializeRecipe = serializePattern; diff --git a/packages/core/src/recipes/wayfinding.test.ts b/packages/core/src/recipes/wayfinding.test.ts index 668a69a0..1011afb2 100644 --- a/packages/core/src/recipes/wayfinding.test.ts +++ b/packages/core/src/recipes/wayfinding.test.ts @@ -6,7 +6,7 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { validateRecipe, serializeRecipe, parseRecipe, primitivesOf, FIELD_MODES } from './schema.ts'; +import { validatePattern, serializePattern, parseRecipe, primitivesOf, FIELD_MODES } from './schema.ts'; import { WAYFINDING_FIELD, WAYFINDING_CURRENT, WAYFINDING_RECIPES, EXPERIMENTAL_RECIPES } from './wayfinding.ts'; import { FIELD_RECIPES } from './gallery.ts'; import { compileRecipe, metricVar } from './compile.ts'; @@ -14,7 +14,7 @@ import { passportFor } from '../contracts/passport.ts'; test('both wayfinding variations validate (real tokens, known layers + diagnostics, matching primitives)', () => { for (const r of WAYFINDING_RECIPES) { - const problems = validateRecipe(r); + const problems = validatePattern(r); assert.deepEqual(problems, [], `${r.name}: ${problems.map((p) => `${p.path} ${p.issue}`).join(', ')}`); assert.deepEqual(r.primitives, primitivesOf(r.bodies), `${r.name}: primitives match body tokens`); } @@ -60,5 +60,5 @@ test('both compile to a runtime plan with metric feedback and a reduced-motion o }); test('both round-trip through JSON', () => { - for (const r of WAYFINDING_RECIPES) assert.deepEqual(parseRecipe(serializeRecipe(r)), r); + for (const r of WAYFINDING_RECIPES) assert.deepEqual(parseRecipe(serializePattern(r)), r); }); diff --git a/packages/core/src/recipes/wayfinding.ts b/packages/core/src/recipes/wayfinding.ts index 2f264447..bd97bec7 100644 --- a/packages/core/src/recipes/wayfinding.ts +++ b/packages/core/src/recipes/wayfinding.ts @@ -11,7 +11,7 @@ * • Wayfinding Field (gravity) — "where am I, what's adjacent" (present-tense orientation). * • Wayfinding Current (electromagnetic) — "where have I been" (the path travelled, the current from home). * - * Both pass `validateRecipe` (real passported tokens, known render layers + diagnostics, primitives = + * Both pass `validatePattern` (real passported tokens, known render layers + diagnostics, primitives = * body tokens, a fundamental field, a reduced-motion equivalent). They are kept OUT of the canonical * 64-recipe `FIELD_RECIPES` catalog (a locked 4×16 of shipped recipes, cited as such across the * papers) and marked `experimental` — they surface on the /recipes hub as a labelled experimental diff --git a/packages/dom/src/apply-recipe.test.ts b/packages/dom/src/apply-recipe.test.ts index 33257451..f031d9ad 100644 --- a/packages/dom/src/apply-recipe.test.ts +++ b/packages/dom/src/apply-recipe.test.ts @@ -7,7 +7,7 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { recipeById } from '@fundamental-engine/core'; +import { patternById } from '@fundamental-engine/core'; import { applyRecipe } from './apply-recipe.ts'; interface FakeEl extends Element { @@ -46,7 +46,7 @@ const fakeRoot = (): Element => ({ querySelectorAll: () => [] as unknown as Node const VP = { width: 1000, height: 1000 }; test('no scoped-field options → the input recipe is used as-is (existing call shape unchanged)', () => { - const base = recipeById('evidence-field')!; + const base = patternById('evidence-field')!; const el = fakeEl({ x: 450, y: 450, w: 100, h: 100 }); const applied = applyRecipe(fakeRoot(), base, { bodies: [el], annotateBodies: false, drive: false, reducedMotion: false }); assert.equal(applied.recipe, base, 'no derived copy when no option asks for one'); @@ -54,7 +54,7 @@ test('no scoped-field options → the input recipe is used as-is (existing call }); test('renderless strips render for the applied run; the catalog recipe object is untouched', () => { - const base = recipeById('evidence-field')!; + const base = patternById('evidence-field')!; const renderBefore = base.render; const renderSnapshot = [...base.render]; const el = fakeEl({ x: 450, y: 450, w: 100, h: 100 }); @@ -78,7 +78,7 @@ test('renderless strips render for the applied run; the catalog recipe object is }); test('extraMetrics appends + dedupes, never mutates the input, and the metric actually flows', () => { - const base = recipeById('evidence-field')!; + const base = patternById('evidence-field')!; const metricsBefore = base.metrics; const metricsSnapshot = [...base.metrics]; // ['coherence', 'entropy'] // centered in the 1000×1000 viewport → proximity 1, fully visible → attention computes to 1 @@ -117,7 +117,7 @@ test('a supplied field target is DRIVEN by the compiled render plan, and destroy setOverlay: (m: string | string[]) => void calls.push(`overlay:${Array.isArray(m) ? m.join('+') : m}`), setHeatmap: (on: boolean) => void calls.push(`heatmap:${on}`), }; - const base = recipeById('contour-charge')!; // render: ['particles', 'heatmap'] → dots + heatmap + const base = patternById('contour-charge')!; // render: ['particles', 'heatmap'] → dots + heatmap const el = fakeEl({ x: 450, y: 450, w: 100, h: 100 }); const applied = applyRecipe(fakeRoot(), base, { bodies: [el], annotateBodies: false, drive: false, reducedMotion: false, field }); assert.deepEqual(calls, ['render:dots', 'overlay:off', 'heatmap:true'], 'the plan executed on apply'); @@ -129,7 +129,7 @@ test('a supplied field target is DRIVEN by the compiled render plan, and destroy test('renderless keeps a supplied field untouched — the guard also covers reduced motion (#370)', () => { const calls: string[] = []; const field = { setRender: () => void calls.push('x'), setOverlay: () => void calls.push('x'), setHeatmap: () => void calls.push('x') }; - const base = recipeById('contour-charge')!; + const base = patternById('contour-charge')!; const el = fakeEl({ x: 450, y: 450, w: 100, h: 100 }); applyRecipe(fakeRoot(), base, { bodies: [el], annotateBodies: false, drive: false, reducedMotion: false, field, renderless: true }).destroy(); assert.deepEqual(calls, [], 'renderless never drives the field (reduced motion shares the same single guard)'); @@ -139,7 +139,7 @@ test('density metric does not clobber --field-density written by the particle en // `priority-well` declares metrics: ['density', 'attention', 'priority']. // The recipe pipeline must NOT write --field-density = 0 when data-field-density is absent — // that would overwrite the engine's own gathered-density write from feedback-sink. - const base = recipeById('priority-well')!; + const base = patternById('priority-well')!; assert.ok(base.metrics.includes('density'), 'precondition: recipe declares density metric'); const el = fakeEl({ x: 450, y: 450, w: 100, h: 100 }); diff --git a/packages/dom/src/apply-recipe.ts b/packages/dom/src/apply-recipe.ts index 3979cda3..7b5a9f9a 100644 --- a/packages/dom/src/apply-recipe.ts +++ b/packages/dom/src/apply-recipe.ts @@ -13,7 +13,7 @@ import { lintPlatform } from './lint.ts'; import { prefersReducedMotion } from './env.ts'; import { computeMetrics, groundedRecency, METRIC_KINDS, type MetricKind } from './metrics.ts'; import { - validateRecipe, + validatePattern, compileRecipe, type FieldRecipe, type CompiledRecipe, @@ -48,7 +48,7 @@ export interface ApplyPatternOptions { * toggle — and `destroy()` resets the surfaces it drove (dots / off / false). Omitted → the * recipe stays signals-only, exactly as before (fully additive). */ - field?: RecipeFieldTarget; + field?: PatternFieldTarget; /** * Extra metric lanes appended to the recipe's `metrics` (deduped, original order preserved) — * e.g. `['attention', 'recency']`. Each appended metric gains the standard feedback binding @@ -109,14 +109,14 @@ const elementKey = (el: Element, i: number): string => el.id || `${el.tagName.to * relationships, installs the reduced-motion output, and returns a destroyable, inspectable handle. */ /** The slice of a live field a recipe can drive — FieldHandle and both fit. */ -export interface RecipeFieldTarget { +export interface PatternFieldTarget { setRender?(mode: string): void; setOverlay?(mode: string | string[]): void; setHeatmap?(on: boolean): void; } /** Execute a compiled render plan on a field target. Exported for tests and custom hosts. */ -export function driveRenderPlan(field: RecipeFieldTarget, plan: { underlay: string | null; overlay: string[]; heatmap: boolean }): void { +export function driveRenderPlan(field: PatternFieldTarget, plan: { underlay: string | null; overlay: string[]; heatmap: boolean }): void { if (plan.underlay && field.setRender) field.setRender(plan.underlay); if (field.setOverlay) field.setOverlay(plan.overlay.length ? plan.overlay : 'off'); if (field.setHeatmap) field.setHeatmap(plan.heatmap); @@ -138,7 +138,7 @@ export function applyPattern(root: Element, recipe: FieldRecipe, options: ApplyP }; } - const problems = validateRecipe(recipe); + const problems = validatePattern(recipe); if (problems.length) throw new Error(`applyRecipe: invalid recipe "${recipe.id}": ${problems.map((p) => `${p.path} (${p.issue})`).join('; ')}`); const compiled = compileRecipe(recipe); @@ -146,7 +146,7 @@ export function applyPattern(root: Element, recipe: FieldRecipe, options: ApplyP // the execution half of recipe.render (#370): drive the supplied field with the compiled plan. // Reduced motion skips the drive entirely — the recipe's static plan is the equivalent, and a // field left at its resting surfaces (dots / off) IS the static reading. - let droveField: RecipeFieldTarget | null = null; + let droveField: PatternFieldTarget | null = null; const reducedMotion = options.reducedMotion ?? prefersReducedMotion(); const platform = createFieldPlatform(root); @@ -372,3 +372,6 @@ export function destroyRecipe(applied: AppliedRecipe): void { function escapeHtml(s: string): string { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); } + +/** @deprecated Renamed to {@link PatternFieldTarget} (recipe → Pattern rename); removed at 1.0. */ +export type RecipeFieldTarget = PatternFieldTarget; diff --git a/packages/dom/src/bind-data.ts b/packages/dom/src/bind-data.ts index cacadcf3..b5810b6e 100644 --- a/packages/dom/src/bind-data.ts +++ b/packages/dom/src/bind-data.ts @@ -1,18 +1,18 @@ /** * bindData — make real application data participate in field behavior. Records become bodies, mapped * metrics become state, mapped relationships become graph edges, and a recipe supplies the behavior - * (metric/feedback framework) via applyRecipe(). Updates are deterministic (diff by id); removed + * (metric/feedback framework) via applyPattern(). Updates are deterministic (diff by id); removed * records decay before they leave rather than popping. * - * const binding = bindData(container, records, mapper, { recipe: 'search-relevance-field' }); + * const binding = bindData(container, records, mapper, { pattern: 'search-relevance-field' }); * binding.update(nextRecords); * binding.destroy(); * * The recipe frames the field (which metrics → --field-* are tracked); the per-record mapper owns the * body tokens, metric values, and relationships — so the data drives the field, not a mock. */ -import { recipeById, type FieldRecipe } from '@fundamental-engine/core'; -import { applyRecipe, type AppliedRecipe } from './apply-recipe.ts'; +import { patternById, type FieldPattern } from '@fundamental-engine/core'; +import { applyPattern, type AppliedPattern } from './apply-recipe.ts'; export interface MappedBody { tokens: string[]; @@ -37,15 +37,17 @@ export interface MappedRecord { export type RecordMapper = (record: T, index: number) => MappedRecord; export interface BindDataOptions { - /** the recipe whose metric/feedback framework drives the bound bodies (id or object). */ - recipe?: string | FieldRecipe; + /** the Pattern whose metric/feedback framework drives the bound bodies (id or object). */ + pattern?: string | FieldPattern; + /** @deprecated Renamed to {@link BindDataOptions.pattern} (recipe → Pattern); read as a fallback, removed at 1.0. */ + recipe?: string | FieldPattern; /** decay duration (ms) before a removed record leaves the DOM (default 400). */ decayMs?: number; /** element tag for each record (default 'div'). */ tag?: string; /** class added to each record element. */ className?: string; - /** install the recipe's reduced-motion output instead of motion (passed to applyRecipe). */ + /** install the recipe's reduced-motion output instead of motion (passed to applyPattern). */ reducedMotion?: boolean; /** custom inner HTML per record (domain markup); overrides the default label. Relationship anchors are kept. */ content?: (record: T, mapped: MappedRecord) => string; @@ -60,7 +62,7 @@ export interface DataBinding { container: HTMLElement; update(records: T[]): void; ids(): string[]; - applied(): AppliedRecipe | null; + applied(): AppliedPattern | null; inspect(): DataBindingInspection | null; destroy(): void; } @@ -91,7 +93,7 @@ function applyMapped(el: HTMLElement, m: MappedRecord, contentHtml?: string): vo setNum(el, 'data-spin', m.body.spin); setNum(el, 'data-angle', m.body.angle); if (m.body.feedback) el.setAttribute('data-feedback', ''); - // metric values → data-field- (the recipe + applyRecipe turn these into --field-* state) + // metric values → data-field- (the recipe + applyPattern turn these into --field-* state) for (const [k, v] of Object.entries(m.metrics ?? {})) setNum(el, `data-field-${k}`, v); // domain content (overrides label), else the plain label if (contentHtml != null) { @@ -131,15 +133,15 @@ export function bindData(container: HTMLElement, records: T[], mapper: Record const tag = options.tag ?? 'div'; const doc = container.ownerDocument; const els = new Map(); - let applied: AppliedRecipe | null = null; - const recipeArg = options.recipe; + let applied: AppliedPattern | null = null; + const patternArg = options.pattern ?? options.recipe; const reapply = (): void => { applied?.destroy(); applied = null; const items = [...els.values()].filter((e) => !('bdExiting' in e.dataset)); - const recipe = typeof recipeArg === 'string' ? recipeById(recipeArg) : recipeArg; - if (recipe && items.length) applied = applyRecipe(container, recipe, { bodies: items, annotateBodies: false, reducedMotion: options.reducedMotion }); + const recipe = typeof patternArg === 'string' ? patternById(patternArg) : patternArg; + if (recipe && items.length) applied = applyPattern(container, recipe, { bodies: items, annotateBodies: false, reducedMotion: options.reducedMotion }); }; const render = (recs: T[]): void => { diff --git a/packages/dom/src/field-nav.ts b/packages/dom/src/field-nav.ts index 083fc646..2899ea1c 100644 --- a/packages/dom/src/field-nav.ts +++ b/packages/dom/src/field-nav.ts @@ -12,7 +12,7 @@ * reachable. The caller owns the visit log and the recipe lookup; this helper owns the binding and its * teardown. Unfrozen (experimental) — option names may refine before 1.0. */ -import { recipeById, type FieldRecipe } from '@fundamental-engine/core'; +import { patternById, type FieldRecipe } from '@fundamental-engine/core'; import { applyRecipe } from './apply-recipe.ts'; import { prefersReducedMotion } from './env.ts'; @@ -51,7 +51,7 @@ export function bindFieldNav( opts: FieldNavOptions = {}, ): FieldNavHandle | null { if (opts.reducedMotion ?? prefersReducedMotion()) return null; - const resolved = typeof recipe === 'string' ? recipeById(recipe) : recipe; + const resolved = typeof recipe === 'string' ? patternById(recipe) : recipe; if (!resolved) return null; const links = [...root.querySelectorAll('a[href]')]; if (!links.length) return null;