feat(splitter): sema + dynamic drag-signal sound infrastructure

Splitter previously had no sema bindings — handle-pick / handle-drag /
commit-set fired silently. Closes the asymmetry with Slider and Drawer.

Sema (src/uix/sema/components/splitter.ts):
- handle-pick → sound('handle.pickup.air', gain 0.10) + tick haptic
- handle-drag → channels: ['sound', 'haptic'] (channels-only; per-emit
  signal.overrides from soma own the dynamic curve so cascade doesn't
  clobber them)
- commit-set → sound('handle.snap.chime', gain 0.14) + tap haptic

Sound resolver (src/uix/sema/sounds.ts):
- resolveSplitterDragSound — heavier sonic register than slider since
  splitters move whole panels, not a thumb:
    pitch 480..820 Hz (slider: 760..1280)
    centroid 2400..4800 (slider: 3600..6800) — less brilliant
    roughness 0.006 (slider: 0.003) — woody texture, not metallic
    envelope attack 16 / decay 72 / duration 76 — perceptual mass
    gain 0.025 + velocity*0.03 — restrained ceiling, long swipes ok

Soma drag-signal infrastructure (splitter-provider.svelte.ts):
- Mirrors slider's frame-throttled signal pipeline:
    dragSignalFrame / pendingDragPercent / lastDragSignalAt / lastDragPercent
    DRAG_SIGNAL_MS = 72ms throttle (≈14 Hz emit cadence)
- dragSignalOverrides(percent01) computes velocity01 from frame delta,
  builds SemaSignatureOverride with sound (via resolveSplitterDragSound)
  and haptic (tick scaled by velocity).
- queueHandleDrag / cancelQueuedHandleDrag wrap requestFrame so each
  pointermove only schedules one trigger per throttle window.
- onpointerdown calls resetDragSignal(startSizeA/100) to anchor the
  velocity baseline at grab time.
- onpointerup / onlostpointercapture / unmount $effect all cancel the
  queued frame so we don't fire post-release.

Verified in browser at /uix/components/splitter:
  handle-pick → 1 event (family: handle)
  handle-drag → frame-throttled across 6 pointermoves
  commit-set  → 1 event (family: commit, intent: neutral)

Checks: svelte-check 0 errors, 133/133 vitest (splitter + sema),
component:audit 91/91 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent b504c1c4b7
commit 59505e6da2

@ -27,6 +27,7 @@ export { ratingGroupSema } from './rating-group'
export { searchFieldSema } from './search-field'
export { selectSema } from './select'
export { sliderSema } from './slider'
export { splitterSema } from './splitter'
export { stepperSema } from './stepper'
export { tabsSema } from './tabs'
export { tagGroupSema } from './tag-group'

@ -0,0 +1,55 @@
import { semaSelector } from '$uix/morfo';
import { splitterMorfo } from '$uix/morfo/components/splitter';
import { sound } from '../sounds';
import type { Sema } from '../sema-map';
/**
* Splitter perceptual defaults — mirrors the Slider's three-event shape
* (handle-pick → handle-drag → commit-set) with a heavier sonic register
* appropriate to moving whole panels rather than a thumb on a track.
*
* Sound:
* - `handle-pick` → `handle.pickup.air` (gain 0.10, slightly softer
* than slider's 0.12 — splitters get grabbed more often)
* - `handle-drag` → channel-only rule. Per-emit `signal.overrides`
* are computed by soma (`SplitterResizeTriggerProvider`) via
* `resolveSplitterDragSound({ value01, velocity01, direction })`
* so the dynamic curve survives the cascade.
* - `commit-set` → `handle.snap.chime` + tap haptic for the
* "I committed to this layout" landing cue.
*
* Haptic mirrors slider: tick on pick, tick driven by velocity during
* drag (also from soma overrides), tap on commit.
*/
const onResizeTrigger = (
matchers?: Parameters<typeof semaSelector<typeof splitterMorfo>>[2]
) => semaSelector(splitterMorfo, 'resize-trigger', matchers);
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof splitterMorfo>>[2]) =>
semaSelector(splitterMorfo, 'provider', matchers);
export const splitterSema: Sema = {
name: 'splitter',
cascade: [
{
selector: onResizeTrigger({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: sound('handle.pickup.air', { gain: 0.1 }),
haptic: { kind: 'tick', intensity: 0.2, duration: 6 }
},
{
// Channels-only — the per-emit `signal.overrides` from soma
// own the dynamic pitch/centroid/gain. Adding a `sound:` here
// would clobber them.
selector: onResizeTrigger({ eventName: 'handle-drag' }),
channels: ['sound', 'haptic']
},
{
selector: onProvider({ eventName: 'commit-set' }),
channels: ['sound', 'haptic'],
sound: sound('handle.snap.chime', { gain: 0.14 }),
haptic: { kind: 'tap', intensity: 0.5, duration: 22 }
}
]
};

@ -92,6 +92,7 @@ export {
resolveSoundDefinition,
resolveHandleDragSound,
resolveSliderDragSound,
resolveSplitterDragSound,
type DragSoundParams,
type SampleSoundDefinition,
type SoundDefinition,

@ -205,6 +205,40 @@ export function resolveSliderDragSound(params: DragSoundParams): SoundSignature
};
}
/**
* Splitter handle drag sound resolver.
*
* Splitters move whole panels, not a thumb on a track. The gesture has
* more body and lower kinetic register than a slider, so the curve is
* tuned to read as a heavier, thicker timbre:
*
* - Lower pitch range (480..820 Hz vs slider's 760..1280) — "thicker"
* - Lower centroid ceiling (2400..4800 vs 3600..6800) — less brilliant
* - Slightly more roughness (0.006) — woody texture, not metallic
* - Slightly longer envelope (attack 16, decay 72, duration 76) —
* the surface being moved has perceptual mass
* - Modest gain floor (0.025) climbing with velocity — splitters can
* drag for long swipes, gain ceiling stays restrained
*
* `value01` = current handle position in `[0, 1]` (size of the panel
* before the handle as a fraction of total container size). `velocity01`
* = normalized motion speed computed by soma against frame deltas.
*/
export function resolveSplitterDragSound(params: DragSoundParams): SoundSignature {
const value01 = clamp(params.value01, 0, 1);
const velocity01 = clamp(params.velocity01, 0, 1);
return {
pitch: Math.round(lerp(480, 820, value01) + velocity01 * 90),
centroid: Math.round(lerp(2400, 4800, velocity01)),
roughness: 0.006,
attack: 16,
decay: 72,
duration: 76,
contour: params.direction,
gain: Number((0.025 + velocity01 * 0.03).toFixed(3))
};
}
function mergeSound(base: SoundSignature, overrides?: Partial<SoundSignature>): SoundSignature {
const next = structuredClone(base);
return overrides ? { ...next, ...overrides } : next;

@ -6,6 +6,15 @@ import { Soma } from '../../core/soma.svelte';
import { splitterMorfo } from '../../../morfo/components/splitter';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { resolveSplitterDragSound, type SemaSignatureOverride } from '$uix/sema';
function nowMs(): number {
return typeof performance !== 'undefined' ? performance.now() : Date.now();
}
function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
// ── Types ────────────────────────────────────────────────────────────────────
@ -377,6 +386,81 @@ export class SplitterResizeTriggerProvider {
private dragStartSizeA = 0;
private dragStartSizeB = 0;
// ── Sema drag-signal throttling ──────────────────────────────────────────
//
// `handle-drag` fires per pointermove frame, but the sound/haptic
// engine doesn't need a new payload every 16ms — it gets crowded
// and burns audio nodes. Throttle to ~14 Hz (72ms) like the slider
// does: queue the next pending percent, fire one signal per frame
// after the throttle window. Pitch/centroid/gain come from the
// position+velocity curve in `resolveSplitterDragSound`.
private dragSignalFrame: number | null = null;
private pendingDragPercent: number | null = null;
private lastDragSignalAt = 0;
private lastDragPercent = 0;
private static readonly DRAG_SIGNAL_MS = 72;
private resetDragSignal(percent01: number): void {
this.lastDragSignalAt = 0;
this.lastDragPercent = clamp(percent01, 0, 1);
this.pendingDragPercent = null;
}
private dragSignalOverrides(percent01: number): SemaSignatureOverride {
const percent = clamp(percent01, 0, 1);
const now = nowMs();
const delta = percent - this.lastDragPercent;
const dt = this.lastDragSignalAt > 0 ? Math.max(now - this.lastDragSignalAt, 16) : 16;
const velocity01 = clamp((Math.abs(delta) / dt) * 1000, 0, 1);
this.lastDragSignalAt = now;
this.lastDragPercent = percent;
return {
sound: resolveSplitterDragSound({
value01: percent,
velocity01,
direction: delta >= 0 ? 'ascending' : 'descending'
}),
haptic: {
kind: 'tick',
intensity: Number((0.16 + velocity01 * 0.16).toFixed(3)),
duration: 6
}
};
}
private queueHandleDrag(percent01: number): void {
this.pendingDragPercent = percent01;
if (this.dragSignalFrame !== null) return;
this.dragSignalFrame = this.provider.soma.dom.requestFrame(() => {
this.dragSignalFrame = null;
if (!this.dragging || this.pendingDragPercent === null) return;
const now = nowMs();
if (
this.lastDragSignalAt > 0 &&
now - this.lastDragSignalAt < SplitterResizeTriggerProvider.DRAG_SIGNAL_MS
) {
return;
}
const nextPercent = this.pendingDragPercent;
this.pendingDragPercent = null;
void this.provider.runtime.trigger('handle-drag', {
overrides: this.dragSignalOverrides(nextPercent)
});
}, this.opts.ref?.current);
}
private cancelQueuedHandleDrag(): void {
if (this.dragSignalFrame !== null) {
this.provider.soma.dom.cancelFrame(this.dragSignalFrame, this.opts.ref?.current);
this.dragSignalFrame = null;
}
this.pendingDragPercent = null;
}
private constructor(opts: SplitterResizeTriggerOpts) {
this.opts = opts;
this.provider = SplitterProvider.require();
@ -404,6 +488,7 @@ export class SplitterResizeTriggerProvider {
this.provider.notifyResizeEnd();
}
this.pointerDown = false;
this.cancelQueuedHandleDrag();
};
});
}
@ -456,6 +541,11 @@ export class SplitterResizeTriggerProvider {
// Capture immediately — resize handle has no child buttons to protect
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Initialize the drag-signal baseline at the current handle
// position. The first `handle-drag` overrides will compute
// velocity from this anchor.
this.resetDragSignal(this.dragStartSizeA / 100);
// Sema: the user grabbed the handle. Coincident with the gesture.
void this.provider.runtime.trigger('handle-pick');
};
@ -488,8 +578,12 @@ export class SplitterResizeTriggerProvider {
this.provider.resizePanels(this.panelBeforeId, this.panelAfterId, deltaPercent);
// Sema: continuous drag. Coincident with each motion frame.
void this.provider.runtime.trigger('handle-drag');
// Sema: continuous drag. Throttled per-frame; the override
// payload (pitch / centroid / gain / haptic intensity) is
// computed from the current handle position + velocity in
// `queueHandleDrag` → `dragSignalOverrides`.
const currentPercent = this.provider.getPanelSize(this.panelBeforeId) / 100;
this.queueHandleDrag(currentPercent);
};
readonly onpointerup = (_e: PointerEvent) => {
@ -497,6 +591,7 @@ export class SplitterResizeTriggerProvider {
if (!this.dragging) return;
this.dragging = false;
this.provider.resizing = false;
this.cancelQueuedHandleDrag();
this.provider.notifyResizeEnd();
};
@ -505,6 +600,7 @@ export class SplitterResizeTriggerProvider {
if (this.dragging) {
this.dragging = false;
this.provider.resizing = false;
this.cancelQueuedHandleDrag();
this.provider.notifyResizeEnd();
}
};

Loading…
Cancel
Save

Powered by TurnKey Linux.