diff --git a/docs/specs/dock-spec.md b/docs/specs/dock-spec.md index 7ba1046..4af1b20 100644 --- a/docs/specs/dock-spec.md +++ b/docs/specs/dock-spec.md @@ -80,9 +80,10 @@ The `+` (up) verdict is always "keep this" (`addExample` + train, `findings-feed | Selector label (UI copy) | Engine `FeedbackMode` | Behaviour | |---|---|---| -| **Push away** (Mode 1) | `Avoid` (`feedback.hpp` enum, `findings-feedback-behaviour.md:90`) | down → geometric-dislike: perturbs the mapping away from what you disliked. In the deployed core this routes to `move_weights(speed, spread, pinMask)` (`plans/feedback-modes-port-spec.md` §2.5 — the true k-NN centroid push is firmware-only, out of scope). | -| **Explore & place** (Mode 2) | `RandomiseMlp` (`findings-feedback-behaviour.md:122`) | down → snapshot + `draw_weights(spread)` re-rolls the whole net into a scratchpad you audition by moving the joystick; `+`/drag commits a `+1` example at the chosen input and **restores the real net** (`findings-feedback-behaviour.md:135-146`); down-again **cancels** (restore snapshot). | +| **Push away** (Mode 1) | `Avoid` (`nisps/ml/feedback.hpp`) | down → geometric dislike: stores the rejection and immediately pushes the mapping away from what you disliked; controller-owned elapsed-time replay then continues optimising live rejections (`docs/adr/rl-feedback-design.md`, 2026-07-25 amendment). | +| **Explore & place** (Mode 2) | `ExploreAndPlace` (`nisps/ml/feedback.hpp`) | down → snapshot + `draw_weights(spread)` re-rolls the whole net into a scratchpad you audition by moving the joystick; `+`/drag commits a positive anchor at the chosen input and **restores the real net**; re-roll and cancel remain distinct Explore-and-place actions (`docs/adr/rl-feedback-design.md` §2.2). | +- **Verdict pointer timing is mode-specific:** in **Push away**, pointer-down MUST dispatch exactly one geometric dislike immediately; holding, pointer-up, and release MUST NOT dispatch another dislike, re-roll/randomise, or cross-route into Explore-and-place. Controller-owned elapsed-time replay MAY continue after that single UI dispatch and is not a repeated held-pointer dispatch. **Explore & place alone** MAY interpret its existing long press as a re-roll. - A third engine mode `RandomiseOutputs` (bypass MLP, hold a static random vector, `findings-feedback-behaviour.md:111`) exists in the core but is **not** surfaced as a product mode in v1 — expose it only behind `?debug=1` as "Static roll". **(Open choice 1.)** - **Peek**: a 2-segment pill (`Push away` / `Explore & place`), the current mode highlighted in `--accent`. This pill is *also* mirrored next to the Verdict cluster (`VerdictCluster`, `findings-design-and-manifold.md:50`) so it is reachable during live play without opening the drawer — matching how a-immersive puts the `rl-label` above the RL buttons (`aimmersive-clone-spec.md:283`). - **Expand**: the pill + a one-line plain-English description of the active mode + an **"exploring…" indicator** that lights when `engine.feedback.exploring()` is true (`findings-feedback-behaviour.md:237`), reusing the `` colour ramp (off/active/high, `aimmersive-clone-spec.md:98`). While exploring in Mode 2, training is paused (`learning_paused()`, `findings-feedback-behaviour.md:48`) — show a small "learning paused" badge. diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md index 15bc1ad..48ff8e1 100644 --- a/manifold/ONBOARDING.md +++ b/manifold/ONBOARDING.md @@ -183,7 +183,9 @@ sweep (L22, zero consumers) — don't cite them. ### Other shared UI files - `shared-ui.tsx` — `MiniMeters` (read-only output bars). `AltitudeNav`/`CompactAxis` were deleted with the focus system. - `icons.tsx` — monochrome inline-SVG icons (mode icons + drawer icons + `GLYPH_FALLBACK` for when monochrome is off). -- `VerdictCluster.tsx` — floating bottom-centre feedback UI (perturb ▽ / undo ↺ / commit △ + A/B); labels adapt to feedback mode. +- `VerdictCluster.tsx` — floating bottom-centre feedback UI (perturb ▽ / undo ↺ / commit △ + A/B); + labels and press timing adapt to feedback mode. Push away fires once on pointer-down and + never turns a hold into randomisation; the 600 ms long-press re-roll remains Explore-only. - `CurvePad.tsx` — square canvas curve editor (vertical drag reshapes [0,1]; ~0.43 ≈ linear). Used in OutputEditor + OutputControlRow. - `OutputEditor.tsx` — inline range/curve popup for a single output (hover/click on a bar), using `DualRange.tsx` for the shared dual-thumb min/max control. diff --git a/manifold/src/console/VerdictCluster.tsx b/manifold/src/console/VerdictCluster.tsx index ebe6af8..d2a9c41 100644 --- a/manifold/src/console/VerdictCluster.tsx +++ b/manifold/src/console/VerdictCluster.tsx @@ -1,7 +1,8 @@ /** * VerdictCluster — floating bottom-centre control, the app's main verdict. * ▽ perturb (thumbs-down) · ↺ undo · △ commit (thumbs-up). - * Long-press perturb = full re-roll. Ported from `VerdictCluster.jsx`. + * Explore mode retains the ported long-press re-roll gesture. Push away fires + * once on pointer-down and never reinterprets a hold as randomisation. * * The cluster reflects the ACTIVE feedback mode (workstream B; rl-feedback §0): * @@ -12,8 +13,9 @@ * thumbs-DOWN = dislike (push away); * thumbs-UP = like + train. * - * Wiring (in ConsoleApp): onCommit / onPerturb dispatch on the mode; onReroll = - * re-roll the scratchpad (Mode 2) or the real net. + * Wiring (in ConsoleApp): onCommit / onPerturb dispatch on the mode; onReroll + * is the Explore-mode long-press path; onRandomise is the explicit secondary + * control. */ import { useRef, useState } from 'react'; import type { CSSProperties } from 'react'; @@ -78,7 +80,7 @@ export function VerdictCluster({ ? exploring ? 'Cancel explore — restore the real net' : 'Explore — re-roll into a scratchpad (hold to re-roll again)' - : 'Dislike — push the sound away (hold to re-roll)'; + : 'Dislike — push the sound away'; const upTitle = explore ? exploring ? picking @@ -90,15 +92,28 @@ export function VerdictCluster({ const lp = useRef | null>(null); const firedReroll = useRef(false); + const cancelLongPress = () => { + if (lp.current) { + clearTimeout(lp.current); + lp.current = null; + } + }; const perturbDown = () => { + cancelLongPress(); + if (!explore) { + onPerturb(); + return; + } firedReroll.current = false; lp.current = setTimeout(() => { + lp.current = null; firedReroll.current = true; onReroll(); }, 600); }; const perturbUp = () => { - if (lp.current) clearTimeout(lp.current); + if (!explore) return; + cancelLongPress(); if (!firedReroll.current) onPerturb(); }; @@ -170,9 +185,8 @@ export function VerdictCluster({ title={downTitle} onPointerDown={perturbDown} onPointerUp={perturbUp} - onPointerLeave={() => { - if (lp.current) clearTimeout(lp.current); - }} + onPointerLeave={cancelLongPress} + onPointerCancel={cancelLongPress} style={big({ background: explore && exploring ? 'rgba(255,106,0,0.22)' : 'rgba(255,106,0,0.16)', color: 'var(--accent)', diff --git a/manifold/tests/e2e/geo-dislike.spec.ts b/manifold/tests/e2e/geo-dislike.spec.ts index 90bafbc..6cba035 100644 --- a/manifold/tests/e2e/geo-dislike.spec.ts +++ b/manifold/tests/e2e/geo-dislike.spec.ts @@ -89,6 +89,23 @@ test.describe('geometric dislike (Mode 1) — core-backed', () => { expect(result).toEqual({ first: 1, live: 1, second: 1, expired: 0 }); }); + test('Push away starts once on pointer-down and holding never rerolls', async ({ page }) => { + const button = page.getByTitle(/Dislike — push the sound away/); + const box = await button.boundingBox(); + if (!box) throw new Error('negative-feedback button has no bounds'); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + expect(await page.evaluate(() => window.__nisps!.feedbackCounts().negative)).toBe(1); + + await page.waitForTimeout(700); + expect(await page.evaluate(() => window.__nisps!.feedbackCounts().negative)).toBe(1); + + await page.mouse.up(); + expect(await page.evaluate(() => window.__nisps!.feedbackCounts().negative)).toBe(1); + await expect(button).not.toHaveAttribute('title', /hold|re-roll/); + }); + test('expanded Learning panel starts calibrated and can restore upstream defaults', async ({ page }) => { await page.getByTitle('Learning', { exact: true }).click(); await page.getByTitle('Expand', { exact: true }).click();