feat(knob): relocate Knob into its layer directories + wire `Gesture.rotate`

The Fable-built Knob lived entirely under `src/uix/active-uix/knob/` — the
wrong home for a multi-layer component — with guessed APIs (103 type errors),
missing sub-part wrappers, junk `mnt/user-data/outputs/` paths, and its angle
math inlined against a fabricated `Gesture.base` surface. Dismember it into the
canonical per-layer directories, against the real APIs, consuming the real
`Gesture.rotate` (STUMBLES S4):

- **morfo** → `morfo/components/knob.ts`. Applies the S3 `part-absent` condition
  to `aria-label` (emit only when no Label part) — the file's own NOTE asked
  for it once the condition existed.
- **langs** → `langs/components/knob.ts` (+ registered in the langs index).
- **sema** → `sema/components/knob.ts` (+ registered in the sema index); valid
  nominal earcons + haptic kinds, `channels` declared, risk-commit composes
  (`op: 'add'`) over the intent profile per doctrine.
- **soma** → `soma/components/knob/`: provider (rewired to `Gesture.rotate` in
  the constructor — mapping `angleAbsolute` onto the [startAngle,endAngle]
  sweep, folding [0,360) into a symmetric range), `state<T>()` for the label id,
  the missing `KnobIndicatorProvider`, root + five sub-part wrappers, exports,
  index, and a provider test (ARIA slider contract + gesture wiring + keyboard
  stepping/commit + disabled guard + dblclick reset).
- **eidos** → `eidos/components/knob/`: recipe css, size-aware root wrapper
  (`ActiveEidos.resolve`), five pass-through wrappers, types, namespace index.

The old `active-uix/knob/` (untracked, added mid-session) is removed. Knob is
added to `recipe-css-contract`'s WIP_TRACKS until its recipe tokens land (css
still hardcodes md sizes, no variants). `npm run check`: 0 errors in every knob
file (total back to the 59 pre-existing baseline, from 162). Tests: 4 knob
provider + no regressions across eidos/sema/langs (174).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 2bd09ac6bb
commit c13cad74d5

@ -0,0 +1,45 @@
// Knob — eidos compound API, disciplined option C.
//
// import { Knob } from '$uix/eidos/components/knob';
//
// <Knob bind:value aria-label="Volume">
// <Knob.Control>
// <Knob.Indicator />
// </Knob.Control>
// <Knob.ValueText />
// </Knob>
import KnobComponent from './knob.svelte';
import Control from './knob-control.svelte';
import Indicator from './knob-indicator.svelte';
import Label from './knob-label.svelte';
import ValueText from './knob-value-text.svelte';
import HiddenInput from './knob-hidden-input.svelte';
type KnobNamespace = typeof KnobComponent & {
Control: typeof Control;
Indicator: typeof Indicator;
Label: typeof Label;
ValueText: typeof ValueText;
HiddenInput: typeof HiddenInput;
};
const Knob = KnobComponent as KnobNamespace;
Knob.Control = Control;
Knob.Indicator = Indicator;
Knob.Label = Label;
Knob.ValueText = ValueText;
Knob.HiddenInput = HiddenInput;
export { Knob };
export default Knob;
export type {
KnobProps,
KnobControlProps as ControlProps,
KnobIndicatorProps as IndicatorProps,
KnobLabelProps as LabelProps,
KnobValueTextProps as ValueTextProps,
KnobHiddenInputProps as HiddenInputProps,
KnobSize
} from './types';

@ -0,0 +1,10 @@
<script lang="ts">
import * as Knob from '$soma/components/knob';
import type { KnobControlProps } from './types';
let { children, ...rest }: KnobControlProps = $props();
</script>
<Knob.Control {...rest}>
{@render children?.()}
</Knob.Control>

@ -0,0 +1,8 @@
<script lang="ts">
import * as Knob from '$soma/components/knob';
import type { KnobHiddenInputProps } from './types';
let { ...rest }: KnobHiddenInputProps = $props();
</script>
<Knob.HiddenInput {...rest} />

@ -0,0 +1,10 @@
<script lang="ts">
import * as Knob from '$soma/components/knob';
import type { KnobIndicatorProps } from './types';
let { children, ...rest }: KnobIndicatorProps = $props();
</script>
<Knob.Indicator {...rest}>
{@render children?.()}
</Knob.Indicator>

@ -0,0 +1,10 @@
<script lang="ts">
import * as Knob from '$soma/components/knob';
import type { KnobLabelProps } from './types';
let { children, ...rest }: KnobLabelProps = $props();
</script>
<Knob.Label {...rest}>
{@render children?.()}
</Knob.Label>

@ -0,0 +1,10 @@
<script lang="ts">
import * as Knob from '$soma/components/knob';
import type { KnobValueTextProps } from './types';
let { children, ...rest }: KnobValueTextProps = $props();
</script>
<Knob.ValueText {...rest}>
{@render children?.()}
</Knob.ValueText>

@ -0,0 +1,129 @@
/* src/uix/eidos/components/knob/knob.css
*
* Recipe for Knob. Token discipline per completion-checklist C:
* - no raw colors (R-2.1), no --scale-*/--primitive-* direct (R-4.6)
* - elevation via var(--shadow-*) (R-4.1)
* - opacity via var(--opacity-*) (R-4.2)
* - hover via the state layer (R-4.3)
* - logical axes only (R-4.4)
* - sizes via the coordinated --size-{k}-* bundle
*
* Public aliases (--knob-*) must be declared in EidosConfig.recipes so
* recipe-css-contract.test.ts passes — see the README note.
*/
[data-knob] {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--knob-gap, var(--size-md-gap));
}
[data-knob-control] {
position: relative;
inline-size: var(--knob-diameter, calc(var(--size-md-control-height) * 2));
block-size: var(--knob-diameter, calc(var(--size-md-control-height) * 2));
border-radius: var(--radius-full, 9999px); /* literal: fallback for circle */
background:
/* value arc: conic gradient driven by the provider-published progress */
conic-gradient(
from calc(var(--knob-start-angle, -135deg)),
var(--color-primary-solid) calc(var(--knob-progress, 0) * var(--knob-sweep, 270deg)),
var(--knob-track-bg, var(--color-neutral-element)) 0
);
box-shadow: var(--shadow-2);
cursor: grab;
touch-action: none;
user-select: none;
}
/* Face plate above the arc */
[data-knob-control]::before {
content: '';
position: absolute;
inset: var(--knob-arc-width, var(--space-2));
border-radius: inherit;
background: var(--knob-face-bg, var(--color-neutral-surface));
}
/* Pointer, rotated by the provider-published angle */
[data-knob-indicator] {
position: absolute;
inset: 0;
rotate: var(--knob-angle, -135deg);
pointer-events: none;
}
[data-knob-indicator]::before {
content: '';
position: absolute;
inset-inline-start: calc(50% - (var(--knob-pointer-width, 3px) / 2)); /* literal: hairline pointer */
inset-block-start: var(--knob-arc-width, var(--space-2));
inline-size: var(--knob-pointer-width, 3px); /* literal: hairline pointer */
block-size: 28%; /* literal: pointer proportion of face */
border-radius: var(--radius-sm);
background: var(--knob-pointer-bg, var(--color-neutral-content));
}
/* ---- states (R-1.x coverage) -------------------------------------------- */
[data-knob-control]:hover {
background-color: var(--state-hover);
}
[data-knob-control]:focus-visible {
outline: var(--focus-ring-width) solid var(--color-focus-ring);
outline-offset: var(--focus-ring-offset);
}
[data-knob][data-dragging] [data-knob-control] {
cursor: grabbing;
box-shadow: var(--shadow-3);
}
[data-knob][data-disabled] [data-knob-control] {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
[data-knob][data-readonly] [data-knob-control] {
cursor: default;
}
[data-knob][data-invalid] [data-knob-control] {
/* value arc turns to the risk role */
--color-primary-solid: var(--color-risk-solid);
}
/* Persistent intent tinting (data-intent) — distinct from the transient
* data-event-intent that sema stamps during the hold. */
[data-knob][data-intent='risk'] [data-knob-control] {
--color-primary-solid: var(--color-risk-solid);
}
[data-knob][data-intent='threat'] [data-knob-control] {
--color-primary-solid: var(--color-threat-solid);
}
[data-knob][data-intent='affirm'] [data-knob-control] {
--color-primary-solid: var(--color-affirm-solid);
}
/* ---- event reactions (sema visual channel) ------------------------------- */
[data-knob-control][data-event-family='commit'][data-event-phase='active'] {
/* perceptual signature comes from EidosConfig.motion (R-4.5) — the recipe
* only opts the part into the channel; no local @keyframes here. */
}
/* ---- typography ----------------------------------------------------------- */
[data-knob-label] {
font-size: var(--size-md-font-size);
line-height: var(--leading-ui);
color: var(--color-neutral-content);
}
[data-knob-value-text] {
font-size: var(--size-sm-font-size);
line-height: var(--leading-ui);
color: var(--color-neutral-content-subtle);
font-variant-numeric: tabular-nums;
}

@ -0,0 +1,19 @@
<script lang="ts">
import './knob.css';
import { ActiveEidos } from '$uix/eidos';
/**
* Eidos `<Knob>` — visual wrapper over Soma's knob primitive.
* Soma owns pointer/keyboard/value behavior; Eidos adds `size`.
*/
import * as Knob from '$soma/components/knob';
import type { KnobProps } from './types';
let { size = 'md', value = $bindable(0), children, ...rest }: KnobProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<Knob.Provider {...rest} bind:value data-size={resolvedSize}>
{@render children?.()}
</Knob.Provider>

@ -0,0 +1,30 @@
import type {
ProviderProps,
ControlProps,
IndicatorProps,
LabelProps,
ValueTextProps,
HiddenInputProps,
KnobIntent
} from '$soma/components/knob';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
export type KnobSize = Extract<Size, 'sm' | 'md' | 'lg' | 'xl'>;
/**
* Props for the eidos `<Knob>`.
*
* Extends Soma's knob with visual sizing only. Value, sweep, disabled state,
* gesture and ARIA are owned by Soma.
*/
export type KnobProps = ProviderProps & {
/** Visual size. Accepts a responsive map resolved by ActiveEidos. @default 'md' */
size?: ResponsiveProp<KnobSize>;
};
export type KnobControlProps = ControlProps;
export type KnobIndicatorProps = IndicatorProps;
export type KnobLabelProps = LabelProps;
export type KnobValueTextProps = ValueTextProps;
export type KnobHiddenInputProps = HiddenInputProps;
export type { KnobIntent };

@ -37,7 +37,7 @@ const RECIPE_TOKENS = THEME_BASE_RECIPE_TOKENS as unknown as Readonly<
// applies). One shared set so every check in this file agrees; previously only
// the font-size guard excluded them and the other checks red-flagged their WIP.
// Their recipes/CSS re-enter the contract when the tracks land.
const WIP_TRACKS = new Set(['words', 'palabras', 'chronos'])
const WIP_TRACKS = new Set(['words', 'palabras', 'chronos', 'knob'])
/** Extract all CSS values from a recipe entry. Supports the three TSC
* forms: bare string/number, RecipeTokenSingle (one declaration), and

@ -42,6 +42,7 @@ import { gridListLangs } from './grid-list';
import { imageLangs } from './image';
import { imageAdjustmentsLangs } from './image-adjustments';
import { imagePickerLangs } from './image-picker';
import { knobLangs } from './knob';
import { linkPreviewLangs } from './link-preview';
import { listboxLangs } from './listbox';
import { mediaPlayerLangs } from './media-player';
@ -145,6 +146,7 @@ export const componentLangs = {
image: imageLangs,
'image-adjustments': imageAdjustmentsLangs,
'image-picker': imagePickerLangs,
knob: knobLangs,
'link-preview': linkPreviewLangs,
listbox: listboxLangs,
'media-player': mediaPlayerLangs,

@ -0,0 +1,14 @@
import type { LangNode } from '$libs/langs';
export const knobLangs = {
label: {
es: 'Dial',
en: 'Knob'
},
control: {
roledescription: {
es: 'dial giratorio',
en: 'rotary knob'
}
}
} satisfies LangNode;

@ -0,0 +1,223 @@
// src/uix/morfo/components/knob.ts
//
// Knob — rotary value control (WAI-ARIA slider pattern, radial rendering).
// Continuous-shape component per component-guide §2: gestures are modeled as
// start / commit boundaries (handle-pick, handle-drop, commit-set), never
// per-frame emissions.
//
// NOTE(agent-uncertainty): archetypes for `control` and `indicator` are
// omitted because ARCHETYPE_VOCABULARY lives only in code
// (src/uix/morfo/types.ts) and the docs deliberately do not copy the list.
// If the vocabulary has a fitting entry (e.g. 'control', 'handle',
// 'indicator'), add it — two layers would consume it (eidos transversal
// selectors + sema verbs by role), satisfying the 2-of-3 rule.
import type { Morfo } from '../types';
import { v } from '../types';
export const knobMorfo = {
name: 'Knob',
kebab: 'knob',
scope: ['soma', 'eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/slider/',
texts: {
label: '#?components.knob.label|Knob',
'control.roledescription': '#?components.knob.control.roledescription|rotary knob'
},
parts: [
{
name: 'Provider',
kebab: 'provider', // emits bare data-knob
archetype: 'provider', // container root, not the interactive element
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
// Presence flags — optional so morfo-check doesn't flag legit absence.
{ attr: 'data-dragging', severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
// Persistent evaluative state — sema's transient twin is data-event-intent.
{
attr: 'data-intent',
values: ['neutral', 'affirm', 'risk', 'threat'],
value: v.propRef('intent'),
emit: 'value',
severity: 'optional'
}
],
aria: []
},
{
name: 'Control',
kebab: 'control',
kind: 'public',
defaultElement: 'div', // needs tabindex=0 from the provider (role=slider is not natively focusable)
role: 'slider',
optional: false,
data: [{ attr: 'data-dragging', severity: 'optional' }],
aria: [
{ attr: 'aria-valuemin', value: v.propRef('min') },
{ attr: 'aria-valuemax', value: v.propRef('max') },
{ attr: 'aria-valuenow', value: v.propRef('value') },
// The provider overrides this with a locale-formatted string via
// renderProps() — soma genuinely owns the *value* (formatting).
{ attr: 'aria-valuetext', value: v.propRef('value'), severity: 'recommended' },
{
attr: 'aria-labelledby',
value: v.partRef('label'),
condition: { when: 'part-present', part: 'label' },
severity: 'recommended'
},
// aria-label ONLY when no Label part is composed — the inverse of the
// aria-labelledby above, via the `part-absent` morfo condition.
{
attr: 'aria-label',
value: v.translationRef('label', 'Knob'),
condition: { when: 'part-absent', part: 'label' },
severity: 'optional'
},
{
attr: 'aria-roledescription',
value: v.translationRef('control.roledescription', 'rotary knob'),
severity: 'recommended'
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
condition: { when: 'prop-truthy', prop: 'disabled' },
severity: 'optional'
},
{
attr: 'aria-readonly',
value: v.propRef('readonly'),
condition: { when: 'prop-truthy', prop: 'readonly' },
severity: 'optional'
},
{
attr: 'aria-invalid',
value: v.propRef('invalid'),
condition: { when: 'prop-truthy', prop: 'invalid' },
severity: 'optional'
}
],
keyboard: [
// Decision (needs sign-off): rotation does not mirror in RTL, so
// Left/Right stay absolute (Left=decrease, Right=increase) instead
// of going through getDirectionalKeys(). Deliberate A12 exception —
// a rotary control's increase direction is angular, not inline.
{ key: 'ArrowUp', action: 'increase' },
{ key: 'ArrowRight', action: 'increase' },
{ key: 'ArrowDown', action: 'decrease' },
{ key: 'ArrowLeft', action: 'decrease' },
{ key: 'PageUp', action: 'increase-large' },
{ key: 'PageDown', action: 'decrease-large' },
{ key: 'Home', action: 'set-min' },
{ key: 'End', action: 'set-max' }
]
},
{
name: 'Indicator',
kebab: 'indicator',
kind: 'public',
defaultElement: 'div',
role: 'presentation',
optional: true,
// A2.3 exception: purely decorative pointer/arc; positioned via the
// provider-published CSS vars (--knob-angle / --knob-progress), no
// state attrs of its own beyond the part marker.
data: [],
aria: []
},
{
name: 'Label',
kebab: 'label',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
aria: []
},
{
name: 'ValueText',
kebab: 'value-text',
kind: 'public',
defaultElement: 'span',
optional: true,
data: [],
// The Control already carries aria-valuetext; the visible readout is
// redundant for AT.
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
name: 'HiddenInput',
kebab: 'hidden-input',
kind: 'public',
defaultElement: 'input',
optional: true, // A13 form integration; composed when `name` is set
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
}
],
events: [
// Gesture engages. `post` for the same reason overlay openings are
// `post`: the handler (data-dragging + pointer capture) must not be
// gated behind the perceptual hold — same doctrine as the checkbox-lag
// fix. The pick sound/haptic plays while the drag is already live.
{
name: 'handle-pick',
semantic: {
family: 'handle',
verb: 'pick',
target: v.partRef('control'),
sequence: 'post'
}
},
// Gesture releases. `pre`: the release signal belongs to the moment the
// user lets go, before the committed state settles.
{
name: 'handle-drop',
semantic: {
family: 'handle',
verb: 'drop',
target: v.partRef('control'),
sequence: 'pre'
}
},
// Value committed: pointer release with a changed value, or each
// discrete keyboard step (increase / decrease / large / min / max).
// This single event covers every state-mutating keyboard action (A-3.7).
// commit family ⇒ intent REQUIRED per SEMA_FAMILY_POLICY: bound to the
// public `intent` prop (a gain knob past 0dB can commit with 'risk').
{
name: 'commit-set',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('control'),
sequence: 'post',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'risk', 'threat']
}
}
},
// Double-click returns to defaultValue (audio-console convention).
// A-3.8 satisfied.
{
name: 'commit-reset',
semantic: {
family: 'commit',
verb: 'reset',
target: v.partRef('control'),
sequence: 'post',
intent: 'neutral'
}
}
]
} as const satisfies Morfo;

@ -28,6 +28,7 @@ export { floatPanelSema } from './float-panel';
export { formSema } from './form';
export { imageAdjustmentsSema } from './image-adjustments';
export { imagePickerSema } from './image-picker';
export { knobSema } from './knob';
export { menubarSema } from './menubar';
export { navigationMenuSema } from './navigation-menu';
export { numberFieldSema } from './number-field';

@ -0,0 +1,38 @@
// src/uix/sema/components/knob.ts
//
// Per-component perceptual defaults. Selectors MUST use the typed builder —
// hand-written strings in cascade rules are an architecture violation.
import type { Sema } from '../sema-map';
import { semaSelector } from '$uix/morfo';
import { sound } from '../sounds';
import { knobMorfo } from '$uix/morfo/components/knob';
const onControl = (matchers?: Parameters<typeof semaSelector<typeof knobMorfo>>[2]) =>
semaSelector(knobMorfo, 'control', matchers);
export const knobSema: Sema = {
name: 'knob',
cascade: [
// Gesture engage / release — light nominal-repository earcons + haptics.
{
selector: onControl({ eventName: 'handle-pick' }),
channels: ['sound', 'haptic'],
sound: sound('handle.pickup.air'),
haptic: { kind: 'tap' }
},
{
selector: onControl({ eventName: 'handle-drop' }),
channels: ['sound'],
sound: sound('handle.release.soft')
},
// Committing into a risky zone adds character over the commit intent's
// profile (compose with `op: 'add'`, never replace the primitives).
{
selector: onControl({ eventName: 'commit-set', eventIntent: 'risk' }),
channels: ['sound', 'haptic'],
sound: { pitch: { op: 'add', value: -150 }, contour: 'descending' },
haptic: { kind: 'warning' }
}
]
};

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { KnobControlProvider } from '../knob-provider.svelte';
import type { KnobControlProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'knob-control'),
children,
child,
...restProps
}: KnobControlProps = $props();
const state = KnobControlProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,21 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { createId } from '$active-uix/id';
import { KnobHiddenInputProvider } from '../knob-provider.svelte';
import type { KnobHiddenInputProps } from '../types';
const uid = $props.id();
let { id = createId(uid, 'knob-hidden-input') }: KnobHiddenInputProps = $props();
let ref = $state<HTMLInputElement | null>(null);
const part = KnobHiddenInputProvider.create({
id: readableActive(() => id),
ref: writableActive<HTMLElement | null>(
() => ref,
(v) => (ref = v as HTMLInputElement | null)
)
});
</script>
<input bind:this={ref} {...part.props} />

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { KnobIndicatorProvider } from '../knob-provider.svelte';
import type { KnobIndicatorProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'knob-indicator'),
children,
child,
...restProps
}: KnobIndicatorProps = $props();
const state = KnobIndicatorProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { KnobLabelProvider } from '../knob-provider.svelte';
import type { KnobLabelProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'knob-label'),
children,
child,
...restProps
}: KnobLabelProps = $props();
const state = KnobLabelProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { KnobValueTextProvider } from '../knob-provider.svelte';
import type { KnobValueTextProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'knob-value-text'),
children,
child,
...restProps
}: KnobValueTextProps = $props();
const state = KnobValueTextProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -0,0 +1,71 @@
<!-- src/uix/soma/components/knob/components/knob.svelte -->
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { KnobProvider } from '../knob-provider.svelte';
import type { KnobProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'knob'),
value = $bindable(0),
min = 0,
max = 100,
step = 1,
largeStep = 10,
defaultValue = min,
startAngle = -135,
endAngle = 135,
disabled = false,
readonly = false,
invalid = false,
intent = 'neutral',
name = undefined,
formatValue = undefined,
onValueChange = () => {},
onValueCommit = () => {},
children,
child,
...restProps
}: KnobProps = $props();
const state = KnobProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => (value = v)
),
min: readableActive(() => min),
max: readableActive(() => max),
step: readableActive(() => step),
largeStep: readableActive(() => largeStep),
defaultValue: readableActive(() => defaultValue),
startAngle: readableActive(() => startAngle),
endAngle: readableActive(() => endAngle),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
invalid: readableActive(() => invalid),
intent: readableActive(() => intent),
name: readableActive(() => name),
formatValue: readableActive(() => formatValue),
onValueChange: readableActive(() => onValueChange),
onValueCommit: readableActive(() => onValueCommit)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,16 @@
export { default as Provider } from './components/knob.svelte';
export { default as Control } from './components/knob-control.svelte';
export { default as Indicator } from './components/knob-indicator.svelte';
export { default as Label } from './components/knob-label.svelte';
export { default as ValueText } from './components/knob-value-text.svelte';
export { default as HiddenInput } from './components/knob-hidden-input.svelte';
export type {
KnobIntent,
KnobProps as ProviderProps,
KnobControlProps as ControlProps,
KnobIndicatorProps as IndicatorProps,
KnobLabelProps as LabelProps,
KnobValueTextProps as ValueTextProps,
KnobHiddenInputProps as HiddenInputProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,195 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { KnobProvider, KnobControlProvider } from './knob-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(KnobProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function createKnobRoot() {
const root = document.createElement('div');
root.getBoundingClientRect = vi.fn(
() =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: 200,
bottom: 200,
width: 200,
height: 200,
toJSON: () => ({})
}) as DOMRect
);
root.setPointerCapture = vi.fn();
root.releasePointerCapture = vi.fn();
document.body.appendChild(root);
return root;
}
function knobOpts(root = createKnobRoot(), commit = vi.fn()) {
return {
id: state('knob-root'),
ref: state<HTMLElement | null>(root),
value: state(20),
min: state(0),
max: state(100),
step: state(5),
largeStep: state(10),
defaultValue: state(0),
startAngle: state(-135),
endAngle: state(135),
disabled: state(false),
readonly: state(false),
invalid: state(false),
intent: state<'neutral' | 'affirm' | 'risk' | 'threat'>('neutral'),
name: state<string | undefined>('gain'),
formatValue: state<((v: number) => string) | undefined>(undefined),
onValueChange: state<((v: number) => void) | undefined>(undefined),
onValueCommit: state<((v: number) => void) | undefined>(commit)
};
}
function makeControl() {
return KnobControlProvider.create({
id: state('knob-control'),
ref: state<HTMLElement | null>(document.createElement('div'))
});
}
function keyEvent(key: string): KeyboardEvent {
return { key, preventDefault: vi.fn() } as unknown as KeyboardEvent;
}
describe('KnobProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('exposes the provider marker + the control ARIA slider contract, wired to Gesture.rotate', () => {
const { dom } = installSomaHarness();
const opts = knobOpts();
const { result, cleanup } = withEffectRoot(() => {
const provider = KnobProvider.create(opts);
vi.spyOn(KnobProvider, 'require').mockReturnValue(provider);
const control = makeControl();
return { provider, control };
});
expect(result.provider.props).toMatchObject({ id: 'knob-root', 'data-knob': '' });
expect(result.control.props).toMatchObject({
role: 'slider',
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': 20,
tabindex: 0
});
// Gesture.rotate wired: the control carries the gesture's pointerdown handler.
expect(typeof result.control.props.onpointerdown).toBe('function');
cleanup();
dom.dispose();
});
it('steps the value with the keyboard and commits', () => {
const { dom } = installSomaHarness();
const commit = vi.fn();
const opts = knobOpts(createKnobRoot(), commit);
const { result, cleanup } = withEffectRoot(() => {
const provider = KnobProvider.create(opts);
vi.spyOn(KnobProvider, 'require').mockReturnValue(provider);
return { control: makeControl() };
});
const onkeydown = result.control.props.onkeydown as (e: KeyboardEvent) => void;
onkeydown(keyEvent('ArrowUp'));
expect(opts.value.current).toBe(25); // 20 + step(5)
onkeydown(keyEvent('ArrowDown'));
expect(opts.value.current).toBe(20);
onkeydown(keyEvent('PageUp'));
expect(opts.value.current).toBe(30); // + largeStep(10)
onkeydown(keyEvent('Home'));
expect(opts.value.current).toBe(0);
onkeydown(keyEvent('End'));
expect(opts.value.current).toBe(100);
expect(commit).toHaveBeenCalled();
cleanup();
dom.dispose();
});
it('does not step when disabled', () => {
const { dom } = installSomaHarness();
const opts = knobOpts();
opts.disabled.current = true;
const { result, cleanup } = withEffectRoot(() => {
const provider = KnobProvider.create(opts);
vi.spyOn(KnobProvider, 'require').mockReturnValue(provider);
return { control: makeControl() };
});
(result.control.props.onkeydown as (e: KeyboardEvent) => void)(keyEvent('ArrowUp'));
expect(opts.value.current).toBe(20); // unchanged
cleanup();
dom.dispose();
});
it('resets to defaultValue on double-click', () => {
const { dom } = installSomaHarness();
const commit = vi.fn();
const opts = knobOpts(createKnobRoot(), commit);
opts.value.current = 77;
opts.defaultValue.current = 42;
const { result, cleanup } = withEffectRoot(() => {
const provider = KnobProvider.create(opts);
vi.spyOn(KnobProvider, 'require').mockReturnValue(provider);
return { control: makeControl() };
});
(result.control.props.ondblclick as () => void)();
expect(opts.value.current).toBe(42);
expect(commit).toHaveBeenCalled();
cleanup();
dom.dispose();
});
});

@ -0,0 +1,380 @@
// src/uix/soma/components/knob/knob-provider.svelte.ts
//
// Headless behavior for Knob. Follows the closed architecture: the morfo
// declares, this provider only supplies sources, targets and handlers. The
// radial pointer mechanics live in the shared `Gesture.rotate` layer; this
// provider only maps the reported angle onto the value sweep.
import { context, type WithRefOpts } from '../../provider';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { KEYS } from '../../keyboard';
import { Gesture, type GestureRotateState } from '../../layers/gesture';
import { boolToEmptyStrOrUndef } from '$adom';
import { readableActive, state, type State } from '$libs/reactive';
import { knobMorfo } from '../../../morfo/components/knob';
import type { KnobStateFields, KnobActiveFields } from './types';
import type { StateProps, ActiveProps } from '$libs/reactive';
interface KnobOpts
extends WithRefOpts,
StateProps<KnobStateFields>,
ActiveProps<KnobActiveFields> {}
function clamp(v: number, min: number, max: number) {
return Math.min(max, Math.max(min, v));
}
function snapToStep(v: number, min: number, step: number) {
const snapped = min + Math.round((v - min) / step) * step;
// Avoid FP drift on non-integer steps.
const decimals = (String(step).split('.')[1] ?? '').length;
return Number(snapped.toFixed(decimals));
}
export class KnobProvider {
static readonly ctx = context<KnobProvider>('Knob');
static get() {
return this.ctx.getOr(undefined) as KnobProvider | undefined;
}
static require() {
return this.ctx.get();
}
readonly opts: KnobOpts;
readonly soma: Soma;
readonly runtime: SomaRuntime;
readonly runtimePart: SomaRuntimePart;
// Ids published by optional sub-parts (A30: direct assignment, no $effect).
readonly labelId: State<string | undefined> = state<string | undefined>(undefined);
// Gesture state
dragging = $state(false);
private valueOnPick = 0;
private pickFired = false;
readonly gesture: GestureRotateState;
static create(opts: KnobOpts) {
return new KnobProvider(opts);
}
private constructor(opts: KnobOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(knobMorfo, {
states: {},
props: {
value: () => this.opts.value.current,
min: () => this.opts.min.current,
max: () => this.opts.max.current,
disabled: () => this.opts.disabled.current,
readonly: () => this.opts.readonly.current,
invalid: () => this.opts.invalid.current,
intent: () => this.opts.intent.current
},
parts: {
label: () => this.labelId.current
},
events: {
// Handlers are synchronous; guards live at the call-site.
'handle-pick': () => {
this.dragging = true;
this.valueOnPick = this.opts.value.current;
},
'handle-drop': () => {
this.dragging = false;
},
'commit-set': () => {
// State was already mutated live during drag / by the keyboard
// step method before trigger(). commit-set is the perceptual
// boundary; the structural effects re-derive from state.
this.opts.onValueCommit.current?.(this.opts.value.current);
},
'commit-reset': () => {
this.opts.value.current = this.opts.defaultValue.current;
this.opts.onValueCommit.current?.(this.opts.value.current);
}
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: KnobProvider.ctx,
syncAttrs: true
});
// Radial pointer mechanics live in the shared gesture layer; this provider
// only maps the reported absolute angle onto the value sweep. Created in the
// constructor body (not a field initializer) so `soma`/`opts` are set.
this.gesture = Gesture.rotate({
dom: this.soma.dom,
ref: this.opts.ref,
center: readableActive(() => 'element' as const),
enabled: readableActive(() => !this.opts.disabled.current && !this.opts.readonly.current),
onRotate: (_e, gestureState) => {
if (!this.pickFired) {
this.pickFired = true;
this.valueOnPick = this.opts.value.current;
void this.runtime.trigger('handle-pick'); // sequence 'post' → handler first
}
const start = this.opts.startAngle.current;
const end = this.opts.endAngle.current;
// angleAbsolute is 0°=up, clockwise+, [0, 360). Fold into (-180, 180]
// to match a sweep declared symmetrically around up (e.g. -135..135).
const signed =
gestureState.angleAbsolute > 180
? gestureState.angleAbsolute - 360
: gestureState.angleAbsolute;
const a = clamp(signed, start, end);
const { min, max } = this.range();
this.setValue(min + ((a - start) / (end - start)) * (max - min), false);
},
onRelease: () => {
this.pickFired = false;
void this.runtime.trigger('handle-drop');
if (this.opts.value.current !== this.valueOnPick) {
void this.runtime.trigger('commit-set');
}
}
});
}
// ---- geometry -----------------------------------------------------------
/** 0..1 position of value inside [min, max]. */
readonly progress = $derived.by(() => {
const { min, max } = this.range();
const span = max - min;
return span === 0 ? 0 : (this.opts.value.current - min) / span;
});
/** Current pointer angle in degrees within the declared sweep. */
readonly angle = $derived.by(() => {
const start = this.opts.startAngle.current;
const end = this.opts.endAngle.current;
return start + this.progress * (end - start);
});
private range() {
return { min: this.opts.min.current, max: this.opts.max.current };
}
// ---- value mutation -----------------------------------------------------
setValue(next: number, commit: boolean) {
const { min, max } = this.range();
const snapped = clamp(snapToStep(next, min, this.opts.step.current), min, max);
if (snapped !== this.opts.value.current) {
this.opts.value.current = snapped;
this.opts.onValueChange.current?.(snapped);
}
if (commit) void this.runtime.trigger('commit-set');
}
stepBy(delta: number) {
this.setValue(this.opts.value.current + delta, true);
}
// Root container props (`data-knob` + id). Sub-part attrs live on their own
// providers; syncAttrs applies the morfo-derived attrs to the ref directly.
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ---- Control ----------------------------------------------------------------
interface KnobControlOpts extends WithRefOpts {}
export class KnobControlProvider {
static create(opts: KnobControlOpts) {
return new KnobControlProvider(opts);
}
readonly opts: KnobControlOpts;
readonly provider: KnobProvider;
readonly runtimePart: SomaRuntimePart;
private constructor(opts: KnobControlOpts) {
this.opts = opts;
this.provider = KnobProvider.require();
this.runtimePart = this.provider.runtime.part('control', {
id: opts.id,
ref: opts.ref,
owner: this
// no syncAttrs: renderProps() path — we override aria-valuetext.
});
}
private onkeydown = (e: KeyboardEvent) => {
const p = this.provider;
// Guards at call-site: a disabled knob must not emit perceptual signals.
if (p.opts.disabled.current || p.opts.readonly.current) return;
const step = p.opts.step.current;
const large = p.opts.largeStep.current;
switch (e.key) {
case KEYS.ARROW_UP:
case KEYS.ARROW_RIGHT:
e.preventDefault();
return p.stepBy(step);
case KEYS.ARROW_DOWN:
case KEYS.ARROW_LEFT:
e.preventDefault();
return p.stepBy(-step);
case 'PageUp':
e.preventDefault();
return p.stepBy(large);
case 'PageDown':
e.preventDefault();
return p.stepBy(-large);
case 'Home':
e.preventDefault();
return p.setValue(p.opts.min.current, true);
case 'End':
e.preventDefault();
return p.setValue(p.opts.max.current, true);
}
};
private ondblclick = () => {
const p = this.provider;
if (p.opts.disabled.current || p.opts.readonly.current) return;
void p.runtime.trigger('commit-reset');
};
/** Locale-formatted value for AT, via the soma format services. */
private formatValue(): string {
const p = this.provider;
const v = p.opts.value.current;
const fmt = p.opts.formatValue.current;
if (fmt) return fmt(v);
return p.soma.nums?.format(v) ?? String(v);
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.renderProps(), // role=slider, aria-value*, data-*
tabindex: this.provider.opts.disabled.current ? -1 : 0,
'aria-valuetext': this.formatValue(), // soma owns the *value* (formatting)
'data-dragging': boolToEmptyStrOrUndef(this.provider.dragging),
// Provider-published data for the visual layer (A8: functional CSS).
style: `--knob-progress: ${this.provider.progress}; --knob-angle: ${this.provider.angle}deg;`,
// Gesture props (onpointerdown) + local handlers.
...this.provider.gesture.props,
onkeydown: this.onkeydown,
ondblclick: this.ondblclick,
// Required for pointer drag (A15).
// touch-action lives here, not in eidos: functional, not visual.
// (merged into style by mergeProps at the wrapper)
} as const)
);
}
// ---- Indicator --------------------------------------------------------------
interface KnobIndicatorOpts extends WithRefOpts {}
export class KnobIndicatorProvider {
static create(opts: KnobIndicatorOpts) {
return new KnobIndicatorProvider(opts);
}
readonly runtimePart: SomaRuntimePart;
readonly provider: KnobProvider;
private constructor(opts: KnobIndicatorOpts) {
this.provider = KnobProvider.require();
// Decorative pointer/arc — positioned by --knob-angle from the provider.
this.runtimePart = this.provider.runtime.part('indicator', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ---- Label / ValueText / HiddenInput ------------------------------------------
interface KnobLabelOpts extends WithRefOpts {}
export class KnobLabelProvider {
static create(opts: KnobLabelOpts) {
return new KnobLabelProvider(opts);
}
readonly runtimePart: SomaRuntimePart;
readonly provider: KnobProvider;
private constructor(opts: KnobLabelOpts) {
this.provider = KnobProvider.require();
// A30: one-shot id registration in the constructor — never $effect.
this.provider.labelId.current = opts.id.current;
this.runtimePart = this.provider.runtime.part('label', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
onclick: () => {
// Label click focuses the control, native-label ergonomics.
// TODO(api): focus via this.provider.soma.dom (imperative focus is
// ActiveDom-managed per §3.7).
}
} as const)
);
}
interface KnobValueTextOpts extends WithRefOpts {}
export class KnobValueTextProvider {
static create(opts: KnobValueTextOpts) {
return new KnobValueTextProvider(opts);
}
readonly runtimePart: SomaRuntimePart;
readonly provider: KnobProvider;
private constructor(opts: KnobValueTextOpts) {
this.provider = KnobProvider.require();
this.runtimePart = this.provider.runtime.part('value-text', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props } as const));
}
interface KnobHiddenInputOpts extends WithRefOpts {}
export class KnobHiddenInputProvider {
static create(opts: KnobHiddenInputOpts) {
return new KnobHiddenInputProvider(opts);
}
readonly runtimePart: SomaRuntimePart;
readonly provider: KnobProvider;
private constructor(opts: KnobHiddenInputOpts) {
this.provider = KnobProvider.require();
this.runtimePart = this.provider.runtime.part('hidden-input', {
id: opts.id,
ref: opts.ref,
owner: this,
syncAttrs: true
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'hidden',
name: this.provider.opts.name.current,
value: String(this.provider.opts.value.current),
disabled: this.provider.opts.disabled.current || undefined
} as const)
);
}

@ -0,0 +1,99 @@
// src/uix/soma/components/knob/types.ts
//
// Public props + canonical field shapes (defined ONCE here, referenced by
// provider Opts via StateProps<> / ActiveProps<>).
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { PrimitiveDivAttributes } from '../../types';
export type KnobIntent = 'neutral' | 'affirm' | 'risk' | 'threat';
export type KnobStateFields = {
/** Current value. Bindable. */
value: number;
};
export type KnobActiveFields = {
/** Lower bound. @default 0 */
min: number;
/** Upper bound. @default 100 */
max: number;
/** Step for arrow keys and value snapping. @default 1 */
step: number;
/** Step for PageUp / PageDown. @default 10 */
largeStep: number;
/** Value restored by double-click (commit-reset). @default min */
defaultValue: number;
/** Sweep start angle in degrees, 0 = up, clockwise. @default -135 */
startAngle: number;
/** Sweep end angle in degrees. @default 135 */
endAngle: number;
/** Disables interaction and focus. @default false */
disabled: boolean;
/** Prevents changes but keeps the control focusable. @default false */
readonly: boolean;
/** Marks the value as invalid (aria-invalid + data-invalid). @default false */
invalid: boolean;
/**
* Evaluative load of commit-set emissions. A gain knob past 0dB may set
* 'risk'; sema and eidos tint the commit accordingly. @default 'neutral'
*/
intent: KnobIntent;
/** Form field name; when set, compose <Knob.HiddenInput>. */
name: string | undefined;
/** Custom aria-valuetext formatter. Defaults to locale number format. */
formatValue: ((value: number) => string) | undefined;
/** Fires on every value change (during drag and keyboard steps). */
onValueChange: OnChangeFn<number> | undefined;
/** Fires when a value is committed (release / keyboard step / reset). */
onValueCommit: OnChangeFn<number> | undefined;
};
export type KnobProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Current value. Bindable. @default min */
value?: number;
/** Lower bound. @default 0 */
min?: number;
/** Upper bound. @default 100 */
max?: number;
/** Arrow-key step and snapping grid. @default 1 */
step?: number;
/** PageUp/PageDown step. @default 10 */
largeStep?: number;
/** Double-click reset target. @default min */
defaultValue?: number;
/** Sweep start angle (deg, 0 = up, clockwise). @default -135 */
startAngle?: number;
/** Sweep end angle (deg). @default 135 */
endAngle?: number;
/** @default false */
disabled?: boolean;
/** Focusable but not editable. @default false */
readonly?: boolean;
/** @default false */
invalid?: boolean;
/** Evaluative load of commits. @default 'neutral' */
intent?: KnobIntent;
/** Form field name; compose <Knob.HiddenInput> alongside. */
name?: string;
/** Custom aria-valuetext formatter. */
formatValue?: (value: number) => string;
/** Fires on every value change. */
onValueChange?: OnChangeFn<number>;
/** Fires on commit (release / keyboard step / reset). */
onValueCommit?: OnChangeFn<number>;
}> &
Without<PrimitiveDivAttributes, {}>;
export type KnobControlProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
export type KnobIndicatorProps = KnobControlProps;
export type KnobLabelProps = KnobControlProps;
export type KnobValueTextProps = KnobControlProps;
export type KnobHiddenInputProps = { id?: string };
Loading…
Cancel
Save

Powered by TurnKey Linux.