fix(contracts): catalogue-guards census — 12 -> 3 failures (knob/menu-dial/onion-menu + unmasked debt)

The "3 ajenos" census. What the 12 pre-existing failures really were: a few
genuine violations + several guards CRASHING on menu-dial's missing
exports.ts (ENOENT) and hiding their true violation lists. All addressed
except the two runtime-discipline files (see tail).

- knob: soma barrel export + the missing soma README (headless contract —
  parts/ARIA/absolute angular keyboard/props/published CSS vars/sema table).
- menu-dial + onion-menu: joined SOMA_INTERNAL_PRIMITIVES — both are
  documented provider-less bridges (keyboard dispatch + nav maths + emission;
  the eidos root owns state and render). The scope guard now subtracts the
  internal set from BOTH sides so a bridge may truthfully keep 'soma' in its
  morfo scope without being a public namespace. Self-alias imports fixed;
  the two morfo docblocks realigned to the implementation ("soma owns open"
  was drift — the bridge files own dispatch/emission only; onion-menu's
  stale "family-default, no pack" tail also corrected, S3b shipped its pack).
- Public barrels decoupled from provider implementation filenames (the
  picker `internals.ts` house pattern) for the 6 offenders: calendar,
  cropper, field-langs, gradient-builder, gradient-picker, image-adjustments.
- listSourceFiles now skips ACTIVE_DEV_TRACK paths (words/palabras/chronos)
  — the track doctrine already said catalogue guards skip them; the
  file-scanning guards just didn't.
- data-archetype added to the known morfo attrs (compiler-emitted from part
  archetype declarations); the four field-overlay picker triggers
  (color-picker, date-range-picker, time-picker, time-range-picker) declared
  archetype 'trigger' while stamping 'field-trigger' at runtime — morfos
  aligned to the date-picker/ntp precedent (contract now matches the DOM;
  zero runtime change, the stamp already won).
- float-panel: the consumer-placed data-float-panel-no-drag opt-out marker
  is now declared in the morfo (presence entry with the consumer-marker
  note) — the NON_DRAG_SELECTOR contract stops being invisible.

Remaining tail (3 failures, deliberately deferred to a dedicated
runtime-discipline session with browser gesture verification):
float-panel-provider direct style writes + raw timer (drag/resize hot path
-> dom.apply/uix.timers migration) and media-player media-provider raw
listeners (-> dom.listen).

Gates: contracts 34/37 (was 25/37) · component-audit 102 PASS · vocab exit 0
· type baseline 57 · eidos-lint invalid 0 on the four touched pickers ·
compile + eidos contract tests 42/42.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent cbd86c35a6
commit cb554ae2e7

@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { basename, dirname, join, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
@ -65,6 +65,17 @@ function listSourceFiles(root: string): string[] {
.flatMap((entry) => listSourceFiles(join(root, entry)))
.filter(
(path) => !path.endsWith('.test.ts') && !path.includes(`${join('components', 'icon', 'lib')}`)
)
// Active-dev-track components are excluded from the audited catalogue
// (same doctrine as collectPublicSomaComponentDirs) — their files are
// skipped by every file-scanning guard until their track lands.
.filter(
(path) =>
![...ACTIVE_DEV_TRACK].some(
(name) =>
path.includes(`${join('components', name)}${sep}`) ||
path.endsWith(`${join('components', `${name}.ts`)}`)
)
);
}
@ -105,6 +116,11 @@ function collectKnownMorfoDataAttrs(): Set<string> {
if (dataMatch[1].startsWith('data-')) attrs.add(dataMatch[1]);
}
}
// Compiler-emitted from every part's `archetype` declaration — a known
// name catalogue-wide. Composition overrides (a field-overlay trigger
// stamped over the composed Popover trigger's generic archetype) write it
// manually with the value their OWN morfo part declares.
attrs.add('data-archetype');
return attrs;
}
@ -136,11 +152,16 @@ function toPascalName(kebab: string): string {
// catalogues, otherwise the asymmetry produces a false positive.
// Soma modules that are NOT public component namespaces: shared chrome or
// behaviour bridges consumed by other layers, with no Provider/parts API of
// their own. picker-shell = the pickers' shared footer chrome; metrics = the
// runtime bridge the eidos-native Metrics root uses to fire its live-update
// signal (documented in soma/components/metrics/index.ts — "no provider/state
// class"; morfo scope is ['sema','eidos'] on purpose).
const SOMA_INTERNAL_PRIMITIVES = new Set(['picker-shell', 'metrics']);
// their own (each documents the split in its index.ts — the eidos root owns
// state + render; the bridge owns runtime emission / keyboard dispatch / pure
// nav maths). They are excluded from BOTH sides of the dir↔scope equality:
// a bridge may truthfully declare 'soma' in its morfo scope (it implements
// soma behaviour) without being a public soma component namespace.
// picker-shell — the pickers' shared footer chrome (no morfo of its own)
// metrics — live-update signal bridge (scope ['sema','eidos'])
// menu-dial — keyboard dispatch + linear nav maths + emission bridge
// onion-menu — keyboard dispatch + radial nav maths + emission bridge
const SOMA_INTERNAL_PRIMITIVES = new Set(['picker-shell', 'metrics', 'menu-dial', 'onion-menu']);
function collectPublicSomaComponentDirs(): string[] {
const root = join(HERE, 'soma', 'components');
@ -592,7 +613,13 @@ describe('UIX layer contracts', () => {
});
it('guards Soma public component modules against Morfo soma scope', () => {
expect(collectPublicSomaComponentDirs()).toEqual(collectMorfoComponentNamesByScope('soma'));
// Internal bridges may declare 'soma' truthfully (they implement soma
// behaviour) without being public namespaces — subtract them so the
// dir↔scope equality compares public surfaces on both sides.
const publicSomaScoped = collectMorfoComponentNamesByScope('soma').filter(
(name) => !SOMA_INTERNAL_PRIMITIVES.has(name)
);
expect(collectPublicSomaComponentDirs()).toEqual(publicSomaScoped);
});
it("guards every component with an eidos recipe declares 'eidos' in morfo scope (SYS-1)", () => {

@ -114,7 +114,11 @@ export const colorPickerMorfo = {
{
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
// Field-overlay trigger: composed ON the Popover trigger (generic
// `trigger` stamp) but presented flush as `field-trigger` — the soma
// component overrides the stamp with THIS declared archetype (same
// pattern as date-picker / natural-time-picker).
archetype: 'field-trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',

@ -78,7 +78,11 @@ export const dateRangePickerMorfo = {
{
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
// Field-overlay trigger: composed ON the Popover trigger (generic
// `trigger` stamp) but presented flush as `field-trigger` — the soma
// component overrides the stamp with THIS declared archetype (same
// pattern as date-picker / natural-time-picker).
archetype: 'field-trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',

@ -151,6 +151,11 @@ export const floatPanelMorfo = {
},
{ attr: 'data-dragging', value: v.propRef('dragging'), severity: 'optional' },
{ attr: 'data-resizing', value: v.propRef('resizing'), severity: 'optional' },
// CONSUMER-placed opt-out marker (never emitted by the runtime):
// elements carrying it inside a `dragHandle="content"` panel do not
// start a drag — the provider's NON_DRAG_SELECTOR consumes it
// alongside the native interactive tags.
{ attr: 'data-float-panel-no-drag', severity: 'optional' },
// Which element grabs the drag — lets the recipe put the grab cursor
// on the whole surface when `dragHandle="content"`. `values` makes the
// runtime emit the STRING (header/content), not a boolean presence.

@ -8,10 +8,12 @@ import { v } from '../types';
* OnionMenu's. By APG it's a Menu Button: the trigger (`aria-haspopup="menu"`)
* opens a `role="menu"` list of `menuitem` actions.
*
* scope: ['soma','sema','eidos'] — soma owns open + the linear keyboard nav,
* focus and dismiss; eidos composes `<Fab>` (trigger) + `IconButton` (actions)
* and lays the fan out by `direction` (no SVG — plain flex); sema picks up
* open/close from `emerge` and commit-select from `commit` (family defaults).
* scope: ['soma','sema','eidos'] — soma is a headless BRIDGE (keyboard-action
* dispatch, linear nav maths, perceptual emission; the eidos root owns the
* open state and render — see soma/components/menu-dial/index.ts); eidos
* composes `<Fab>` (trigger) + `IconButton` (actions) and lays the fan out by
* `direction` (no SVG — plain flex); sema picks up open/close from `emerge`
* and commit-select from `commit` (family defaults).
*/
export const menuDialMorfo = {
name: 'MenuDial',

@ -8,10 +8,12 @@ import { v } from '../types';
* to a trail). It is a Menu Button by APG — the visual is radial, the semantics
* are a `role="menu"` with `menuitem`s opened by a `aria-haspopup` trigger.
*
* scope: ['soma','sema','eidos'] — soma owns the open + drill state, the radial
* keyboard navigation and focus/dismiss; eidos renders the SVG rings from the
* geometry + colour engines; sema picks up open/close from the `emerge` family
* base and commit-select from `commit` (expression: 'family-default', no pack).
* scope: ['soma','sema','eidos'] — soma is a headless BRIDGE (keyboard-action
* dispatch, radial nav maths, perceptual emission; the eidos root owns the
* open + drill state and the SVG render — see
* soma/components/onion-menu/index.ts); eidos renders the SVG rings from the
* geometry + colour engines; sema tunes the radial character via its pack
* (S3b, `expression: 'pack'` below).
*/
export const onionMenuMorfo = {
name: 'OnionMenu',

@ -81,7 +81,11 @@ export const timePickerMorfo = {
{
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
// Field-overlay trigger: composed ON the Popover trigger (generic
// `trigger` stamp) but presented flush as `field-trigger` — the soma
// component overrides the stamp with THIS declared archetype (same
// pattern as date-picker / natural-time-picker).
archetype: 'field-trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',

@ -87,7 +87,11 @@ export const timeRangePickerMorfo = {
{
name: 'Trigger',
kebab: 'trigger',
archetype: 'trigger',
// Field-overlay trigger: composed ON the Popover trigger (generic
// `trigger` stamp) but presented flush as `field-trigger` — the soma
// component overrides the stamp with THIS declared archetype (same
// pattern as date-picker / natural-time-picker).
archetype: 'field-trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',

@ -16,7 +16,7 @@ export { default as Day } from './components/calendar-day.svelte';
// The provider class is exported so the eidos view-switch parts
// (Heading button, MonthView, YearView) can read `view` + drive
// `advanceView` / `selectMonthView` / `selectYearView` off the live context.
export { CalendarProvider } from './calendar-provider.svelte';
export { CalendarProvider } from './internals';
// Shared navigation contract: both CalendarProvider and RangeCalendarProvider
// publish themselves here so ONE set of refined chrome (month/year selectors,

@ -0,0 +1,6 @@
// Indirection layer for the public barrel.
//
// Soma's `exports.ts` must not re-export provider implementation filenames
// directly. This plain-`.ts` module is the canonical re-export point —
// consumers reach it through `$soma/components/calendar` via `exports.ts`.
export { CalendarProvider } from './calendar-provider.svelte';

@ -4,7 +4,7 @@ export { default as Selection } from './components/cropper-selection.svelte';
export { default as Grid } from './components/cropper-grid.svelte';
export { default as Handle } from './components/cropper-handle.svelte';
export { moveRect, resizeRect } from './cropper-provider.svelte';
export { moveRect, resizeRect } from './internals';
export type {
CropRect,

@ -0,0 +1,6 @@
// Indirection layer for the public barrel.
//
// Soma's `exports.ts` must not re-export provider implementation filenames
// directly. This plain-`.ts` module is the canonical re-export point —
// consumers reach it through `$soma/components/cropper` via `exports.ts`.
export { moveRect, resizeRect } from './cropper-provider.svelte';

@ -4,7 +4,7 @@
export { default as Provider } from './components/field-langs.svelte';
export { FieldLangsProvider } from './field-langs-provider.svelte';
export { FieldLangsProvider } from './internals';
export type {
FieldLangsProviderProps as ProviderProps,

@ -0,0 +1,6 @@
// Indirection layer for the public barrel.
//
// Soma's `exports.ts` must not re-export provider implementation filenames
// directly. This plain-`.ts` module is the canonical re-export point —
// consumers reach it through `$soma/components/field-langs` via `exports.ts`.
export { FieldLangsProvider } from './field-langs-provider.svelte';

@ -6,7 +6,7 @@ export {
GradientBuilderProvider,
GradientStopProvider,
GradientTrackProvider
} from './gradient-builder-provider.svelte';
} from './internals';
export type {
GradientBuilderProps as ProviderProps,

@ -0,0 +1,10 @@
// Indirection layer for the public barrel.
//
// Soma's `exports.ts` must not re-export provider implementation filenames
// directly. This plain-`.ts` module is the canonical re-export point —
// consumers reach it through `$soma/components/gradient-builder` via `exports.ts`.
export {
GradientBuilderProvider,
GradientStopProvider,
GradientTrackProvider
} from './gradient-builder-provider.svelte';

@ -7,7 +7,7 @@ export {
GradientPickerTriggerProvider,
GradientPickerValueSwatchProvider,
DEFAULT_PICKER_GRADIENT
} from './gradient-picker-provider.svelte';
} from './internals';
export type {
GradientPickerProviderProps as ProviderProps,

@ -0,0 +1,11 @@
// Indirection layer for the public barrel.
//
// Soma's `exports.ts` must not re-export provider implementation filenames
// directly. This plain-`.ts` module is the canonical re-export point —
// consumers reach it through `$soma/components/gradient-picker` via `exports.ts`.
export {
GradientPickerProvider,
GradientPickerTriggerProvider,
GradientPickerValueSwatchProvider,
DEFAULT_PICKER_GRADIENT
} from './gradient-picker-provider.svelte';

@ -8,7 +8,7 @@ export {
IMAGE_ADJUSTMENTS,
DEFAULT_IMAGE_ADJUSTMENTS,
computeImageFilter
} from './image-adjustments-provider.svelte';
} from './internals';
export type {
AdjustmentDef,

@ -0,0 +1,10 @@
// Indirection layer for the public barrel.
//
// Soma's `exports.ts` must not re-export provider implementation filenames
// directly. This plain-`.ts` module is the canonical re-export point —
// consumers reach it through `$soma/components/image-adjustments` via `exports.ts`.
export {
IMAGE_ADJUSTMENTS,
DEFAULT_IMAGE_ADJUSTMENTS,
computeImageFilter
} from './image-adjustments-provider.svelte';

@ -35,6 +35,7 @@ export * as GradientPicker from './gradient-picker';
export * as GridList from './grid-list';
export * as ImageAdjustments from './image-adjustments';
export * as ImagePicker from './image-picker';
export * as Knob from './knob';
export * as LinkPreview from './link-preview';
export * as Listbox from './listbox';
export * as MaskField from './mask-field';

@ -0,0 +1,97 @@
# Knob (soma)
Headless rotary value control — the WAI-ARIA **slider** pattern with radial
rendering. Continuous-shape like the Slider: a rAF-throttled `handle-drag`
rides the gesture, framed by `handle-pick` / `handle-drop` / `commit-set`.
Soma owns value, angular sweep, gesture, keyboard and ARIA; the eidos wrapper
adds the dial recipe.
Consumer door: [`eidos/components/knob/README.md`](../../../eidos/components/knob/README.md) ·
Morfo: [`morfo/components/knob.ts`](../../../morfo/components/knob.ts) ·
APG: [slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/).
## Anatomy
```svelte
<Knob.Provider bind:value min={0} max={11} defaultValue={5}>
<Knob.Control>
<Knob.Indicator />
</Knob.Control>
<Knob.Label>Gain</Knob.Label>
<Knob.ValueText />
<Knob.HiddenInput name="gain" />
</Knob.Provider>
```
## Parts
| Part | Element | Description |
| ------------- | ---------- | ------------------------------------------------------------------------------ |
| `Provider` | `<div>` | Root. `data-dragging` / `data-disabled` / `data-readonly` / `data-invalid` flags + the persistent `data-intent` enum. |
| `Control` | `<div>` | The interactive dial — `role="slider"` (focusable via tabindex), full ARIA + keyboard. Publishes the angular CSS vars. |
| `Indicator` | `<div>` | Decorative pointer/arc (`role="presentation"`, A2.3 exception) — positioned purely by the published vars. |
| `Label` | `<span>` | Visible label; the Control links it via `aria-labelledby` (or falls back to a translated `aria-label` when absent — `part-absent` condition). |
| `ValueText` | `<span>` | Visible readout, `aria-hidden` (the Control already carries `aria-valuetext`). |
| `ValueField` | `<div>` | The EDITABLE readout: composes a real `NumberField` wired to the knob's value (compound-in-component; event isolation). |
| `HiddenInput` | `<input>` | Form bridge, composed when `name` is set (A13). |
## ARIA (Control)
`aria-valuemin` / `aria-valuemax` / `aria-valuenow` + `aria-valuetext`
(locale-formatted by the provider via `formatValue`),
`aria-roledescription` = translated "rotary knob", `aria-labelledby` ↔ Label,
`aria-disabled` / `aria-readonly` / `aria-invalid` when the flags are on.
## Keyboard
Angular and ABSOLUTE — a deliberate A12 exception documented in the morfo:
rotation does not mirror in RTL (a rotary's increase direction is angular,
not inline).
| Key | Action |
| ---------------------------- | ---------------------------------------- |
| `ArrowUp` / `ArrowRight` | Increase by `step` |
| `ArrowDown` / `ArrowLeft` | Decrease by `step` |
| `PageUp` / `PageDown` | Increase / decrease by `largeStep` |
| `Home` / `End` | Jump to `min` / `max` |
| double-click | Reset to `defaultValue` (audio-console convention → `commit-reset`) |
## Props
| Prop | Type | Default | Notes |
| ----------------------- | -------------------------------------- | ---------- | -------------------------------------------------------- |
| `value` | `number` | — | Bindable. |
| `onValueChange` / `onValueCommit` | `OnChangeFn<number>` | — | Continuous / terminal. |
| `min` / `max` / `step` / `largeStep` | `number` | — | Range + quantisation. |
| `defaultValue` | `number` | — | The double-click reset anchor (knob KEEPS the prop — reasoned N8 exception). |
| `startAngle` / `endAngle` | `number` | — | The angular sweep. |
| `formatValue` | `(value: number) => string` | — | Feeds the localized `aria-valuetext`. |
| `intent` | `'neutral' \| 'affirm' \| 'risk' \| 'threat'` | `'neutral'` | Persistent evaluative state (`data-intent`) AND the `commit-set` intent via `fromProp` — a gain knob past 0dB can commit with `risk`. |
| `disabled` / `readonly` / `invalid` | `boolean` | `false` | Flags. |
| `name` | `string` | — | Renders the HiddenInput form bridge. |
## Published CSS variables
The provider inline-styles the Control with the angular geometry every frame
of the gesture — the recipe consumes them and never computes geometry from
layout reads:
`--knob-progress` (0…1) · `--knob-angle` (deg) · `--knob-start-angle` ·
`--knob-sweep`.
## Sema events
Pack: `sema/components/knob.ts` (`expression: 'pack'`).
| Name | Family | Verb | Sequence | Intent | Notes |
| -------------- | -------- | ------ | ------------ | ----------------- | ------------------------------------------------------------ |
| `handle-pick` | `handle` | `pick` | `post` | — | Gesture engages; pointer capture is not gated behind the hold (checkbox-lag doctrine). |
| `handle-drag` | `handle` | `drag` | `coincident` | — | rAF-throttled, per-emit payload (progress + angular velocity) — the Slider mirror. |
| `handle-drop` | `handle` | `drop` | `pre` | — | The release moment. The only `drop` declared in the handle census. |
| `commit-set` | `commit` | `set` | `post` | `fromProp` intent | Pointer release with a changed value AND every discrete keyboard step (A-3.7). |
| `commit-reset` | `commit` | `reset`| `post` | `neutral` | Double-click → `defaultValue`. |
## Suite
`knob-provider.svelte.test.ts` — the provider behaviour suite (value flow,
keyboard steps, gesture cycle).

@ -1,5 +1,5 @@
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { menuDialMorfo } from '$uix/morfo/components/menu-dial';
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '../../runtime.svelte';
import { menuDialMorfo } from '../../../morfo/components/menu-dial';
/**
* The MenuDial soma runtime — the headless behaviour bridge. Like OnionMenu, the

@ -1,5 +1,5 @@
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { onionMenuMorfo } from '$uix/morfo/components/onion-menu';
import { createSomaRuntime, type SomaRuntime, type SomaRuntimeSources } from '../../runtime.svelte';
import { onionMenuMorfo } from '../../../morfo/components/onion-menu';
/**
* The OnionMenu soma runtime. Unlike a compound provider, the OnionMenu's render

Loading…
Cancel
Save

Powered by TurnKey Linux.