Skip to content
Closed
2 changes: 1 addition & 1 deletion NODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Verdicts: OK · FIX(ed this batch) · DOC(umented quirk).
| visibility | Logic | effect | on | OK |
| onclick | Triggers | event | — | FIX: event→effect is now a legal drag (the typed sockets rejected wiring On Click into the Object Selector its own feature requires). Pulse window OK. |
| counter | Triggers | number | pulse reset | FIX (21-E4): a wired `reset` EVENT zeroes it, which is what a round-2 score needs — the counting lives inside `applyNodeTrigger`, so the reset rides that same path and is handle-aware there rather than being a second code path. The `op` param is unchanged and still governs a counter with nothing wired into reset. |
| onimpact | Triggers | event | minStrength | NEW (PFX-C): pulses when the physics INITIATOR lands the object on the ground/another object (rapier contact-start + pre-step downward velocity ≥ 1.2 m/s + 300ms per-body cooldown). Targeting walks THROUGH intermediate nodes to the Object Selector (the fireObjectClick BFS) and honors the implicit-owner rule. Replicated via nodetrigger stamps; `minStrength` gates on impact speed (wirable). |
| onimpact | Triggers | event | minStrength, direction | NEW (PFX-C): pulses when the physics INITIATOR registers a contact-start for the object. fix-batch-1 #14: `direction: 'any'` (new nodes) = the pre-step CLOSING speed along the contact normal ≥ 1.2 m/s, from any side (walls, targets, a kinematic paddle); `'down'` = pre-step downward velocity ≥ 1.2 m/s, the only rule up to 1.43 and what a node saved WITHOUT `direction` keeps (`legacy: 'down'` on the param). Each channel has its own 300ms per-body cooldown; emitters' "On impact" stay on the landing channel. Targeting walks THROUGH intermediate nodes to the Object Selector (the fireObjectClick BFS) and honors the implicit-owner rule. Replicated via nodetrigger stamps; `minStrength` gates on impact speed (wirable). |
| shake/spin/bounce/orbit | Animation | effect | per-param | OK (generic AnimationNode; params get ⓘ editors where sensible) |
| rotor (Rotate / Motor) | Animation | effect | on (boolean) + per-param | NEW (36-fb F25): turns about a LOCAL axis and the object's origin, rpm + spin-up as a pure function of the shared clock (deterministic, kinematic for physics — pushes bodies, carries Fluid-emitter water through the measured collider motion); on a DYNAMIC body during a run it is a torque-limited motor on the initiator instead (POSE_FREE). |
| flowfloat (Float Along Flow) | Animation | effect | path (object) + per-param | NEW (36-fb F24): rides a Flow path from the base pose at the path speed × speed, bob, faces the current, wraps; nearest path within 4 m when unwired. Pure function of (base, path, time) — nothing sent. |
Expand Down
41 changes: 38 additions & 3 deletions scripts/graph-audit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const read = (/** @type {string} */ p) => fs.readFileSync(path.join(ROOT, p), 'utf8');

/** @returns {{type: string, group: string, label: string, inputs: string[], params: string[], note: boolean}[]} */
/** @returns {{type: string, group: string, label: string, inputs: string[], hasInputs: boolean, params: string[], ranges: string[], note: boolean}[]} */
function catalogTypes() {
const src = read('src/lib/nodeCatalog.js');
const out = [];
Expand Down Expand Up @@ -53,7 +53,8 @@ function catalogTypes() {
.map((s) => s.trim().replace(/'/g, ''))
.filter(Boolean);
const params = [...body.matchAll(/key: '([^']+)'/g)].map((m) => m[1]);
out.push({ type: t[1], group, label, inputs, params, note: /\bnote:/.test(body) });
const ranges = [...body.matchAll(/key: '([^']+)', kind: 'range'/g)].map((m) => m[1]);
out.push({ type: t[1], group, label, inputs, hasInputs: /\binputs: \[/.test(body), params, ranges, note: /\bnote:/.test(body) });
}
// a type spelled twice (inside an options literal etc.) keeps its first row
const seen = new Set();
Expand Down Expand Up @@ -82,6 +83,34 @@ function outputTable() {
return Object.fromEntries([...block.matchAll(/([a-z0-9_-]+): '([a-z0-9]+)'/g)].map((m) => [m[1], m[2]]));
}

/** flowSockets' INPUT table: type -> the handles the runtime types (and so reads) */
function inputTable() {
const src = read('src/lib/flowSockets.js');
const a = src.indexOf('const INPUT = {');
const block = src.slice(a, src.indexOf('\n};', a));
return Object.fromEntries([...block.matchAll(/^\t'?([a-z0-9_-]+)'?: \{([^}]*)\}/gm)].map((m) => [m[1], [...m[2].matchAll(/'?([A-Za-z0-9_]+)'?:/g)].map((k) => k[1])]));
}

/** HudNode's own INPUTS map: type -> the sockets that card draws */
function hudCardInputs() {
const src = read('src/components/editors/nodes/HudNode.svelte');
const a = src.indexOf('const INPUTS');
const block = src.slice(a, src.indexOf('};', a));
return Object.fromEntries([...block.matchAll(/^\t\t([a-z0-9_-]+): \[([^\]]*)\]/gm)].map((m) => [m[1], [...m[2].matchAll(/'([^']+)'/g)].map((k) => k[1])]));
}

/**
* fix-batch-1 (docs-1 Q1): the sockets a GENERIC card draws — AnimationNode draws the spec's
* `inputs` (+ a socket per range param when it has them) or, with no `inputs`, one per range
* param; HudNode draws its INPUTS map. null = a card of its own (it draws what it draws).
* @param {any} t @param {string} card @param {Record<string, string[]>} hud
*/
function drawnInputs(t, card, hud) {
if (card === 'AnimationNode') return t.hasInputs ? [...t.inputs, ...t.ranges] : t.ranges;
if (card === 'HudNode') return hud[t.type] ?? [];
return null;
}

function docKeys() {
const src = read('src/lib/nodeDocs.js');
return new Set([...src.matchAll(/^\t'?([a-z0-9_-]+)'?:/gm)].map((m) => m[1]));
Expand All @@ -93,8 +122,10 @@ function catalogReport() {
const { cases, valueTypes } = evaluatorInfo();
const outs = outputTable();
const docs = docKeys();
const ins = inputTable();
const hud = hudCardInputs();
const rows = [];
const flags = { noCard: [], valueNoEval: [], evalNotListed: [], noDoc: [], noOutType: [] };
const flags = { noCard: [], valueNoEval: [], evalNotListed: [], noDoc: [], noOutType: [], undrawnInput: [] };
for (const t of types) {
const f = [];
if (!cards[t.type]) (f.push('NO CARD'), flags.noCard.push(t.type));
Expand All @@ -103,6 +134,10 @@ function catalogReport() {
(f.push('evaluated but not a listed value source'), flags.evalNotListed.push(t.type));
if (!docs.has(t.type)) (f.push('no doc'), flags.noDoc.push(t.type));
if (!outs[t.type]) flags.noOutType.push(t.type);
// a handle the runtime types (and reads) that the card never draws: unwirable by hand
const drawn = drawnInputs(t, cards[t.type], hud);
const missing = drawn ? (ins[t.type] ?? []).filter((h) => !drawn.includes(h)) : [];
if (missing.length) (f.push('UNDRAWN INPUT ' + missing.join(',')), flags.undrawnInput.push(t.type + ':' + missing.join(',')));
rows.push(
`| ${t.type} | ${t.group} | ${cards[t.type] ?? '—'} | ${outs[t.type] ?? '—'} | ${t.inputs.join(' ') || '—'} | ${t.params.join(' ') || '—'} | ${cases.has(t.type) ? 'yes' : '—'} | ${f.join('; ') || 'OK'} |`
);
Expand Down
13 changes: 13 additions & 0 deletions src/components/editors/nodes/ContactNode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@
/>
<span class="w-6 text-right font-mono text-[10px] text-text-muted">{(+(data.minStrength ?? 1)).toFixed(1)}</span>
</label>
<!-- fix-batch-1 #14: missing = 'down', what a node saved before the option did -->
<label class="flex items-center gap-1">
<span class="w-14 shrink-0 text-[10px] text-text-muted">from</span>
<select
class="nodrag nopan flex-1 text-[10px]"
data-impact-direction
value={data.direction ?? 'down'}
on:change={(e) => setNodeData(id, { direction: e.currentTarget.value })}
>
<option value="any">any side</option>
<option value="down">landing only</option>
</select>
</label>
{/if}
<div class="relative -mx-3 flex h-5 items-center gap-1 px-3">
<span class="text-[10px] text-text-2">other</span>
Expand Down
6 changes: 5 additions & 1 deletion src/components/editors/nodes/HudNode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@
hudtimer: ['start', 'duration'],
hudscreen: ['trigger'],
hudlist: ['trigger'],
hudbutton: []
hudbutton: [],
// fix-batch-1 (docs-1 Q1): the runtime read these (flowSockets types them) but the card
// drew no socket, so neither node could be wired by hand
hudrows: ['trigger', 'text'],
hudset: ['trigger', 'value', 'options']
};
$: inputs = INPUTS[data.type] ?? [];
$: wiredSource = (key: string) =>
Expand Down
12 changes: 10 additions & 2 deletions src/components/editors/nodes/SequenceNode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,27 @@
<Socket kind="target" nodeType={data.type} position={Position.Left} id="trigger" style="top: 50%;" />
<span class="text-[10px] text-text-2">trigger</span>
</div>
<!-- fix-batch-1 (docs-1): on the standard 150 px card a "wait" word on every row plus a 48 px step
label left each field's input 12 px — the values read as empty boxes. "wait" is said ONCE,
as the column's heading (each field still names itself to a screen reader), and the step
label is a fixed 36 px so the field does not jump when the ● lights. -->
<div class="flex h-4 items-center justify-between text-[10px] text-text-muted" aria-hidden="true">
<span>wait (s)</span>
<span>then</span>
</div>
{#each STEPS as step (step)}
<div class="relative -mx-3 flex h-6 items-center gap-1 px-3">
<span class="w-10 shrink-0 text-[10px] text-text-muted">wait</span>
<DragRow
nodrag
ariaLabel={`wait before step ${step} (seconds)`}
step={0.05}
decimals={2}
min={0}
value={data['delay' + step] ?? 0}
onchange={(v: number) => setNodeData(id, { ['delay' + step]: v })}
/>
<span
class="w-12 shrink-0 text-right text-[10px]"
class="w-9 shrink-0 whitespace-nowrap text-right text-[10px]"
class:text-accent-text={!!handles['step' + step]}
class:text-text-2={!handles['step' + step]}
>
Expand Down
16 changes: 13 additions & 3 deletions src/components/menu/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
import { geometry as sidebarGeometry } from '$lib/sidebars';
import type { ToolbarLayout, LayoutConfig, Placements, Region } from '$lib/toolbarLayout';
import { pivotMode, pivotParentAvailable } from '$lib/multiTransform'; // 37 R1: the toolbar Pivot cell
import { cssPx, scaledPx } from '$lib/uiScale'; // 53 A1: hud.css px tokens read back as calc(...)
import { cssPx, scaledPx, uiScale } from '$lib/uiScale'; // 53 A1: hud.css px tokens read back as calc(...)
import { VRButton, XRButton } from '@threlte/xr'

// A panel is "shown" when it is open AND either the visible dock tab OR floating
Expand Down Expand Up @@ -776,8 +776,10 @@
read();
return { destroy: () => ro.disconnect() };
}
const objHideSearch = $derived(objHeaderW < 260);
const objHideLabel = $derived(objHeaderW < 190);
// fix-batch-1 #23: the thresholds are 100 % widths — at 150 % the search and the label are 1.5x as
// wide, and an unscaled 260 kept the search on the folded N6 (a ~390 px header) and wrapped the ✕
const objHideSearch = $derived(objHeaderW < (260 * $uiScale) / 100);
const objHideLabel = $derived(objHeaderW < (190 * $uiScale) / 100);
// 38 R6: what the header element carries now that WindowChrome draws it — the width
// probe above (as an attachment) and the drop-to-root target
const objHeaderAttrs = {
Expand Down Expand Up @@ -4397,6 +4399,14 @@
.obj-search :global(.sf) {
width: 100%;
}
/* fix-batch-1 #23: at a large UI scale the search enters the header's line-breaking at a minimum
and grows into what is left (WindowChrome's compact-header rule for the title), so it narrows
before the ✕ wraps to a second row */
:global(:root[data-ui-large]) .obj-search {
flex: 3 1 0; /* most of what is left: the spacer after it only pushes the ✕ right */
width: auto;
min-width: min(6em, 100%);
}
.obj-filters {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
Expand Down
32 changes: 29 additions & 3 deletions src/components/menu/Users.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,16 @@
// viewport trap below), so the notch's circle sits exactly over the trigger wherever it is.
dropdownEl.style.setProperty('--pm-right', `${Math.max(0, Math.round(window.innerWidth - r.right))}px`);
}
/** fix-batch-1 #23: the trigger's keys — Enter / Space toggle, ↓ opens; opening moves focus in */
function triggerKey(e: KeyboardEvent) {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowDown') return;
e.preventDefault();
const opening = e.key === 'ArrowDown' || !openDropdown;
openDropdown = opening;
if (opening) focusFirstRow = true;
}
/** set by a keyboard open: the panel's mount focuses its first row */
let focusFirstRow = false;
/** focus moving OUT of the menu or its trigger (to a real element) closes it */
function dropdownFocusOut(e: FocusEvent) {
const to = e.relatedTarget as Node | null;
Expand Down Expand Up @@ -558,8 +568,21 @@
openDropdown = false;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') openDropdown = false;
if (e.key !== 'Escape') return;
// fix-batch-1 #23: focus inside the panel goes back to the button that opened it
const inPanel = !!document.activeElement && node.contains(document.activeElement);
openDropdown = false;
if (inPanel) triggerEl?.focus();
};
if (focusFirstRow) {
focusFirstRow = false;
queueMicrotask(() => {
// the first ROW — not the notch's avatar circle, which only closes the panel again
const rows = 'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])';
const first = node.querySelector<HTMLElement>(`.pm-card :is(${rows})`) ?? node.querySelector<HTMLElement>(rows);
first?.focus();
});
}
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(placeDropdown) : null;
if (triggerEl) ro?.observe(triggerEl);
window.addEventListener('resize', placeDropdown);
Expand Down Expand Up @@ -1015,7 +1038,10 @@
top-right corner. -->
<div id="avatar-menu" class="mr-5 flex w-52 items-center md:order-2">
<div class="flex items-center space-x-3">
<!-- tabindex 0 + press/focus semantics: what flowbite's Popper gave the trigger -->
<!-- tabindex 0 + press semantics. fix-batch-1 #23 (53-int Q5): it opened on FOCUS too (what
flowbite's Popper did), so Tab walked the whole panel — sign-in and every link on a cloud
build. Now the ARIA menu-button pattern: Tab passes it, Enter / Space / ↓ open it and move
focus to its first row, Escape closes it and gives focus back here. -->
<div
id="avatar-trigger"
bind:this={triggerEl}
Expand All @@ -1026,7 +1052,7 @@
aria-expanded={openDropdown}
style="position: absolute; top: 8px; right: 20px; cursor: pointer; z-index: 4; line-height: 0;"
onmousedown={() => (openDropdown = !openDropdown)}
onfocusin={() => (openDropdown = true)}
onkeydown={triggerKey}
onfocusout={dropdownFocusOut}
>
{@render avatarImg(effAvatar || undefined, 'bg-surface-2 h-12 w-12 border-2 border-border-strong')}
Expand Down
36 changes: 36 additions & 0 deletions src/components/ui/WindowChrome.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,42 @@
:global(:root[data-ui-large]) .wc-title {
min-width: min(6.5em, 100%);
}
/* fix-batch-1 #23 (53-int Q4): …but the wrap is the LAST resort now, not the first. A flex line
breaks on its items' HYPOTHETICAL sizes, so a title as long as its text pushed the buttons down
while there was room to ellipsize it — at 150 % on the folded N6 (a ~260 px layout) the Settings
sheet's close button sat on a second row and the Inspector header took three. Compact header at
a large scale: the title enters line-breaking at its minimum (basis 0, grows into what is left),
the decorative icon and the back button's text step aside (the text stays its accessible name),
and the header buttons stop growing at 120 % — still a 44 px target and more — so the scale goes
to the words */
:global(:root[data-ui-large]) .wc-head {
column-gap: calc(4px * var(--ui-scale, 1));
padding-left: calc(8px * var(--ui-scale, 1));
}
:global(:root[data-ui-large]) .wc-head .wc-title {
flex: 1 1 0;
min-width: min(4em, 100%);
}
:global(:root[data-ui-large]) .wc-icon {
display: none;
}
:global(:root[data-ui-large]) .wc-actions {
gap: calc(4px * var(--ui-scale, 1));
}
:global(:root[data-ui-large]) .wc-nav .wc-back > span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
:global(:root[data-ui-large]) .wc-modal .wc-btn,
:global(:root[data-ui-large]) .wc-panel .wc-btn,
:global(:root[data-ui-large]) .wc-panel .wc-head :global(.wc-act) {
width: calc(44px * min(var(--ui-scale, 1), 1.2));
height: calc(44px * min(var(--ui-scale, 1), 1.2));
}
.wc-modal .wc-title {
font-size: var(--fs-modal-title);
letter-spacing: -0.01em;
Expand Down
Loading
Loading