Tighten sema channel and floating contracts

active-uix
dev 5 months ago
parent 8b195e38e2
commit 802ebce655

@ -35,7 +35,7 @@ import type {
IntentExpectedFamily, IntentExpectedFamily,
IntentOptionalFamily IntentOptionalFamily
} from '../sema/types'; } from '../sema/types';
import type { SemaChannelId, SemaSignatureOverride } from '../sema/sema-map'; import type { SemaChannelId, SemaSignatureOverride } from '../sema/channels';
import type { SemaDurationSpec } from '../sema/durations'; import type { SemaDurationSpec } from '../sema/durations';
// ── HTML element ────────────────────────────────────────────────────────── // ── HTML element ──────────────────────────────────────────────────────────

@ -0,0 +1,102 @@
/**
* Sema channel contract.
*
* This is the single source for runtime channel ids, channel signatures and
* per-channel overrides. `sema-map.ts` owns data; this file owns the open
* channel type surface that both the map and event declarations consume.
*/
export interface SoundSignature {
pitch: number;
centroid: number;
roughness: number;
attack: number;
decay: number;
duration: number;
contour: 'flat' | 'ascending' | 'descending' | 'arc' | 'bell';
gain: number;
sampleUrl?: string;
}
/**
* Haptic / vibration feedback. Categorical `kind` lets future channels map
* to platform haptic primitives (iOS Haptic Engine, Android
* HapticFeedbackConstants) without breaking consumers; V1 of `HapticChannel`
* implements the Vibration API path with intensity-modulated durations.
*/
export interface HapticSignature {
/**
* Categorical pattern. Maps to system primitives where available; falls
* back to a Vibration API pattern derived from `duration` + `intensity`.
*/
kind: 'tick' | 'tap' | 'pulse' | 'thud' | 'success' | 'warning' | 'error';
/** 0..1. Modulates Vibration API duration; ignored on categorical-only platforms. */
intensity: number;
/** Base pulse duration in ms (single Vibration API pulse). */
duration: number;
/** Explicit on/off/on/off… pattern in ms. */
pattern?: readonly number[];
/** Delay relative to signal start (ms). */
delay?: number;
}
/**
* Canonical channel registry. Apps extend it via declaration merging:
*
* declare module '$uix/sema' {
* interface SemaChannelSignatures {
* a11y: A11ySignature;
* voice: VoiceSignature;
* }
* }
*/
export interface SemaChannelSignatures {
sound: SoundSignature;
haptic: HapticSignature;
}
/**
* Open string id of a channel. Known channels autocomplete in editors;
* arbitrary strings are accepted for channels registered with the engine but
* without a typed signature slice.
*/
export type SemaChannelId = keyof SemaChannelSignatures | (string & {});
/**
* Runtime channels are the things the engine actually dispatches to.
* Visual dimensions (`motion`, `color`, `presence`) are not channels.
*/
export type SemaRuntimeChannelId = 'visual' | 'sound' | 'haptic' | (string & {});
export type DeltaOp =
| { op: 'multiply'; factor: number }
| { op: 'replace'; value: number | string | boolean | null | readonly DeltaValue[] }
| { op: 'add'; value: number };
export type DeltaValue =
| number
| string
| boolean
| null
| DeltaOp
| readonly DeltaValue[]
| { [key: string]: DeltaValue };
/**
* Per-channel partial override applied at resolution time.
*/
export type SemaSignatureOverride = {
/**
* Replace the active-channel list for matching signals. Last-write-wins
* across the cascade. Empty array silences the signal entirely.
*/
channels?: readonly SemaChannelId[];
} & {
[K in keyof SemaChannelSignatures]?:
| Partial<SemaChannelSignatures[K]>
| Record<string, DeltaValue>;
};

@ -1,5 +1,5 @@
import type { EffectiveSignature } from '../resolver' import type { EffectiveSignature } from '../resolver'
import type { HapticSignature } from '../sema-map' import type { HapticSignature } from '../channels'
import type { SemanticSignal } from '../signal' import type { SemanticSignal } from '../signal'
import type { Channel } from './types' import type { Channel } from './types'

@ -1,13 +1,17 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { SoundChannel } from './sound'; import { SoundChannel, type SoundChannelDom } from './sound';
function createDocHarness() { function createDomHarness() {
const cleanup = vi.fn();
return { return {
addEventListener: vi.fn(), cleanup,
removeEventListener: vi.fn() dom: {
} satisfies Pick<Document, 'addEventListener' | 'removeEventListener'>; getDocument: vi.fn(() => document),
listen: vi.fn(() => cleanup)
} satisfies SoundChannelDom
};
} }
function createAudioHarness() { function createAudioHarness() {
@ -31,25 +35,25 @@ function createAudioHarness() {
describe('SoundChannel', () => { describe('SoundChannel', () => {
it('does not register global unlock listeners in the constructor', () => { it('does not register global unlock listeners in the constructor', () => {
const doc = createDocHarness(); const { dom } = createDomHarness();
const audio = createAudioHarness(); const audio = createAudioHarness();
const channel = new SoundChannel({ const channel = new SoundChannel({
doc, dom,
audioContextFactory: audio.factory audioContextFactory: audio.factory
}); });
expect(doc.addEventListener).not.toHaveBeenCalled(); expect(dom.listen).not.toHaveBeenCalled();
expect(audio.factory).not.toHaveBeenCalled(); expect(audio.factory).not.toHaveBeenCalled();
channel.dispose(); channel.dispose();
}); });
it('primes the AudioContext synchronously from prepare when sound is allowed', () => { it('primes the AudioContext synchronously from prepare when sound is allowed', () => {
const doc = createDocHarness(); const { cleanup, dom } = createDomHarness();
const audio = createAudioHarness(); const audio = createAudioHarness();
const channel = new SoundChannel({ const channel = new SoundChannel({
doc, dom,
audioContextFactory: audio.factory, audioContextFactory: audio.factory,
masterGain: 0.4 masterGain: 0.4
}); });
@ -64,22 +68,25 @@ describe('SoundChannel', () => {
expect(audio.ctx.createGain).toHaveBeenCalledTimes(1); expect(audio.ctx.createGain).toHaveBeenCalledTimes(1);
expect(audio.gainNode.gain.value).toBe(0.4); expect(audio.gainNode.gain.value).toBe(0.4);
expect(audio.resume).toHaveBeenCalledTimes(1); expect(audio.resume).toHaveBeenCalledTimes(1);
expect(doc.addEventListener).toHaveBeenCalledTimes(3); expect(dom.getDocument).toHaveBeenCalledTimes(1);
expect(doc.addEventListener).toHaveBeenCalledWith('click', expect.any(Function), true); expect(dom.listen).toHaveBeenCalledWith(
expect(doc.addEventListener).toHaveBeenCalledWith('touchstart', expect.any(Function), true); document,
expect(doc.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), true); ['click', 'touchstart', 'keydown'],
expect.any(Function),
true
);
channel.dispose(); channel.dispose();
expect(doc.removeEventListener).toHaveBeenCalledTimes(3); expect(cleanup).toHaveBeenCalledTimes(1);
expect(audio.close).toHaveBeenCalledTimes(1); expect(audio.close).toHaveBeenCalledTimes(1);
}); });
it('does not prime the AudioContext for explicit non-sound channel lists', () => { it('does not prime the AudioContext for explicit non-sound channel lists', () => {
const doc = createDocHarness(); const { dom } = createDomHarness();
const audio = createAudioHarness(); const audio = createAudioHarness();
const channel = new SoundChannel({ const channel = new SoundChannel({
doc, dom,
audioContextFactory: audio.factory audioContextFactory: audio.factory
}); });
@ -90,7 +97,7 @@ describe('SoundChannel', () => {
}); });
expect(audio.factory).not.toHaveBeenCalled(); expect(audio.factory).not.toHaveBeenCalled();
expect(doc.addEventListener).not.toHaveBeenCalled(); expect(dom.listen).not.toHaveBeenCalled();
channel.dispose(); channel.dispose();
}); });

@ -30,13 +30,23 @@ import type { Logger } from '$libs/logger';
type AudioContextCtor = new () => AudioContext; type AudioContextCtor = new () => AudioContext;
export interface SoundChannelDom {
getDocument(node?: Element | Window | Node | Document | null): Document;
listen(
target: EventTarget,
event: string | readonly string[],
handler: EventListener,
options?: boolean | AddEventListenerOptions
): () => void;
}
export interface SoundChannelOptions { export interface SoundChannelOptions {
/** Override the AudioContext factory (tests / non-browser environments). */ /** Override the AudioContext factory (tests / non-browser environments). */
audioContextFactory?: () => AudioContext | null; audioContextFactory?: () => AudioContext | null;
/** Override the global fetch (for sample loading in tests). */ /** Override the global fetch (for sample loading in tests). */
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
/** Override document for unlock listener registration (tests). */ /** DOM service used for global unlock listener registration. */
doc?: Pick<Document, 'addEventListener' | 'removeEventListener'>; dom?: SoundChannelDom;
/** Master gain multiplier applied on top of every signature's `gain`. Default 1. */ /** Master gain multiplier applied on top of every signature's `gain`. Default 1. */
masterGain?: number; masterGain?: number;
/** Optional diagnostics logger. When omitted, audio failures stay silent. */ /** Optional diagnostics logger. When omitted, audio failures stay silent. */
@ -59,7 +69,7 @@ export class SoundChannel implements Channel {
private readonly sampleCache = new Map<string, AudioBuffer>(); private readonly sampleCache = new Map<string, AudioBuffer>();
private readonly fetchFn?: typeof fetch; private readonly fetchFn?: typeof fetch;
private readonly audioContextFactory?: () => AudioContext | null; private readonly audioContextFactory?: () => AudioContext | null;
private readonly doc?: Pick<Document, 'addEventListener' | 'removeEventListener'>; private readonly dom?: SoundChannelDom;
private readonly masterGainValue: number; private readonly masterGainValue: number;
private readonly logger: Logger | undefined; private readonly logger: Logger | undefined;
private teardownUnlock?: () => void; private teardownUnlock?: () => void;
@ -68,7 +78,7 @@ export class SoundChannel implements Channel {
this.fetchFn = this.fetchFn =
opts.fetchFn ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined); opts.fetchFn ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined);
this.audioContextFactory = opts.audioContextFactory; this.audioContextFactory = opts.audioContextFactory;
this.doc = opts.doc ?? (typeof document !== 'undefined' ? document : undefined); this.dom = opts.dom;
this.masterGainValue = opts.masterGain ?? 1; this.masterGainValue = opts.masterGain ?? 1;
this.logger = opts.logger; this.logger = opts.logger;
} }
@ -199,22 +209,14 @@ export class SoundChannel implements Channel {
} }
private setupUnlockListener(): void { private setupUnlockListener(): void {
if (!this.doc || this.teardownUnlock) return; if (!this.dom || this.teardownUnlock) return;
const events = ['click', 'touchstart', 'keydown'] as const; const events = ['click', 'touchstart', 'keydown'] as const;
const unlock = () => { const unlock = () => {
this.audioCtx?.resume().catch(() => {}); this.audioCtx?.resume().catch(() => {});
}; };
for (const eventName of events) { this.teardownUnlock = this.dom.listen(this.dom.getDocument(), events, unlock, true);
this.doc.addEventListener(eventName, unlock, true);
}
this.teardownUnlock = () => {
for (const eventName of events) {
this.doc?.removeEventListener(eventName, unlock, true);
}
};
} }
private async synthesize(ctx: AudioContext, sig: SoundSignature): Promise<void> { private async synthesize(ctx: AudioContext, sig: SoundSignature): Promise<void> {

@ -26,11 +26,12 @@ import type { Channel, ChannelPreparation } from './chans/types';
import type { DomApplier } from '$libs/dom'; import type { DomApplier } from '$libs/dom';
import type { Logger } from '$libs/logger'; import type { Logger } from '$libs/logger';
import { HapticChannel, type HapticChannelOptions } from './chans/haptic'; import { HapticChannel, type HapticChannelOptions } from './chans/haptic';
import { SoundChannel, type SoundChannelOptions } from './chans/sound'; import { SoundChannel, type SoundChannelDom, type SoundChannelOptions } from './chans/sound';
import { VisualChannel, type VisualChannelOptions } from './chans/visual'; import { VisualChannel, type VisualChannelOptions } from './chans/visual';
import { SemaDuplicateChannelError } from './errors'; import { SemaDuplicateChannelError } from './errors';
import { applyMapOverrides, resolveSignature, type SemaCascadeRule } from './resolver'; import { applyMapOverrides, resolveSignature, type SemaCascadeRule } from './resolver';
import { SEMA_MAP, type DeltaValue, type SemaMap, type Sema } from './sema-map'; import { SEMA_MAP, type SemaMap, type Sema } from './sema-map';
import type { DeltaValue } from './channels';
import type { SemanticSignal } from './signal'; import type { SemanticSignal } from './signal';
import type { SignalProjector } from './projection'; import type { SignalProjector } from './projection';
@ -81,7 +82,7 @@ export interface EngineSemanticOptions {
* In `active-uix` this is `uix.dom`, so sema projects event attrs through * In `active-uix` this is `uix.dom`, so sema projects event attrs through
* the same DOM owner as soma. * the same DOM owner as soma.
*/ */
dom?: DomApplier; dom?: DomApplier | (DomApplier & SoundChannelDom);
} }
export class EngineSemantic { export class EngineSemantic {
@ -125,9 +126,17 @@ export class EngineSemantic {
} }
if (opts.sound !== undefined && opts.sound !== false) { if (opts.sound !== undefined && opts.sound !== false) {
const soundChannel: Channel = isChannel(opts.sound) let soundChannel: Channel;
? opts.sound if (isChannel(opts.sound)) {
: new SoundChannel({ ...(opts.sound === true ? {} : opts.sound), logger: opts.logger }); soundChannel = opts.sound;
} else {
const soundOptions = opts.sound === true ? {} : opts.sound;
soundChannel = new SoundChannel({
...soundOptions,
dom: soundOptions.dom ?? (isSoundChannelDom(opts.dom) ? opts.dom : undefined),
logger: opts.logger
});
}
this.register(soundChannel); this.register(soundChannel);
// Pre-decode the WAVs declared by component packs so the first // Pre-decode the WAVs declared by component packs so the first
@ -246,3 +255,12 @@ function isChannel(value: unknown): value is Channel {
typeof (value as Channel).handle === 'function' typeof (value as Channel).handle === 'function'
); );
} }
function isSoundChannelDom(value: unknown): value is SoundChannelDom {
return (
value !== null &&
typeof value === 'object' &&
typeof (value as SoundChannelDom).getDocument === 'function' &&
typeof (value as SoundChannelDom).listen === 'function'
);
}

@ -64,21 +64,23 @@ export {
resolveSignature, resolveSignature,
applyMapOverrides, applyMapOverrides,
type EffectiveSignature, type EffectiveSignature,
type HapticSignature,
type SemaCascadeRule, type SemaCascadeRule,
type SemaChannelId, type SemaResolveOptions
type SemaChannelSignatures,
type SemaResolveOptions,
type SemaSignatureOverride,
type SoundSignature
} from './resolver'; } from './resolver';
export type {
HapticSignature,
SemaChannelId,
SemaChannelSignatures,
SemaSignatureOverride,
SemaRuntimeChannelId,
SoundSignature
} from './channels';
export { export {
SEMA_MAP, SEMA_MAP,
type SemaMap, type SemaMap,
type FamilyMapEntry, type FamilyMapEntry,
type IntentMapEntry, type IntentMapEntry,
type Sema, type Sema
type SemaRuntimeChannelId
} from './sema-map'; } from './sema-map';
export { stampEventAttrs, unstampEventAttrs } from './stamp'; export { stampEventAttrs, unstampEventAttrs } from './stamp';

@ -34,18 +34,16 @@ import type { SemanticSignal } from './signal';
import type { Intent } from '../intent'; import type { Intent } from '../intent';
import type { SemaFamily } from './types'; import type { SemaFamily } from './types';
import { resolveSemaDuration } from './durations'; import { resolveSemaDuration } from './durations';
import { import { SEMA_MAP, SEMA_VALENCED_FAMILY_LIST, type SemaMap } from './sema-map';
SEMA_MAP, import type {
SEMA_VALENCED_FAMILY_LIST, DeltaOp,
type DeltaOp, DeltaValue,
type DeltaValue, HapticSignature,
type HapticSignature, SemaChannelId,
type SemaChannelId, SemaChannelSignatures,
type SemaChannelSignatures, SemaSignatureOverride,
type SemaMap, SoundSignature
type SemaSignatureOverride, } from './channels';
type SoundSignature
} from './sema-map';
export type { export type {
HapticSignature, HapticSignature,
@ -53,7 +51,7 @@ export type {
SemaChannelSignatures, SemaChannelSignatures,
SemaSignatureOverride, SemaSignatureOverride,
SoundSignature SoundSignature
} from './sema-map'; } from './channels';
/** /**
* Single cascade rule. Identical shape to a CSS rule — a selector that * Single cascade rule. Identical shape to a CSS rule — a selector that

@ -22,126 +22,15 @@
import type { Intent } from '../intent'; import type { Intent } from '../intent';
import type { SemaFamily, SemaValencedFamily } from './types'; import type { SemaFamily, SemaValencedFamily } from './types';
import type { SemaDurationSpec } from './durations'; import type { SemaDurationSpec } from './durations';
import type {
// ── Channel signature types ──────────────────────────────────────────────── DeltaValue,
SemaChannelId,
export interface SoundSignature { SemaChannelSignatures,
pitch: number; SemaSignatureOverride
centroid: number; } from './channels';
roughness: number;
attack: number;
decay: number;
duration: number;
contour: 'flat' | 'ascending' | 'descending' | 'arc' | 'bell';
gain: number;
sampleUrl?: string;
}
/**
* Haptic / vibration feedback. Categorical `kind` lets future channels map
* to platform haptic primitives (iOS Haptic Engine, Android
* HapticFeedbackConstants) without breaking consumers; V1 of `HapticChannel`
* implements the Vibration API path with intensity-modulated durations.
*/
export interface HapticSignature {
/**
* Categorical pattern. Maps to system primitives where available; falls
* back to a Vibration API pattern derived from `duration` + `intensity`.
*
* tick — micro acknowledgement (drag step, focus crossing).
* tap — discrete decision confirmation (commit).
* pulse — alert / call to attention (signal).
* thud — heavy descending feedback (loss).
* success — positive confirmation (affirm / fulfill intents).
* warning — moderate negative consequence (risk intent).
* error — strong negative consequence (threat intent).
*/
kind: 'tick' | 'tap' | 'pulse' | 'thud' | 'success' | 'warning' | 'error';
/** 0..1. Modulates Vibration API duration; ignored on categorical-only platforms. */
intensity: number;
/** Base pulse duration in ms (single Vibration API pulse). */
duration: number;
/**
* Explicit on/off/on/off… pattern in ms. Wins over `kind` + `duration`
* when present; passed straight to `navigator.vibrate(pattern)`.
*/
pattern?: readonly number[];
/**
* Delay relative to signal start (ms). Anchors the haptic to the
* perceptually correct moment of the signal — `contact` fires at 0
* (synchronous to the press), `commit` after the visual settles
* (~60ms), `signal` immediately for alerts.
*/
delay?: number;
}
// ── Channel registry (open via declaration merging) ────────────────────────
/**
* Canonical channel registry. Each key is a channel id; each value is the
* per-channel signature shape. Apps extend it via declaration merging:
*
* declare module '$uix/sema' {
* interface SemaChannelSignatures {
* a11y: A11ySignature;
* voice: VoiceSignature;
* }
* }
*
* Channels without a typed signature (pure side-effect, no slice on the
* `EffectiveSignature`) just register a `Channel` with the engine — they
* do not need an entry here.
*/
export interface SemaChannelSignatures {
sound: SoundSignature;
haptic: HapticSignature;
}
/**
* Open string id of a channel. Known channels (keys of
* `SemaChannelSignatures`) autocomplete in editors; arbitrary strings are
* accepted for channels registered with the engine but without a typed
* signature slice. The `(string & {})` trick prevents TS from collapsing
* the union to plain `string` and losing autocomplete.
*/
export type SemaChannelId = keyof SemaChannelSignatures | (string & {});
/**
* Phase 6 of the codex refactor (`refactorizacion_codex.md`):
* `SemaRuntimeChannelId` is the FORWARD-LOOKING type for `channels`
* lists. It enumerates the channels that the engine actually dispatches
* to at runtime — `'visual'` (DOM projection + hold), `'sound'`, `'haptic'`,
* plus future channels (`'a11y'`, `'voice'`) added via declaration merging.
*
* Visual dimensions (`motion`, `color`, `presence`) are not channels.
* They live in Eidos; Sema dispatches real runtime channels only.
*
* Use this type when adding new code that lists CHANNELS (the things
* the engine dispatches to), not when listing dimensions of an
* EffectiveSignature.
*/
export type SemaRuntimeChannelId = 'visual' | 'sound' | 'haptic' | (string & {});
// ── Map shape ────────────────────────────────────────────────────────────── // ── Map shape ──────────────────────────────────────────────────────────────
export type DeltaOp =
| { op: 'multiply'; factor: number }
| { op: 'replace'; value: number | string | boolean | null | readonly DeltaValue[] }
| { op: 'add'; value: number };
export type DeltaValue =
| number
| string
| boolean
| null
| DeltaOp
| readonly DeltaValue[]
| { [key: string]: DeltaValue };
export interface FamilyMapEntry { export interface FamilyMapEntry {
/** Per-channel base signatures. Channels not present default to undefined. */ /** Per-channel base signatures. Channels not present default to undefined. */
base: Partial<SemaChannelSignatures>; base: Partial<SemaChannelSignatures>;
@ -168,29 +57,6 @@ export interface IntentMapEntry {
deltas: Partial<Record<SemaChannelId, Record<string, DeltaValue>>>; deltas: Partial<Record<SemaChannelId, Record<string, DeltaValue>>>;
} }
/**
* Per-channel partial override applied at resolution time. Used by:
* - layer 4: per-event morfo overrides (in `SemaEvent.overrides`)
* - layer 6: cascade rule overrides (`SemaCascadeRule.overrides[eventLabel]`)
*
* Numeric leaf values add by default; `{ op: 'replace' | 'multiply' | 'add' }`
* for explicit semantics; arrays replace atomically. Apps that augment
* `SemaChannelSignatures` automatically gain typed keys here.
*/
export type SemaSignatureOverride = {
/**
* Replace the active-channel list for matching signals. Last-write-wins
* across the cascade — the rule applied LAST decides. Empty array
* silences the signal entirely (no channel activates). Omit to leave
* `activeChannels` untouched.
*/
channels?: readonly SemaChannelId[];
} & {
[K in keyof SemaChannelSignatures]?:
| Partial<SemaChannelSignatures[K]>
| Record<string, DeltaValue>;
};
/** /**
* Per-component sema declaration — sibling to `Morfo`. Lives in * Per-component sema declaration — sibling to `Morfo`. Lives in
* `src/uix/sema/components/{name}.ts` alongside the component's morfo, * `src/uix/sema/components/{name}.ts` alongside the component's morfo,

@ -8,7 +8,7 @@
* propia modalidad. * propia modalidad.
*/ */
import type { SemaChannelId, SemaSignatureOverride } from './sema-map' import type { SemaChannelId, SemaSignatureOverride } from './channels'
import type { Intent } from '../intent' import type { Intent } from '../intent'
import type { SemaFamily } from './types' import type { SemaFamily } from './types'

@ -8,7 +8,7 @@
import type { Intent } from '../intent'; import type { Intent } from '../intent';
import type { PartRef } from '../types'; import type { PartRef } from '../types';
import type { SemaChannelId, SemaSignatureOverride } from './sema-map'; import type { SemaChannelId, SemaSignatureOverride } from './channels';
// ── Core domain ──────────────────────────────────────────────────────────── // ── Core domain ────────────────────────────────────────────────────────────

@ -23,20 +23,7 @@ import { ElementSize, watch } from 'runed';
import { useFloating } from './use-floating.svelte'; import { useFloating } from './use-floating.svelte';
import type { Measurable, UseFloatingReturn } from './types'; import type { Measurable, UseFloatingReturn } from './types';
import { OPPOSITE_SIDE, type Align, type Boundary, type Side } from './placement';
export const SIDE_OPTIONS = ['top', 'right', 'bottom', 'left'] as const;
export const ALIGN_OPTIONS = ['start', 'center', 'end'] as const;
const OPPOSITE_SIDE: Record<Side, Side> = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
};
export type Side = (typeof SIDE_OPTIONS)[number];
export type Align = (typeof ALIGN_OPTIONS)[number];
export type Boundary = Element | null;
// ── FloatingProvider ───────────────────────────────────────────────────────── // ── FloatingProvider ─────────────────────────────────────────────────────────

@ -5,12 +5,16 @@ export {
FloatingArrow, FloatingArrow,
FloatingAnchor, FloatingAnchor,
type FloatingContentOpts, type FloatingContentOpts,
type Side,
type Align,
type Boundary,
getSideFromPlacement, getSideFromPlacement,
getAlignFromPlacement getAlignFromPlacement
} from './floating.svelte'; } from './floating.svelte';
export {
SIDE_OPTIONS,
ALIGN_OPTIONS,
type Side,
type Align,
type Boundary
} from './placement';
// ── Floating engine ────────────────────────────────────────────────────────── // ── Floating engine ──────────────────────────────────────────────────────────
export type { export type {

@ -0,0 +1,13 @@
export const SIDE_OPTIONS = ['top', 'right', 'bottom', 'left'] as const;
export const ALIGN_OPTIONS = ['start', 'center', 'end'] as const;
export type Side = (typeof SIDE_OPTIONS)[number];
export type Align = (typeof ALIGN_OPTIONS)[number];
export type Boundary = Element | null;
export const OPPOSITE_SIDE: Record<Side, Side> = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
};

@ -12,7 +12,7 @@ import type {
} from '$libs/reactive'; } from '$libs/reactive';
import type { Arrayable, Direction, StyleProperties } from '$soma/types'; import type { Arrayable, Direction, StyleProperties } from '$soma/types';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { Align, Boundary, Side } from './floating.svelte.js'; import type { Align, Boundary, Side } from './placement';
// ─── Shared ─────────────────────────────────────────────────────────────────── // ─── Shared ───────────────────────────────────────────────────────────────────

Loading…
Cancel
Save

Powered by TurnKey Linux.