From 802ebce6557b4297aae5c4aae7178fd627120691 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 20:49:59 +0200 Subject: [PATCH] Tighten sema channel and floating contracts --- src/uix/morfo/types.ts | 2 +- src/uix/sema/channels.ts | 102 ++++++++++++ src/uix/sema/chans/haptic.ts | 2 +- src/uix/sema/chans/sound.test.ts | 43 +++--- src/uix/sema/chans/sound.ts | 30 ++-- src/uix/sema/engine.ts | 30 +++- src/uix/sema/exports.ts | 18 ++- src/uix/sema/resolver.ts | 24 ++- src/uix/sema/sema-map.ts | 146 +----------------- src/uix/sema/signal.ts | 2 +- src/uix/sema/types.ts | 2 +- .../soma/layers/floating/floating.svelte.ts | 15 +- src/uix/soma/layers/floating/index.ts | 10 +- src/uix/soma/layers/floating/placement.ts | 13 ++ src/uix/soma/layers/floating/types.ts | 2 +- 15 files changed, 220 insertions(+), 221 deletions(-) create mode 100644 src/uix/sema/channels.ts create mode 100644 src/uix/soma/layers/floating/placement.ts diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index 0092bec72..362316b38 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -35,7 +35,7 @@ import type { IntentExpectedFamily, IntentOptionalFamily } from '../sema/types'; -import type { SemaChannelId, SemaSignatureOverride } from '../sema/sema-map'; +import type { SemaChannelId, SemaSignatureOverride } from '../sema/channels'; import type { SemaDurationSpec } from '../sema/durations'; // ── HTML element ────────────────────────────────────────────────────────── diff --git a/src/uix/sema/channels.ts b/src/uix/sema/channels.ts new file mode 100644 index 000000000..fc4c3ab76 --- /dev/null +++ b/src/uix/sema/channels.ts @@ -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 + | Record; +}; diff --git a/src/uix/sema/chans/haptic.ts b/src/uix/sema/chans/haptic.ts index 52297b415..43567578f 100644 --- a/src/uix/sema/chans/haptic.ts +++ b/src/uix/sema/chans/haptic.ts @@ -1,5 +1,5 @@ import type { EffectiveSignature } from '../resolver' -import type { HapticSignature } from '../sema-map' +import type { HapticSignature } from '../channels' import type { SemanticSignal } from '../signal' import type { Channel } from './types' diff --git a/src/uix/sema/chans/sound.test.ts b/src/uix/sema/chans/sound.test.ts index f57a291c7..006b369b3 100644 --- a/src/uix/sema/chans/sound.test.ts +++ b/src/uix/sema/chans/sound.test.ts @@ -1,13 +1,17 @@ // @vitest-environment jsdom 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 { - addEventListener: vi.fn(), - removeEventListener: vi.fn() - } satisfies Pick; + cleanup, + dom: { + getDocument: vi.fn(() => document), + listen: vi.fn(() => cleanup) + } satisfies SoundChannelDom + }; } function createAudioHarness() { @@ -31,25 +35,25 @@ function createAudioHarness() { describe('SoundChannel', () => { it('does not register global unlock listeners in the constructor', () => { - const doc = createDocHarness(); + const { dom } = createDomHarness(); const audio = createAudioHarness(); const channel = new SoundChannel({ - doc, + dom, audioContextFactory: audio.factory }); - expect(doc.addEventListener).not.toHaveBeenCalled(); + expect(dom.listen).not.toHaveBeenCalled(); expect(audio.factory).not.toHaveBeenCalled(); channel.dispose(); }); it('primes the AudioContext synchronously from prepare when sound is allowed', () => { - const doc = createDocHarness(); + const { cleanup, dom } = createDomHarness(); const audio = createAudioHarness(); const channel = new SoundChannel({ - doc, + dom, audioContextFactory: audio.factory, masterGain: 0.4 }); @@ -64,22 +68,25 @@ describe('SoundChannel', () => { expect(audio.ctx.createGain).toHaveBeenCalledTimes(1); expect(audio.gainNode.gain.value).toBe(0.4); expect(audio.resume).toHaveBeenCalledTimes(1); - expect(doc.addEventListener).toHaveBeenCalledTimes(3); - expect(doc.addEventListener).toHaveBeenCalledWith('click', expect.any(Function), true); - expect(doc.addEventListener).toHaveBeenCalledWith('touchstart', expect.any(Function), true); - expect(doc.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), true); + expect(dom.getDocument).toHaveBeenCalledTimes(1); + expect(dom.listen).toHaveBeenCalledWith( + document, + ['click', 'touchstart', 'keydown'], + expect.any(Function), + true + ); channel.dispose(); - expect(doc.removeEventListener).toHaveBeenCalledTimes(3); + expect(cleanup).toHaveBeenCalledTimes(1); expect(audio.close).toHaveBeenCalledTimes(1); }); it('does not prime the AudioContext for explicit non-sound channel lists', () => { - const doc = createDocHarness(); + const { dom } = createDomHarness(); const audio = createAudioHarness(); const channel = new SoundChannel({ - doc, + dom, audioContextFactory: audio.factory }); @@ -90,7 +97,7 @@ describe('SoundChannel', () => { }); expect(audio.factory).not.toHaveBeenCalled(); - expect(doc.addEventListener).not.toHaveBeenCalled(); + expect(dom.listen).not.toHaveBeenCalled(); channel.dispose(); }); diff --git a/src/uix/sema/chans/sound.ts b/src/uix/sema/chans/sound.ts index 460b02659..094d7f84d 100644 --- a/src/uix/sema/chans/sound.ts +++ b/src/uix/sema/chans/sound.ts @@ -30,13 +30,23 @@ import type { Logger } from '$libs/logger'; 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 { /** Override the AudioContext factory (tests / non-browser environments). */ audioContextFactory?: () => AudioContext | null; /** Override the global fetch (for sample loading in tests). */ fetchFn?: typeof fetch; - /** Override document for unlock listener registration (tests). */ - doc?: Pick; + /** DOM service used for global unlock listener registration. */ + dom?: SoundChannelDom; /** Master gain multiplier applied on top of every signature's `gain`. Default 1. */ masterGain?: number; /** Optional diagnostics logger. When omitted, audio failures stay silent. */ @@ -59,7 +69,7 @@ export class SoundChannel implements Channel { private readonly sampleCache = new Map(); private readonly fetchFn?: typeof fetch; private readonly audioContextFactory?: () => AudioContext | null; - private readonly doc?: Pick; + private readonly dom?: SoundChannelDom; private readonly masterGainValue: number; private readonly logger: Logger | undefined; private teardownUnlock?: () => void; @@ -68,7 +78,7 @@ export class SoundChannel implements Channel { this.fetchFn = opts.fetchFn ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined); this.audioContextFactory = opts.audioContextFactory; - this.doc = opts.doc ?? (typeof document !== 'undefined' ? document : undefined); + this.dom = opts.dom; this.masterGainValue = opts.masterGain ?? 1; this.logger = opts.logger; } @@ -199,22 +209,14 @@ export class SoundChannel implements Channel { } private setupUnlockListener(): void { - if (!this.doc || this.teardownUnlock) return; + if (!this.dom || this.teardownUnlock) return; const events = ['click', 'touchstart', 'keydown'] as const; const unlock = () => { this.audioCtx?.resume().catch(() => {}); }; - for (const eventName of events) { - this.doc.addEventListener(eventName, unlock, true); - } - - this.teardownUnlock = () => { - for (const eventName of events) { - this.doc?.removeEventListener(eventName, unlock, true); - } - }; + this.teardownUnlock = this.dom.listen(this.dom.getDocument(), events, unlock, true); } private async synthesize(ctx: AudioContext, sig: SoundSignature): Promise { diff --git a/src/uix/sema/engine.ts b/src/uix/sema/engine.ts index 0adb1a798..48e8948cd 100644 --- a/src/uix/sema/engine.ts +++ b/src/uix/sema/engine.ts @@ -26,11 +26,12 @@ import type { Channel, ChannelPreparation } from './chans/types'; import type { DomApplier } from '$libs/dom'; import type { Logger } from '$libs/logger'; 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 { SemaDuplicateChannelError } from './errors'; 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 { SignalProjector } from './projection'; @@ -81,7 +82,7 @@ export interface EngineSemanticOptions { * In `active-uix` this is `uix.dom`, so sema projects event attrs through * the same DOM owner as soma. */ - dom?: DomApplier; + dom?: DomApplier | (DomApplier & SoundChannelDom); } export class EngineSemantic { @@ -125,9 +126,17 @@ export class EngineSemantic { } if (opts.sound !== undefined && opts.sound !== false) { - const soundChannel: Channel = isChannel(opts.sound) - ? opts.sound - : new SoundChannel({ ...(opts.sound === true ? {} : opts.sound), logger: opts.logger }); + let soundChannel: Channel; + if (isChannel(opts.sound)) { + 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); // 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' ); } + +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' + ); +} diff --git a/src/uix/sema/exports.ts b/src/uix/sema/exports.ts index 7ccf659d8..c495dcd9b 100644 --- a/src/uix/sema/exports.ts +++ b/src/uix/sema/exports.ts @@ -64,21 +64,23 @@ export { resolveSignature, applyMapOverrides, type EffectiveSignature, - type HapticSignature, type SemaCascadeRule, - type SemaChannelId, - type SemaChannelSignatures, - type SemaResolveOptions, - type SemaSignatureOverride, - type SoundSignature + type SemaResolveOptions } from './resolver'; +export type { + HapticSignature, + SemaChannelId, + SemaChannelSignatures, + SemaSignatureOverride, + SemaRuntimeChannelId, + SoundSignature +} from './channels'; export { SEMA_MAP, type SemaMap, type FamilyMapEntry, type IntentMapEntry, - type Sema, - type SemaRuntimeChannelId + type Sema } from './sema-map'; export { stampEventAttrs, unstampEventAttrs } from './stamp'; diff --git a/src/uix/sema/resolver.ts b/src/uix/sema/resolver.ts index 55a8ee415..40dfaf193 100644 --- a/src/uix/sema/resolver.ts +++ b/src/uix/sema/resolver.ts @@ -34,18 +34,16 @@ import type { SemanticSignal } from './signal'; import type { Intent } from '../intent'; import type { SemaFamily } from './types'; import { resolveSemaDuration } from './durations'; -import { - SEMA_MAP, - SEMA_VALENCED_FAMILY_LIST, - type DeltaOp, - type DeltaValue, - type HapticSignature, - type SemaChannelId, - type SemaChannelSignatures, - type SemaMap, - type SemaSignatureOverride, - type SoundSignature -} from './sema-map'; +import { SEMA_MAP, SEMA_VALENCED_FAMILY_LIST, type SemaMap } from './sema-map'; +import type { + DeltaOp, + DeltaValue, + HapticSignature, + SemaChannelId, + SemaChannelSignatures, + SemaSignatureOverride, + SoundSignature +} from './channels'; export type { HapticSignature, @@ -53,7 +51,7 @@ export type { SemaChannelSignatures, SemaSignatureOverride, SoundSignature -} from './sema-map'; +} from './channels'; /** * Single cascade rule. Identical shape to a CSS rule — a selector that diff --git a/src/uix/sema/sema-map.ts b/src/uix/sema/sema-map.ts index 3bf7683e2..51d23addc 100644 --- a/src/uix/sema/sema-map.ts +++ b/src/uix/sema/sema-map.ts @@ -22,126 +22,15 @@ import type { Intent } from '../intent'; import type { SemaFamily, SemaValencedFamily } from './types'; import type { SemaDurationSpec } from './durations'; - -// ── Channel signature types ──────────────────────────────────────────────── - -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`. - * - * 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 & {}); +import type { + DeltaValue, + SemaChannelId, + SemaChannelSignatures, + SemaSignatureOverride +} from './channels'; // ── 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 { /** Per-channel base signatures. Channels not present default to undefined. */ base: Partial; @@ -168,29 +57,6 @@ export interface IntentMapEntry { deltas: Partial>>; } -/** - * 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 - | Record; -}; - /** * Per-component sema declaration — sibling to `Morfo`. Lives in * `src/uix/sema/components/{name}.ts` alongside the component's morfo, diff --git a/src/uix/sema/signal.ts b/src/uix/sema/signal.ts index 9342dafb8..fa95fe444 100644 --- a/src/uix/sema/signal.ts +++ b/src/uix/sema/signal.ts @@ -8,7 +8,7 @@ * propia modalidad. */ -import type { SemaChannelId, SemaSignatureOverride } from './sema-map' +import type { SemaChannelId, SemaSignatureOverride } from './channels' import type { Intent } from '../intent' import type { SemaFamily } from './types' diff --git a/src/uix/sema/types.ts b/src/uix/sema/types.ts index 84ed026e9..8565dbec1 100644 --- a/src/uix/sema/types.ts +++ b/src/uix/sema/types.ts @@ -8,7 +8,7 @@ import type { Intent } from '../intent'; import type { PartRef } from '../types'; -import type { SemaChannelId, SemaSignatureOverride } from './sema-map'; +import type { SemaChannelId, SemaSignatureOverride } from './channels'; // ── Core domain ──────────────────────────────────────────────────────────── diff --git a/src/uix/soma/layers/floating/floating.svelte.ts b/src/uix/soma/layers/floating/floating.svelte.ts index c1ad46d35..1a1b8603e 100644 --- a/src/uix/soma/layers/floating/floating.svelte.ts +++ b/src/uix/soma/layers/floating/floating.svelte.ts @@ -23,20 +23,7 @@ import { ElementSize, watch } from 'runed'; import { useFloating } from './use-floating.svelte'; import type { Measurable, UseFloatingReturn } from './types'; - -export const SIDE_OPTIONS = ['top', 'right', 'bottom', 'left'] as const; -export const ALIGN_OPTIONS = ['start', 'center', 'end'] as const; - -const OPPOSITE_SIDE: Record = { - 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; +import { OPPOSITE_SIDE, type Align, type Boundary, type Side } from './placement'; // ── FloatingProvider ───────────────────────────────────────────────────────── diff --git a/src/uix/soma/layers/floating/index.ts b/src/uix/soma/layers/floating/index.ts index e91773766..d94743dc0 100644 --- a/src/uix/soma/layers/floating/index.ts +++ b/src/uix/soma/layers/floating/index.ts @@ -5,12 +5,16 @@ export { FloatingArrow, FloatingAnchor, type FloatingContentOpts, - type Side, - type Align, - type Boundary, getSideFromPlacement, getAlignFromPlacement } from './floating.svelte'; +export { + SIDE_OPTIONS, + ALIGN_OPTIONS, + type Side, + type Align, + type Boundary +} from './placement'; // ── Floating engine ────────────────────────────────────────────────────────── export type { diff --git a/src/uix/soma/layers/floating/placement.ts b/src/uix/soma/layers/floating/placement.ts new file mode 100644 index 000000000..a8c71da47 --- /dev/null +++ b/src/uix/soma/layers/floating/placement.ts @@ -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 = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right' +}; diff --git a/src/uix/soma/layers/floating/types.ts b/src/uix/soma/layers/floating/types.ts index 8e93ed95b..4057f578f 100644 --- a/src/uix/soma/layers/floating/types.ts +++ b/src/uix/soma/layers/floating/types.ts @@ -12,7 +12,7 @@ import type { } from '$libs/reactive'; import type { Arrayable, Direction, StyleProperties } from '$soma/types'; import type { Snippet } from 'svelte'; -import type { Align, Boundary, Side } from './floating.svelte.js'; +import type { Align, Boundary, Side } from './placement'; // ─── Shared ───────────────────────────────────────────────────────────────────