You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/uix/lib/harness.svelte.ts

230 lines
8.5 KiB

/**
* Demo harness — shared logic + vocabulary for the UIX component demo pages.
*
* The new demo layout exposes the WHOLE system surface, not just
* variant/size/role. This module is the single source for the axes every
* visual component shares (palette · border · shape · depth · density · motion)
* plus the perceptual (sema) firma resolution and the live `data-event` trace.
*
* Pure data + small reactive helpers. The visual panels that consume it live
* in sibling components (`PalettePicker.svelte`, `SystemAxes.svelte`,
* `MotionPanel.svelte`, `SemaPanel.svelte`). See `DEMO_AUTHORING_GUIDE.md`.
*/
import { PALETTE_SCALES, type PaletteScale } from '$uix/eidos/lib/types';
import { resolveSignature, SEMA_MAP, type EffectiveSignature } from '$uix/sema';
export { PALETTE_SCALES };
// ── Color vocabulary ───────────────────────────────────────────────────────
/** Hierarchy promotion levels — the non-evaluative `color` values. */
export const HIERARCHY_ROLES = ['primary', 'secondary', 'tertiary', 'neutral'] as const;
/** The evaluative intent canon (the valenced axis). */
export const INTENTS = ['neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as const;
/**
* The donor scales (all of `PALETTE_SCALES`) grouped for display. The component
* `color` prop accepts any of these when `intent === 'neutral'`
* (docs/theming/reference.md §25.5). Completeness vs the const is asserted
* below — adding a scale to `PALETTE_SCALES` without grouping it here throws
* at module load in dev.
*/
export const PALETTE_GROUPS: { label: string; scales: readonly PaletteScale[] }[] = [
{ label: 'Greys', scales: ['gray', 'mauve', 'slate', 'sage', 'olive', 'sand'] },
{
label: 'Chromatics',
scales: [
'tomato',
'red',
'ruby',
'crimson',
'pink',
'plum',
'fuchsia',
'purple',
'violet',
'iris',
'indigo',
'blue',
'cyan',
'teal',
'jade',
'green',
'grass',
'lime',
'yellow',
'amber',
'orange',
'brown',
'sky',
'mint'
]
},
{ label: 'Metals', scales: ['gold', 'bronze', 'steel'] }
];
if (import.meta.env.DEV) {
const grouped = new Set(PALETTE_GROUPS.flatMap((g) => g.scales));
const missing = PALETTE_SCALES.filter((s) => !grouped.has(s));
if (missing.length > 0) {
throw new Error(`PALETTE_GROUPS is missing scales from PALETTE_SCALES: ${missing.join(', ')}`);
}
}
// ── Border / shape / depth vocabulary ──────────────────────────────────────
/** Foundation border-width scale (`--border-width-*`). */
export const BORDER_WIDTHS = ['none', 'thin', 'medium', 'thick', 'heavy'] as const;
/** Foundation border-style scale (`--border-style-*`). */
export const BORDER_STYLES = ['solid', 'dashed', 'dotted'] as const;
/** Foundation radius magnitude scale (`--radius-*`). */
export const RADIUS_STEPS = ['none', 'sm', 'md', 'lg', 'xl', 'xxl', 'full'] as const;
/** Corner-shape families (`data-shape`, the SHAPE engine). */
export const SHAPE_FAMILIES = ['rounded', 'continuous', 'cut', 'scoop'] as const;
/** Elevation planes (`data-depth`, the DEPTH engine). */
export const DEPTH_PLANES = ['flush', 'raised', 'overlay', 'modal', 'recessed'] as const;
/** Ergonomic density levels (`data-density`). */
export const DENSITIES = ['compact', 'comfortable', 'spacious'] as const;
// ── Motion catalog ─────────────────────────────────────────────────────────
export type MotionKind = 'enter/exit' | 'emphasis' | 'loop' | 'js';
export interface MotionPreset {
name: string;
kind: MotionKind;
blurb: string;
}
/** The built-in `motion` preset catalog (eidos `MOTION_GUIDE.md`). */
export const MOTION_PRESETS: readonly MotionPreset[] = [
{ name: 'fade', kind: 'enter/exit', blurb: 'Opacity only — the reduced-motion-safe default.' },
{ name: 'scale-fade', kind: 'enter/exit', blurb: 'Scale + fade; transform-origin aware (popovers, dialogs).' },
{ name: 'slide-fade', kind: 'enter/exit', blurb: 'Side-aware slide + fade (menus, tooltips).' },
{ name: 'slide-full', kind: 'enter/exit', blurb: 'Full slide by edge — drawers / sheets.' },
{ name: 'collapse', kind: 'enter/exit', blurb: 'Animates a measured height (accordions).' },
{ name: 'shared-axis-x', kind: 'enter/exit', blurb: 'Material directional slide + fade (X).' },
{ name: 'shared-axis-y', kind: 'enter/exit', blurb: 'Material directional slide + fade (Y).' },
{ name: 'fade-through', kind: 'enter/exit', blurb: 'Scale-up + fade for unrelated content swaps.' },
{ name: 'select-pop', kind: 'emphasis', blurb: 'Scale pop, no opacity — a "becoming selected" cue.' },
{ name: 'spin', kind: 'loop', blurb: 'Continuous rotation — loaders.' },
{ name: 'pulse', kind: 'loop', blurb: 'Continuous opacity breathe — skeletons.' },
{ name: 'ping', kind: 'loop', blurb: 'Ring scale + fade — notifications.' },
{ name: 'bounce', kind: 'loop', blurb: 'Continuous translate — attention cues.' },
{ name: 'spring-pop', kind: 'js', blurb: 'Physical bounce via the JS spring driver (uix.motion).' }
];
/** The canonical duration scale (`--duration-*`). */
export const DURATIONS = [
'instant',
'fast',
'normal',
'moderate',
'slow',
'slower',
'deliberate',
'emphatic',
'sustained'
] as const;
/** The canonical easing scale (`--ease-*`). */
export const EASINGS = ['default', 'out', 'in', 'spring', 'alert', 'symmetric', 'emphasized'] as const;
// ── Live `data-event` trace ────────────────────────────────────────────────
export interface TraceEntry {
event: string;
family: string;
intent?: string;
phase?: string;
at: number;
}
/**
* Watches a stage subtree for the `data-event*` attrs the sema visual channel
* stamps during a hold, and keeps a reactive ring of the most recent events.
*
* Lifecycle stays in the component:
*
* const trace = new DemoTrace();
* $effect(() => { if (stageRef) return trace.observe(stageRef); });
*/
export class DemoTrace {
entries = $state<TraceEntry[]>([]);
private clock = 0;
observe(el: HTMLElement): () => void {
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
this.entries = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
phase: target.getAttribute('data-event-phase') ?? undefined,
at: this.clock++
},
...this.entries
].slice(0, 8);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
}
clear(): void {
this.entries = [];
}
}
// ── Sema firma resolution ──────────────────────────────────────────────────
/**
* Resolve the intrinsic perceptual signature of a family (+ optional intent)
* through the canonical SEMA_MAP — base signature + intent deltas. Component
* packs (per-component cascade) add character on top at emit time; this is the
* base firma the Sema panel visualises. Target is irrelevant without a
* cascade, so a detached element is fine.
*/
export function signatureFor(family: string, intent?: string): EffectiveSignature {
return resolveSignature(
{
name: `${family}-preview`,
family: family as never,
target: (typeof document !== 'undefined'
? document.createElement('span')
: ({} as HTMLElement)) as HTMLElement,
...(intent ? { intent: intent as never } : {})
},
SEMA_MAP
);
}
/**
* Imperative `value` setter for `<input type="color">` (Svelte action). A reactive
* `value=` / `bind:value` makes Svelte momentarily set the input to `""` during
* hydration, which trips the browser's "specified value '' does not conform to
* #rrggbb" warning even when the bound value is a valid hex. Setting the value from
* an action side-steps Svelte's managed attribute, so no `""` is ever applied.
* Usage: `<input type="color" use:colorValue={hex} oninput={…} />` (no `value=`).
*/
export function colorValue(node: HTMLInputElement, hex: string) {
node.value = hex || '#000000';
return {
update(next: string) {
node.value = next || '#000000';
}
};
}

Powered by TurnKey Linux.