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.
230 lines
8.5 KiB
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';
|
|
}
|
|
};
|
|
}
|