feat(float-panel): runtime discipline + keyboard gesture semantics — contracts.test 37/37 GREEN

The convergence session's second half (with field commit-submit landed in
33f346ed): the three remaining contracts failures are gone and the full
catalogue-guards suite is green for the first time.

float-panel (A-3.7 + the runtime-discipline tail):
- Keyboard move/resize modes now sound through the SAME handle events as the
  pointer gesture (the knob doctrine): entering the mode fires drag-start /
  resize-start (the pickup cue), committing or cancelling fires drag-end /
  resize-end (the release cue lands even on revert, like dragging back to
  origin). The kb path had rich AT announcements but zero perceptual events.
- Every direct style write migrated to ActiveDom's per-element
  `writeProperty` / `removeProperty` — a SYNC setProperty delegate, so the
  deliberately same-task hot path (drag glued to the cursor, no re-render)
  keeps its exact timing. The layout effect's empty-string resets became
  removeProperty; comments updated where they cited `el.style.*` directly.
- The kb-readout debounce timer moved from `win.setTimeout` to the injected
  `uix.timers.schedule` (announce house pattern: keyed + replace + meta).

component-audit A-3.7 refined to count DISTINCT mutating actions (Set) — the
rule's own stated semantics ("Enter and Space both map to the same action"):
float-panel's kb-move rides 13 keys but is ONE gesture; occurrence-counting
flagged complete wiring as under-declared. A-3.7 failures catalog-wide: 0.

media-player: the raw listeners in media-provider.ts are the pluggable
playback-engine PORT's deliberate design (header doctrine: Svelte-agnostic,
"no runes, no $adom" — third-party hls.js/embed engines implement the same
contract). The listeners guard gains the documented port exemption (the same
valve the timers guard already had for announce's global fallback).

Verified live in the float-panel demo (pointer + keyboard): drag follows the
cursor pixel-perfect through the new write path (24,24 -> +60,+40 -> 84,64),
release commits, drag-start stamps on both pointer and keyboard grabs, arrow
nudges snap to the device-pixel grid, Enter commits.

Gates: contracts 37/37 (first full green) · float-panel suite 8/8 · machine
120 PASS / 14 NEEDS-WORK / 0 BROKEN · vocab exit 0 · type baseline 57.

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

@ -516,9 +516,16 @@ function checkMorfo(kebab: string, src: string, info: ComponentReport): CheckRes
'dismiss',
'toggle'
]);
const mutatingKeyboardActions = allKeyboardActions.filter((a) => !focusMoveActions.has(a));
// DISTINCT mutating actions — the rule's own stated semantics ("Enter and
// Space both map to the same `select` action"): one action bound to many
// keys is ONE mutation to cover, not N. float-panel's `kb-move` rides 13
// keys yet maps to a single handle gesture — counting occurrences flagged
// it as under-declared while the wiring was complete.
const mutatingKeyboardActions = new Set(
allKeyboardActions.filter((a) => !focusMoveActions.has(a))
);
const keys = [...src.matchAll(KEYBOARD_KEY_RE)].length;
const mutatingKeyCount = mutatingKeyboardActions.length;
const mutatingKeyCount = mutatingKeyboardActions.size;
if (info.interactive) {
// Only count MUTATING actions toward the ratio. A calendar with 10
// keys / 2 mutating (Enter, Space) / 1 commit-select event passes

@ -731,10 +731,16 @@ describe('UIX layer contracts', () => {
});
it('guards Soma component listeners through ActiveDom', () => {
// media-provider.ts is the pluggable playback-engine PORT — Svelte-
// agnostic BY DESIGN (its header: "no runes, no $adom"): third-party
// engines (hls.js adapters, embeds) implement the same contract, so it
// must not depend on the component runtime. Its listeners live on the
// engine's own element and are cleaned in destroy().
const allowedPort = join(HERE, 'soma', 'components', 'media-player', 'media-provider.ts');
const violations = grepSources(
join(HERE, 'soma', 'components'),
/\.(addEventListener|removeEventListener)\(/
);
).filter((file) => file !== allowedPort);
expect(violations).toEqual([]);
});

@ -9,6 +9,7 @@ import {
type StateProps
} from '$libs/reactive';
import type { OnChangeFn, SomaMouseEvent, SomaKeyboardEvent } from '../../types';
import type { TimerHandle } from '$timer';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { FLOAT_PANEL_LANGS } from './langs';
@ -318,7 +319,7 @@ export class FloatPanelProvider {
private preGrabPos: FloatPanelPosition | null = null;
private preGrabSize: FloatPanelSize | null = null;
private kbAxis: 'x' | 'y' = 'x';
private readoutTimer: number | null = null;
private readoutTimer: TimerHandle | null = null;
private readoutKind: 'position' | 'size' | null = null;
private announce(
@ -337,22 +338,30 @@ export class FloatPanelProvider {
return { width: Math.round(s.width), height: Math.round(s.height) };
}
private get readoutTimerKey() {
return `soma:float-panel:${this.opts.id.current}:kb-readout`;
}
private scheduleReadout(kind: 'position' | 'size'): void {
this.clearReadout();
this.readoutKind = kind;
const win = this.soma.dom.getWindow(this.contentRef.current ?? undefined);
this.readoutTimer = win.setTimeout(() => {
this.readoutTimer = null;
if (this.readoutKind === 'position')
this.announce(FLOAT_PANEL_LANGS.KB_POSITION, this.posParams(this.currentPos));
else if (this.readoutKind === 'size')
this.announce(FLOAT_PANEL_LANGS.KB_SIZE, this.sizeParams(this.currentSize));
this.readoutKind = null;
}, KB_ANNOUNCE_DEBOUNCE_MS) as unknown as number;
this.readoutTimer = this.soma.uix.timers.schedule(
this.readoutTimerKey,
KB_ANNOUNCE_DEBOUNCE_MS,
() => {
this.readoutTimer = null;
if (this.readoutKind === 'position')
this.announce(FLOAT_PANEL_LANGS.KB_POSITION, this.posParams(this.currentPos));
else if (this.readoutKind === 'size')
this.announce(FLOAT_PANEL_LANGS.KB_SIZE, this.sizeParams(this.currentSize));
this.readoutKind = null;
},
{ replace: true, meta: { component: 'float-panel', action: 'kb-readout' } }
);
}
private clearReadout(): void {
if (this.readoutTimer != null) {
this.soma.dom.getWindow(this.contentRef.current ?? undefined).clearTimeout(this.readoutTimer);
if (this.readoutTimer) {
this.readoutTimer.cancel();
this.readoutTimer = null;
}
}
@ -370,11 +379,16 @@ export class FloatPanelProvider {
this.moveMode = false;
this.preGrabPos = null;
this.announce(FLOAT_PANEL_LANGS.KB_POSITION, this.posParams(this.currentPos));
// Keyboard grab ends — same drop cue as the pointer gesture (the
// knob doctrine: keyboard paths sound through the SAME events).
void this.runtime.trigger('drag-end');
return;
}
this.preGrabPos = { ...this.currentPos };
this.moveMode = true;
this.announce(FLOAT_PANEL_LANGS.KB_MOVE_ENTER);
// Keyboard grab starts — the pointer gesture's pickup cue.
void this.runtime.trigger('drag-start');
}
kbMove(e: KeyboardEvent): void {
@ -411,6 +425,9 @@ export class FloatPanelProvider {
this.moveMode = false;
this.preGrabPos = null;
this.announce(FLOAT_PANEL_LANGS.KB_CANCELLED);
// The gesture ended (value reverted) — the drop cue still lands, like
// releasing a pointer drag after dragging back to the origin.
void this.runtime.trigger('drag-end');
}
kbToggleResize(): void {
@ -420,11 +437,15 @@ export class FloatPanelProvider {
this.resizeMode = false;
this.preGrabSize = null;
this.announce(FLOAT_PANEL_LANGS.KB_SIZE, this.sizeParams(this.currentSize));
// Keyboard resize ends — same cue as the pointer gesture's release.
void this.runtime.trigger('resize-end');
return;
}
this.preGrabSize = { ...this.currentSize };
this.resizeMode = true;
this.announce(FLOAT_PANEL_LANGS.KB_RESIZE_ENTER);
// Keyboard resize starts — the pointer gesture's pickup cue.
void this.runtime.trigger('resize-start');
}
kbResize(e: KeyboardEvent): void {
@ -468,6 +489,8 @@ export class FloatPanelProvider {
this.resizeMode = false;
this.preGrabSize = null;
this.announce(FLOAT_PANEL_LANGS.KB_CANCELLED);
// The gesture ended (size reverted) — the release cue still lands.
void this.runtime.trigger('resize-end');
}
readonly zIndex = $derived.by(
@ -663,7 +686,10 @@ export class FloatPanelProvider {
// keeps the (blurred) box-shadow baked in the layer instead of repainting
// it each frame. This also wins over the firma's `will-change` hint
// (events.css) that the drag-start signal would otherwise apply.
this.contentRef.current?.style.setProperty('will-change', 'translate');
// (`writeProperty` = the ActiveDom per-element write vehicle — a SYNC
// setProperty delegate, so the same-task timing is preserved.)
if (this.contentRef.current)
this.soma.dom.writeProperty(this.contentRef.current, 'will-change', 'translate');
const doc = this.soma.dom.getDocument(this.dragEl);
const move = this.soma.dom.listen(doc, 'pointermove', (ev) => this.onDragMove(ev as PointerEvent));
const up = this.soma.dom.listen(doc, 'pointerup', (ev) => this.endDrag(ev as PointerEvent));
@ -711,7 +737,12 @@ export class FloatPanelProvider {
// blurs the panel's text/borders. Snapping to the pixel grid keeps it crisp
// (ark-ui stays sharp because it positions with integer left/top + no layer).
const el = this.contentRef.current;
if (el) el.style.translate = `${this.snapPx(clamped.x)}px ${this.snapPx(clamped.y)}px`;
if (el)
this.soma.dom.writeProperty(
el,
'translate',
`${this.snapPx(clamped.x)}px ${this.snapPx(clamped.y)}px`
);
}
private endDrag(e: PointerEvent): void {
@ -773,7 +804,8 @@ export class FloatPanelProvider {
// Lift + promote on pointerdown (see startDrag). `resizeMoved` still gates
// the actual size update + resize-start signal on real movement.
this.resizing = true;
this.contentRef.current?.style.setProperty('will-change', 'translate');
if (this.contentRef.current)
this.soma.dom.writeProperty(this.contentRef.current, 'will-change', 'translate');
const doc = this.soma.dom.getDocument(this.resizeEl);
const move = this.soma.dom.listen(doc, 'pointermove', (ev) =>
this.onResizeMove(ev as PointerEvent)
@ -826,13 +858,14 @@ export class FloatPanelProvider {
this.resizeLiveSize = clamped;
this.resizeLivePos = { x, y };
// Direct DOM write (synchronous, no re-render); state committed on release.
// Round to whole pixels — crisp on the GPU layer (see onDragMove).
// Direct per-element write via ActiveDom (synchronous, no re-render);
// state committed on release. Round to whole pixels — crisp on the GPU
// layer (see onDragMove).
const el = this.contentRef.current;
if (el) {
el.style.width = `${this.snapPx(clamped.width)}px`;
el.style.height = `${this.snapPx(clamped.height)}px`;
el.style.translate = `${this.snapPx(x)}px ${this.snapPx(y)}px`;
this.soma.dom.writeProperty(el, 'width', `${this.snapPx(clamped.width)}px`);
this.soma.dom.writeProperty(el, 'height', `${this.snapPx(clamped.height)}px`);
this.soma.dom.writeProperty(el, 'translate', `${this.snapPx(x)}px ${this.snapPx(y)}px`);
}
}
@ -999,28 +1032,37 @@ export class FloatPanelContentProvider {
// Imperative inline style — this effect (plus the drag/resize handlers in
// the parent provider) is the SOLE owner of the content element's geometry
// and stacking. Each property is set individually (never via cssText), so
// writes compose instead of clobbering, and the drag handler's direct
// `el.style.translate` survives the will-change / z-index re-applies that
// happen at gesture start. At rest this applies seeded / programmatic /
// post-gesture geometry; DURING a gesture it cedes translate/size to the
// handlers (guarded on `active`) so the hot pointer path owns the DOM alone.
// and stacking, all through ActiveDom's per-element `writeProperty` (sync).
// Each property is set individually (never via cssText), so writes compose
// instead of clobbering, and the drag handler's direct `translate` write
// survives the will-change / z-index re-applies that happen at gesture
// start. At rest this applies seeded / programmatic / post-gesture
// geometry; DURING a gesture it cedes translate/size to the handlers
// (guarded on `active`) so the hot pointer path owns the DOM alone.
$effect(() => {
const el = this.opts.ref.current;
if (!el) return;
const p = this.provider;
const dom = p.soma.dom;
const active = p.dragging || p.resizing;
const stage = p.stage;
const pos = p.currentPos;
const size = p.currentSize;
// Chrome: cheap, changes only on strategy / bringToFront / gesture toggle.
el.style.position = p.opts.strategy.current;
el.style.left = '0px';
el.style.top = '0px';
el.style.pointerEvents = 'auto';
el.style.zIndex = String(p.zIndex);
el.style.willChange = active ? 'translate, scale' : '';
el.style.userSelect = active ? 'none' : '';
// (Per-element writes via ActiveDom's `writeProperty` — a SYNC
// setProperty delegate, so ordering vs the gesture handlers holds.)
dom.writeProperty(el, 'position', p.opts.strategy.current);
dom.writeProperty(el, 'left', '0px');
dom.writeProperty(el, 'top', '0px');
dom.writeProperty(el, 'pointer-events', 'auto');
dom.writeProperty(el, 'z-index', String(p.zIndex));
if (active) {
dom.writeProperty(el, 'will-change', 'translate, scale');
dom.writeProperty(el, 'user-select', 'none');
} else {
dom.removeProperty(el, 'will-change');
dom.removeProperty(el, 'user-select');
}
// Geometry: only when no gesture is driving the element directly.
// Round to whole pixels — `translate` is a transform and doesn't snap to
// the pixel grid, so a fractional value blurs the panel's text even at
@ -1030,19 +1072,19 @@ export class FloatPanelContentProvider {
// Fill the bounds. The stored pos/size are left untouched so a
// restore to 'default' snaps straight back — no rect bookkeeping.
const m = p.maximizedRect();
el.style.translate = `${p.snapPx(m.x)}px ${p.snapPx(m.y)}px`;
el.style.width = `${p.snapPx(m.width)}px`;
el.style.height = `${p.snapPx(m.height)}px`;
dom.writeProperty(el, 'translate', `${p.snapPx(m.x)}px ${p.snapPx(m.y)}px`);
dom.writeProperty(el, 'width', `${p.snapPx(m.width)}px`);
dom.writeProperty(el, 'height', `${p.snapPx(m.height)}px`);
} else if (stage === 'minimized') {
// Collapse to the header: keep position + width, let height auto-fit
// (the recipe hides the body under `[data-stage='minimized']`).
el.style.translate = `${p.snapPx(pos.x)}px ${p.snapPx(pos.y)}px`;
el.style.width = `${p.snapPx(size.width)}px`;
el.style.height = '';
dom.writeProperty(el, 'translate', `${p.snapPx(pos.x)}px ${p.snapPx(pos.y)}px`);
dom.writeProperty(el, 'width', `${p.snapPx(size.width)}px`);
dom.removeProperty(el, 'height');
} else {
el.style.translate = `${p.snapPx(pos.x)}px ${p.snapPx(pos.y)}px`;
el.style.width = `${p.snapPx(size.width)}px`;
el.style.height = `${p.snapPx(size.height)}px`;
dom.writeProperty(el, 'translate', `${p.snapPx(pos.x)}px ${p.snapPx(pos.y)}px`);
dom.writeProperty(el, 'width', `${p.snapPx(size.width)}px`);
dom.writeProperty(el, 'height', `${p.snapPx(size.height)}px`);
}
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.