diff --git a/.github/workflows/deploy-branches.yml b/.github/workflows/deploy-branches.yml index 7aafc25..002120f 100644 --- a/.github/workflows/deploy-branches.yml +++ b/.github/workflows/deploy-branches.yml @@ -11,16 +11,16 @@ jobs: #- name: Debugging with ssh # uses: lhotari/action-upterm@v1 - name: Checkout nool repo on current branch # STEP 1 - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: path: source - name: Add name of current branch to environment as BRANCH_NAME uses: nelonoel/branch-name@v1.0.1 - name: Retrieve build environment if cached # STEP 2 id: build-cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: - path: '/home/runner/.opam/' + path: "/home/runner/.opam/" key: ${{ runner.os }}-modules-${{ hashFiles('./source/pnp-lock.yaml') }} #- name: Debugging with ssh # uses: lhotari/action-upterm@v1 @@ -29,7 +29,7 @@ jobs: - name: Use Node.js 21.2.0 uses: actions/setup-node@v3 with: - node-version: '21.2.0' + node-version: "21.2.0" # yarn global add pnpm@8.10.5 - name: Install dependencies and build nool # STEP 3 run: | @@ -43,7 +43,7 @@ jobs: pnpm run build working-directory: ./source - name: Checkout website build artifacts repo # STEP 4 - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: repository: disconcision/disconcision.github.io token: ${{ secrets.DEPLOY_NOOL }} @@ -66,4 +66,4 @@ jobs: git add -A git status git diff-index --quiet HEAD || (git commit -m "github-deploy-action-${BRANCH_NAME}"; git push) - working-directory: ./server \ No newline at end of file + working-directory: ./server diff --git a/design/TODO.md b/design/TODO.md new file mode 100644 index 0000000..141fb46 --- /dev/null +++ b/design/TODO.md @@ -0,0 +1,88 @@ +# TODO — consolidated index (draggable branch) + +Single place to find remaining work. Detail lives in the referenced docs; +items listed in full here are NOT recorded elsewhere. + +## Recorded elsewhere (see those files for detail) + +- **design/captured-geometry.md** — plan step 4 "Still open": chaining + (multi-rewrite drags); Pat-level provenance, execution half (rewrites record + correspondence — subsumes comp-match ambiguity and clone-source edge + cases; the affordance half — pattern-level moverhood — landed + 2026-07-30, restoring the factor-via-shared-die gesture symmetrically); + near-collinear rail acquisition tie-breaks. Also its findings entry + "Rule gating (game design, sketch)". +- **design/pinned.md** — shadow strategy reevaluation (nine-slice sprite + etc.) + Shift+D default decision; drag overlap legibility (shadow + scale + on held subtree, dim the rest); toolbox motion (tool flips snap; extend + motion layer to pat-* ids); mobile gaps (touch toolbox scroll, landscape + crampedness, touch equivalents for keyboard/debug shortcuts). +- **design/sound.md** — the channel model (meaning → audio channel), the + plucks decision, the derivation ladder (size delta → operator multiset → + pattern melody), pan sketch; open: undo/redo sound. +- **design/drag-target-decomposition.md** — tree-edit vs layout-reaction + in drag targets; site-frame rail correction (open, next) and staged + choreography (open, designed experiment); figs/rail-variants.png. + +## Not recorded elsewhere (canonical here) + +### Decisions awaiting Andrew +- Rails vs Closest drag mechanic: comparison toggle exists (8th corner + icon); pick one, delete the other. +- Debug defaults when tuning ends: drag-debug overlay currently ON by + default; Shift+S / Shift+D toggles and `window.__knob` debug global + should be removed or gated. + +### Game-design track (mechanism sketch in captured-geometry.md) +- Rule ACQUISITION: pick up / put down transforms, earned via levels + (loadout toggles are only the enable/disable half). +- Loadout conflict detection: geometric-ambiguity checking at enumeration + (coincident/near-coincident anchors), warnings or disambiguation; + curated presets. +- Loadout persistence: tools.dragActive resets on restart (Escape) and + isn't persisted across sessions. + +### Polish (Andrew's observations, not yet acted on) +- Noolbox recess lip: the bottom turquoise line reads thicker than the + top edge; the top has a nice cast shadow below (maybe wants slightly + more) but lacks a brightness lip on its upper part. Subtle and easy to + overdo — parked. (An edge fade-out was tried and reverted: it dissolved + the hard lines that make the recess read as a slot; but the current + hard cutoff is also "close but imperfect".) +- Noolbox exact-fit height (no truncated bottom row) would need + JS-driven resizing with a moving bottom lip, or elastic inter-rule + gaps — both judged worse than truncation for now; see discussion. + +### Pending verdicts / watches +- Shift+A harmony experiment (sound.md): keep or delete after listening. +- Sound pan (concept 3, sound.md): queued, unbuilt. +- Adjacent-possible previews: blur-at-rest/sharpen-on-hover fixed and + icon moved top-left — Andrew to judge whether the feature earns its + keep or gets deleted. +- Stuck-mid-tween drag state (reported, never reproduced in three + scripted chaos campaigns): guards landed — if it recurs, check the + console for "manual_start: empty before-capture" and whether + window.__knob.frames is still advancing; either datum bisects the + hypothesis space. +- Die loses blue tint during merge flights: diagnosis + candidate fix + in captured-geometry.md findings (backdrop baking); Andrew to call. + +### Icon workflow (idea, unbuilt) +- Trying icon candidates in context: fetch SVGs by concept from the + Iconify API (aggregates 200+ open sets, no auth: + api.iconify.design//.svg) into assets/icons/candidates/, + plus a one-page "icon lab" rendering each candidate at actual corner + size with the app's invert filter and hover styling. Noun Project API + (Andrew has an account) as the alternative source, same lab. + +### Meta +- Distill the capture-vs-internalize REPORT from captured-geometry.md's + findings into a standalone write-up while fresh. +- Code cleanup nits: unused imports/dead consts (Toolbar, ModelField, + palette_1/2, ded), `flipping` dep in package.json. + +### Long-horizon (context in memory + first design discussion) +- Internalization triggers to watch for: vary-style continuous drags, + non-CSS layouts, animating layout at 60fps, 3D. +- Toolbox as a drag surface (tools-as-draggables, palette drags à la + stage-builder demo). diff --git a/design/captured-geometry.md b/design/captured-geometry.md new file mode 100644 index 0000000..3f95521 --- /dev/null +++ b/design/captured-geometry.md @@ -0,0 +1,622 @@ +# Draggable nool: captured geometry over CSS layout + +Living architectural record for the `draggable` branch. This is both an +implementation plan and a report-in-progress on a general question: + +> What does it take to drive dragology-style speculative drag interfaces from +> **captured** geometry (DOM layout measured after the fact) rather than +> **internalized** geometry (a layout model the program owns, as in SVG or a +> custom engine)? + +Reference implementation: the `animate-algebra` demo in the dragology repo +(`draggable-diagrams/src/demos/animate-algebra/tree.tsx`), which is +"draggable nool" rendered in React+SVG. + +Update this document as decisions are made or reversed. Keep the decision log +append-only; strike out reversed decisions rather than deleting them. + +## Thesis (running) + +- Dragology's engine needs surprisingly little from its rendering substrate: + `render(state) → per-id layers`, a way to resolve a point per layer, a lerp + over layers, and hit-testing. SVG's only real contribution is that geometry + is readable *without mounting* — positions live in transform attributes. +- For **discrete** drag specs (`closest` over `between`s of fixed states — the + nool interaction), candidates are enumerated and rendered once per drag at + pointerdown; per-frame work is pure math (Delaunay-weighted lerp of + already-rendered trees keyed by id). Only `d.vary` (continuous optimization) + renders per frame (~200×/frame), and nool doesn't need it. +- Therefore captured geometry is viable: batch-measure K candidate states in + hidden mounts at pointerdown; never measure per frame. The feared + "render the scene many times per frame" problem does not arise for discrete + rewrites. +- The capture layer is an **adapter, not a compromise**: the drag/animation + system is written against "per-id boxes + shallow visual content", and the + box *source* is pluggable — gBCR over CSS today, a JS layout function later, + a 3D projection eventually. Internalization becomes a gradual migration + behind a stable interface, not a rewrite. SVG is a sidetrack: it demands the + full internalization cost up front (hand layout, no text metrics, port all + styling) while being the least capable target and off the road to 3D. +- Slogan version: dragology hoists actual elements because SVG has no layout + to disturb. In CSS, *the clone is the price of hoisting, and the + computed-style snapshot is the price of the cascade.* Capture turns layout + outputs into transform inputs; flattening turns the cascade into a snapshot. + +## How dragology actually works (facts that shaped the design) + +Established by code survey of the engine (2026-07-29): + +- Geometry is fully analytic; the engine never measures the DOM. An element's + position in a state = a local anchor point pushed through accumulated + `translate/rotate/scale` transform strings from the unmounted element tree. +- `d.between` over N states: N renders at drag start, Delaunay triangulation + of anchor positions, per-frame pointer→barycentric weights→N-ary lerp of + whole trees matched by id. Enter/exit = opacity fades; `dragologyEmergeFrom` + fabricates a synthetic "before" so new nodes grow out of their source. +- Animation: no springs — a frozen whole-tree snapshot of *whatever is + currently on screen* (origin) blended toward a live-recomputed target on an + easing clock. Retargeting re-snapshots the current blend as the new origin: + jank-free by construction. This is the retargetability that view + transitions lack. +- Drop: behavior runs once at release; nearest candidate anchor wins; settle + tweens from on-screen pixels to the drop state. `withSnapRadius(_, {chain})` + re-enumerates from the committed state mid-drag (event-scale, not + frame-scale). +- The algebra demo's spec: grab node → pattern-match rewrites whose + `#`-trigger is the grabbed node → + `d.closest(cands.map(c => d.between([cur, c]))).withSnapRadius(1, {chain:true})`. + Node identity survives rewrites (wildcard bindings keep ids; the dragged + node's id survives preferentially); duplicating rewrites give copies fresh + ids + `emergeFrom` provenance. +- Even the SVG demo hand-computes box layout in JS. SVG provides readback, + not layout. + +## Decision log + +- **2026-07-29 — Whole-scene morph, not floating.** Drag feel is `between` + (the entire scene interpolates toward the candidate), emphatically not + `withFloating`. Happily also the cheaper option (no floating-layer algebra). +- **2026-07-29 — Capture-first, internalize-by-degrees, SVG never.** + Rationale in Thesis. First internalization step, when it comes, is the + hybrid: measure leaf glyph metrics (cacheable per symbol × size), compose + boxes analytically. Triggers that would make internalization due: + continuous `vary`-style interactions; layouts CSS can't express (radial, + force-directed, curved connectors); animating layout itself at 60fps; 3D. +- **2026-07-29 — Flattened clone overlay, not in-place nested FLIP.** + For repositioning, nesting buys nothing: every element's blended global box + is directly computable and that's all a transform needs. Nesting in CSS is + load-bearing for three things a morph must override anyway: + 1. *Layout coupling* — parent geometry derives from children in flow. + In-place compensation fights recomputed layout every frame; parent size + change forces a choice between scale-faking (distorts radii/glyphs) and + real width/height animation (reflow that re-couples children). + 2. *Stacking* — an element can't escape an ancestor's stacking context, and + every transformed ancestor is one; crossing subtrees (commute) and + raise-the-grabbed-node need free per-layer z (dragology `stackingPath`). + 3. *Opacity* — parent opacity multiplies onto children; identity + elimination (op fades, operand survives and departs) is impossible + in-place without ad hoc reparenting — which *is* flattening. + Failure-mode asymmetry: in-place fails *continuously* (mid-flight jitter + from compensation vs layout, plus fragility to any reactive rebuild); + overlay fails *at the boundaries* (a pop if clones aren't pixel-identical), + which is a bounded engineering problem. nool's DOM is flattening-friendly: + comp heads are their own id'd nodes, so a comp's shallow clone is just the + rounded-rect background; glyphs travel as their own layers. +- **2026-07-29 — View transitions out; one retargetable tween layer for + clicks and drags.** The current inject serializes all interaction behind an + active transition (~0.5s hover queue) — fatal for drag. Replacement: + dragology's origin/target scheme in box space (origin = current on-screen + boxes, target = measured boxes of new state, retarget = re-snapshot). + Button clicks and drags are the same mechanism driven by clock vs pointer. + +## The substrate contract (what the overlay implements) + +Per drag (or per click-animation): + +- `enumerate(grabbedId) → candidateStates[]` — via rule matching with trigger + annotations (see prerequisites). +- `measure(state) → SnapshotMap: id → {box, scale, styleSnapshot, shallowContent}` + — render state into a hidden container (same width, computed-style capture), + batched gBCR reads, dispose. Solid's synchronous `render()` makes this a + clean single-beat operation. +- `overlay(snapshots) → per-id flat layers` — shallow clones (element minus + id'd descendants), absolutely positioned, explicit px font-size, z from + depth + grabbed-status. +- per frame: `weights(pointer) → blend(boxes, opacities)` — transform + + width/height + opacity on flat layers only; zero scene layout. +- boundary discipline: build overlay + hide base in one frame; at settle end, + swap back only when the re-rendered base measures identical. + +## Prerequisites in nool (mostly also the cleanup the code needs anyway) + +1. **DOM stability**: views are invoked as plain function calls, so any store + write (including tool hover) rebuilds the whole stage DOM; expression + `` keys by position so `#node-N` migrates between elements. Fix: + real component boundaries, id-keyed ``. The Elm architecture (single + store, whole-model update) stays; only the boundaries change. +2. **Remove view transitions** (Animate.tsx dynamic assignment, inject + branching, hover deferral) in favor of the box-tween layer. +3. **Trigger annotations on rules**: nool patterns anchor at the selected + path; dragging needs "grab node X → which (rule, site, variant) fire". + Design task, not plumbing — feel depends on which grab-points each + conceptual rule exposes (op vs operand vs sideways variants). +4. **`freshen` → emerge provenance**: duplicating rules currently give copies + anonymous fresh ids (reads as vanish+appear). Record `emergeFrom` instead. + Prior art: the commented-out delayed-freshen block in Update.tsx. +5. **Mid-drag hygiene**: a `dragging` class suppressing hover `scale:1.02`, + pulse keyframes, `:active` wobble (precedent: `.notransition` switches). +6. Scale quirks capture handles automatically (but internalization would have + to model): `#stage` font-size = 4/(depth+1)em (depth-changing rewrites + re-zoom the scene — the box lerp morphs it smoothly); toolbox at 0.4em vs + stage if cross-boundary drags ever happen. + +## Rule gating (game design, sketch) + +- One tool per *conceptual* rewrite; trigger-variant fan-out (pull-op / + pull-operand / sideways) lives inside the tool, not as separate toggles + (the demo's checkboxes are intentionally over-granular). +- Acquisition: pick up / put down transforms (earned by beating levels); + separately, enable/disable among owned tools; possibly curated presets of + non-conflicting loadouts. +- **Conflict has an operational definition**: two enabled rules conflict when + some grab yields candidates whose anchor targets are geometrically too + close to disambiguate (cf. the demo marking pull-sideways as conflicting + with commutativity). So conflict is checkable at enumeration time from + measured candidate geometry — can warn on loadouts, or only flag actual + ambiguous grabs contextually. + +## Plan + +1. ~~Stability + VT removal + box-snapshot tween layer, proven on existing + *button* clicks (a win even if drag stalls).~~ **Done 2026-07-29**, see + status below. +2. ~~Hidden-mount candidate measurement: pointerdown → enumerate → measure → + visualize anchors.~~ **Done 2026-07-29.** Drag is a separate mode + (`settings.dragging`, corner-icon toggle below the sound icon) so it + can't interfere with noolbox interaction. `src/drag/Drag.tsx`: grabbing a + node enumerates transforms applicable at it (both directions, ids + PRESERVED — unlike PreView's deliberate freshening — then `freshen` for + dup-id normalization, structurally deduped), renders each candidate into + a hidden probe inside #stage (explicit px font-size replicating the + depth-derived stage scale, center-aligned to the live scene), and + batch-measures per-id boxes. Debug overlay shows the grabbed node's box + and a colored anchor dot per candidate (hollow = grab vanishes in that + candidate; falls back to candidate root). Measured cost: ~10ms for 5 + candidates — drag-start budgeting confirmed trivial. Immediately visible + in the dots: **coincident anchors** (different rules placing the grabbed + node at the same point) — the loadout-conflict phenomenon, now literally + observable geometry. Enumeration is root-anchored for now (rules that + rewrite the grabbed subtree itself); trigger-annotation ergonomics + deferred to step 4. +3. ~~Overlay + `between` over one id-pure rule (commute) to validate feel.~~ + **Done 2026-07-29** — and went further: closest-of-betweens over ALL + candidates is live. Motion gained a manual (pointer-driven) mode sharing + every layer mechanism with the clock driver: `manual_start(after)` builds + layers from the current display toward a probe-measured candidate, + `manual_set(t)` drives the blend, `manual_release()` springs back, and + committing is just `inject(action)` — animate() captures the manual blend + as its origin, so drag→commit handoff is seamless by construction. + Dragging = project the pointer onto each grab→anchor segment + (rel-offset-mapped boxes as the poor man's local-frame anchor), nearest + segment wins, its parameter is t; release past t=0.5 commits. Wobble + (:active shake) suppressed in drag mode. + **The load-bearing design discovery: root-anchored enumeration produces + zero anchor travel for the most iconic drags.** Commuting a node's own + children doesn't move the node — the grab point IS the anchor, so the + candidate is unreachable (commute measured 0px travel when grabbing the + parent). Fix: enumerate at the grabbed node AND all ancestor sites — the + grab is then a *moving subterm* of the rewrite (grab the operand, drag it + to its sibling's place → the parent commutes; 141px travel, verified + end-to-end commit). MIN_TRAVEL=12px filters self-static candidates, which + is the right default: you drag the thing that moves. This empirically + grounds the demo's `#`-trigger design — triggers are which-moving-subterm + annotations — and largely answers the step-4 trigger question from the + geometry side. ~14 candidates over ancestor sites measure in ~18ms. +4. closest-of-betweens refinement — in progress: + - **Stickiness landed** (24px challenger margin): the active track keeps + the drag unless another is meaningfully closer; kills mid-drag track + theft by nearly-collinear segments. + - **Mover-not-passenger affordance filter landed**: a candidate is + offered at grab-node X only if X *itself* moves in it — if X's box + maps rigidly through its parent's displacement, the rule relocated an + enclosing subtree and X merely rode along; that drag belongs to the + ancestor (grab it instead). Derived purely from measured geometry (no + pattern annotations), this reproduces the demo's trigger discipline + (nodes inside wildcard bindings don't fire rules). Verified: grabbing + a factored (+ a b) inside (× k (+ a b)) offers exactly commute-times + and distribute — ancestor commutes that would carry the whole product + are excluded. Report note: affordance selection is ANOTHER thing + measured geometry can decide that analytic substrates express through + annotations — the "who moves" question has a purely geometric answer. + - **Knob-on-rails mechanic landed (replaces stickiness entirely).** + Andrew's articulation, formalized: dispatch-by-nearest-candidate is a + *classifier*, and classifiers have decision boundaries where the + visual state teleports; hysteresis only moves the boundaries. Instead + the drag state is a *configuration*: a knob on a star-shaped rail + network joined at the grab point. The pointer pulls, the knob chases + the pointer's projection along its current rail at bounded speed + (KNOB_SPEED 20px/frame), and rails can be changed only within a 10px + hub radius (evaluated before the chase — a diagonal pull toward + another rail still projects positively onto the current one). + Consequences, all verified: continuity by construction (no jumps, + ever); "commitment" far from the hub is *topology*, not a tuned + threshold — you return through the junction to change rules; near-hub + switching is essentially free; arcs under the hub rest the knob at the + junction. The trap (locally parked while another rail is globally + closer) is deliberate — it is what makes it read as a mechanism. + Report framing: dragology's `between` blends a *field* over candidate + positions (Delaunay); the rail mechanic is the 1-skeleton of the same + geometry with enforced continuity — a genuinely different, more + mechanical interaction point in the same design space. + - **Vanishing grabs are not offered.** If the grabbed node has no box in + a candidate (the rewrite consumes it, e.g. grabbing a product that + factoring merges away), the candidate is dropped — matching the + demo, whose triggers always survive. This also fixed a real bug: the + old fallback anchored such candidates to the candidate ROOT box, whose + rel-mapped position swung wildly with the within-node click position. + - **Dragology placement of the rail mechanic**: not orthogonal — a new + dispatch combinator, sibling of `closest` (call it `d.rails([...])`). + Dragology's dispatch is memoryless classification patched by stateful + band-aids (closest's hysteresis, withBranchTransition's pixel + smoothing); the rails mechanic moves state INTO dispatch: (branch, t) + integrated at bounded speed with topology-gated switching. Needs zero + new substrate primitives (per-branch anchors + pointer frames) and is + implementable today as a custom DragBehavior. It is the strong-form + answer to dragology's own TODO "hysteresis / status-quo bias". + A mechanic toggle (8th corner icon) switches Rails ⇄ Closest (the + original memoryless dispatch, kept for comparison; stickiness deleted). + - **The mover filter is stricter than the demo's annotations in one + telling case**: "drag the shared factor A to factor" is excluded + because A rides rigidly inside its product while everything reshuffles + around it — factoring is instead draggable via the non-rigid movers + (the B/C operands, or the sum in reverse). The demo explicitly + annotates `#A` as a factor trigger — i.e. this is exactly where + hand-annotations encode an ergonomic CHOICE that pure geometry + wouldn't offer. If that gesture is wanted, triggers must be able to + override the geometric filter (annotation ∪ geometry, not geometry + alone). + - **Merge rules and id survival**: when a rewrite merges structurally + equal subtrees (factor's two A copies), the matcher arbitrarily keeps + one copy's ids, so which die "survives" (and thus affords the drag) + is an id-plumbing accident. The demo solves this with "prefer the + triggered copy so the dragged node's id survives" — nool's hydration + needs the same grab-aware preference eventually. + - **Emerge/converge v1 landed (drag path).** The demo's emergeFrom, + adapted: entering nodes start AT a source node's box, fully visible, + and pull out (identity-intro: the new + and 0 grow out of the grabbed + operand); exiting nodes converge into the box of whatever replaces + the rewrite site while fading (identity-elim: the wrap ghost is + absorbed into the survivor's destination). Provenance is a geometric + stand-in for now: enters emerge from the GRAB (the demo's + expanding-rewrite trigger rule), exits converge into the site's + replacement root. The demo's id-discipline audit, for the record: + (1) wildcard bindings keep matched ids [nool has this]; (2) repeated + wildcards prefer the TRIGGERED copy [nool picks arbitrarily — the + die-lottery]; (3) every fresh id records emergeFrom provenance + (clones from originals, expanding ops from the trigger) [nool + discards]; (4) the engine synthesizes before-states from provenance, + bidirectionally [now partially ported]. True Pat-level provenance + would upgrade (2)+(3): distribute's clone would emerge from the + ORIGINAL die rather than the grab, and merge convergence would target + the surviving copy rather than the site root. Identity plumbing is + animation semantics. + - **Provenance-lite landed: structural equality as the clone/new + discriminator.** The inconsistency cluster Andrew found (displaced + clone dice, fading merges, wrong emerge sources) had one genesis: + provenance was being GUESSED at the animation boundary instead of + recorded at the rewrite. Interim principled fix: a created node that + structurally equals an existing site-scoped node is a CLONE (emerges + full-size AT its original — sym→sym and head→head mapped, so clones + are pixel-coincident at t=0); no counterpart = genuinely NEW (grows + from 15% at its source, opacity 1 throughout — no fades anywhere). + Converge mirrors: an exiting subtree with a surviving structural twin + MERGEs (travels to coincide, no fade); otherwise ABSORBs (shrinks + into the site's replacement while fading). Labels were considered and + rejected as the discriminator (a + wrapped in a + lies); structure is + the sharper proxy, and true rewrite-recorded provenance remains the + endgame (fixes the die-lottery, distribute-clone source preference, + and removes the guessing entirely). + - **The correspondence model, unified (exits made dual to enters).** + A morph is (before, after, correspondence), total over node ids: + MOVE (id in both → lerp box); EMERGE (after-only → source + clone|grow); + CONVERGE (before-only → target + merge|absorb). Geometry is fully + codified and mode-symmetric: clone starts AT the source's box + (kind-matched node→node/sym→sym/head→head), grow starts at 15% of it; + merge ends AT the target's box, absorb ends at 15% of it; NOTHING + fades, and emerging layers sit slightly behind established ones + (z −0.4) so creation comes out from behind. Exits are now per-node + layers (the whole-ghost granularity is gone), so factor's losing die + merges independently onto the survivor and the ×2 merges onto ×1 — + verified at opacity 1 throughout. The asymmetry Andrew caught + (scrubbing forward looked right, the reverse OPERATION didn't) was + precisely the enters-rich/exits-coarse imbalance; with duality, + scrub-reverse ≈ reverse-drag by construction. + - **Prefer-the-grabbed-copy landed at the candidate level**: when a + merge rule consumes the grabbed subtree but keeps a structural twin, + the grabbed subtree (ids and all) is spliced over the twin — sound + because we commit the exact candidate exp. Both dice now afford + factoring, and the OTHER copy becomes the merging exit. This is the + demo's applyRewrite rule done without touching Pat; rewrite-recorded + provenance remains the cleaner endgame. + - **Pattern-level moverhood landed (supersedes the geometric mover + filter, 2026-07-30).** The affordance filter went through three + formulations, each probed on a suss case: (1) the geometric + ride-along test wrongly offered rules AT THEIR FIXED POINTS — + grabbing the root + offered commute/associate (the root has no + parent frame so the test passed vacuously, and different-sized + operands swapping reflows its box past MIN_TRAVEL); (2) local tree + position (parent id + seat changed) parked the fixed points exactly + but excluded the shared-factor grip — the die in a×b + a×c keeps + its parent and seat while the sum dissolves from over it — and the + tempting lost-ancestor patch is direction-asymmetric (factoring + loses an ancestor, distributing gains one, and gained-ancestor + tests readmit the associate-internal passengers); (3) pattern-level: + classify the grab against the rule's source pattern — matched + structure, binding root, or strictly inside a binding — and move + iff the pattern-position occurrence set differs source↔result. + Inside-a-binding = passenger; fixed points (the root + under + commute/associate) have identical occurrence sets and park; the + shared factor's occurrence set changes shape ({1·1, 2·1} ⇄ {1}) so + the die grips factoring AND distributing, symmetrically — verified + live in both directions. Across the whole toolbox the three + formulations disagree ONLY on repeated-variable bindings, so this + was precisely a decision about whether "pinch the shared thing" is + a gesture (it now is). Report note: the demo hand-annotates `#A` + for exactly this; the pattern already contained the answer — "who + moves" is decidable from the rewrite rule alone, no annotations, no + measured geometry. Trigger annotations remain relevant only as an + ergonomic OVERRIDE (grips the pattern calls parked, or suppressing + offered ones), not as the default mechanism. This is the affordance + half of Pat-level provenance; the execution half stays open below. + - **Click-path emerge/converge landed (2026-07-31).** Button-applied + transforms now get the same provenance geometry as drags: App.inject + passes animate() a provenance closure for transform actions at the + selection — the site is the selection, and the selected node plays + the grab's trigger role (grow-from-head origin included). Identity, + inverse, double-neg, and distribute finally look the same whether + clicked or dragged; commute/associate always did (pure moves). Drag + commits pass through untouched (selection is unselected in drag + mode, and their provenance already resolved during the manual + morph). + - **Bystander-twin tightening (2026-07-31).** Provenance-lite's comp + twin test (same head-sym in the site) matched innocent bystanders: + double-neg elimination merged its dying negations onto the + mushroom's minus, intro cloned them out of it, and dying head + GLYPHS independently flew to any structurally equal head. Now a + comp twin must itself have CHANGED in the rewrite (participants, + not bystanders — factor's merge and distribute's clone still + qualify), and comp heads never twin-match alone (they follow their + comp). Verified by trajectory sampling. Another instance of the + guessing that rewrite-recorded provenance (execution half, below) + would eliminate wholesale. + - **Open observation — flat layers lose their backdrop tint.** An + atom's in-situ tint (the die's blue) is the stack of translucent + ancestor comp backgrounds behind it; solo shallow layers sit on the + page gradient (context shells are display:contents — classes, no + painted boxes), so a merging die visibly pales in flight. Present + since the overlay design (the documented flattening tradeoff) but + conspicuous now that merges ride at full opacity. Candidate fix: + bake the origin backdrop (composite ancestor background colors) + behind each shallow layer's own background; bounded imperfection + (frozen tint until teardown). Not implemented — Andrew to call. + - Still open: chain; Pat-level provenance, execution half (the + affordance half landed above): rewrites recording correspondence so + emerge/converge sources and the merge-copy choice come from the + rule application instead of structural guessing; + near-collinear rail acquisition tie-breaks (first capture is by + perpendicular distance and can pick the shorter of two nearly-parallel + rails). + +## Status + +**Step 1 landed (2026-07-29).** What exists now: + +- `src/motion/Motion.tsx` — the box-snapshot tween layer. `Motion.animate( + apply, enabled)` measures per-id boxes (`node-*`/`sym-*` under + `#stage .node-container`, viewport coords + computed font-size + tree + depth), runs the update, re-measures, and if geometry changed builds a flat + overlay of shallow clones (id'd descendants stripped, ids removed, + `data-motion-id` retained for debugging) that tween left/top/width/height/ + font-size/opacity under `easeInOutBack`, stacked by tree depth. Real stage + is `visibility:hidden` during flight. Retarget = blend-at-now becomes the + new origin. Enter = fade+grow from 50%; exit = fade+shrink to 80% in place. + Duration = `--anim-factor` × 250ms; `motion: Off` skips entirely. +- View transitions fully removed: `Animate.tsx` deleted, `inject` is now + two lines, hover-deferral gone (hover updates land instantly mid-tween), + `::view-transition-*` CSS and the action-name-class rules deleted. +- Views componentized: `Seed`/`StageView`/`ToolsView`/`TransformView`/ + `PatView`/`ExpViewGo`/`AdjacentPossible` are real JSX components; + expression kids use id-preserving `` (keyed by subtree object + identity, which `Pat.hydrate` preserves); toolbox rows keyed by stable + transform index and read their transform reactively (survives flips); + `AdjacentPossible`'s non-reactive early returns fixed with ``; + `StageView` memoizes the hover mask. Dead `animate`-class threading + removed from ExpView. + +Verified in-browser (vite + CDP): transform click → 28-layer morph → clean +teardown; mid-flight retarget continuity is exact (0.00px jump measured on a +layer across a second transform fired at t≈150ms); tool hover updates mask +classes with **zero** stage DOM replacement (same element references before/ +after); selection changes skip the overlay via the no-change guard; +projection toggles (CSS grid relayouts) animate through the same layer. + +## Step-1 findings for the report + +- **The overlay doubles as the VT replacement with no extra machinery** — + clicks and (future) drags share every line of Motion.tsx; only the driver + differs (clock now, pointer weights later). +- **Retargetability came out simpler than dragology's scheme**: blending + boxes (5 numbers/layer) instead of whole rendered trees makes + origin-resnapshotting trivial, and the measured `to` boxes are exact by + construction so there is no end-of-tween pop. +- **Clone fidelity tax was lower than budgeted** for nool's DOM: since comp + heads and atom glyphs are their own id'd elements, shallow clones are + mostly bare rounded-rect boxes, and explicit px font-size defeats the em + cascade. No computed-style snapshotting was needed yet (may change under + drag, where clones live longer and themes can flip mid-flight). +- **Context-scoped selectors were the first real fidelity bug** (user-visible + style loss mid-morph). Much of nool's node styling lives under + `#stage .node.comp`, `.node-container. …`, `#seed. …` + — none of which matched clones mounted in an overlay outside `#seed`. Fix: + mount the overlay INSIDE the hidden `.node-container` and give layers + `visibility: visible` (visibility, unlike display, is inherited but + overridable). Clones then live in the genuine cascade context — even + `:has(.selected)` rules keep working against the real hidden structure — + and no CSS had to be rewritten. Report lesson: in the capture approach, + "flatten the geometry, not the context" — hoisted layers should stay + inside the subtree whose selectors style them. Residual gap: structural + pseudo-classes (`:nth-child`, `:first-child`) over a node's *children* + can't match shallow clones (children are separate layers); none of nool's + current node styling depends on these in a way that shows. +- **Flattening breaks intra-tree cascade and compositing — three mechanisms, + three fixes (the second fidelity round, also user-visible).** Symptoms: + operators went black mid-morph, mask tints vanished from glyphs, head + glyphs jumped up-left and popped back. Causes: (1) descendant selectors + keyed on *node* ancestors (`.node.comp .head { background-clip:text; + padding }`, `.node.mask .node`) don't match clones whose ancestors were + flattened away — the "jump" was literally the head's missing 0.15em + padding; (2) `filter:` on a masked node applies to its painted *subtree* + in the live DOM but only to the fragment in a per-node layer, so glyph + layers escaped the tint; (3) infinite pulse/hover animations perturb + measurement. Fixes, in order of importance: + 1. **Rigid-subtree grouping** — a subtree whose members all map through + one uniform scale+translate (and with no internal enter/exit) stays a + single layer with a FULL clone. Filters, shadows, blends, and + descendant selectors then compose exactly as live. Root-commute went + from 28 layers to 7. This recovers what the old VT scheme did + implicitly (untagged nodes rode inside their parent's snapshot), but + derived from measured geometry instead of hand-maintained classes. + Nool's em-based layout makes uniformly-scaling groups exact: interpolate + the group's box + font-size and the internal layout scales with it. + 2. **Context shells** — split layers are wrapped in `display:contents` + divs carrying their real ancestor classes. Boxless, so geometry is + untouched, but selector matching AND CSS inheritance flow through. + 3. **Ancestor-filter copy** — shells can't host filters (no box), so + split layers inherit ancestor `filter` values inline; pixel-wise color + filters (sepia/hue-rotate/saturate) approximately commute with alpha + compositing, so per-fragment filtering reads correctly. + Plus a `motion-measuring` class that freezes animations/`scale` during + measurement. Retarget-through-groups works by storing member boxes + relative to the group's target box and reconstructing them against the + blended box when a group must split mid-flight (measured: 0.001px jump). + Report lesson: the substrate contract needs more than "per-id boxes" — + paint effects (filter/blend/opacity) compose over the *tree*, so the + flattener must either keep composing subtrees intact (grouping) or + replicate the compositing context per fragment (shells + filter copy). + Grouping is also a big perf and z-order win; deformation-only splitting + is the right default. +- **Costs paid**: `transition/animation: none !important` needed on clones + (the live nodes' own CSS transitions would fight per-frame style writes); + glyph *content* swaps instantly when a surviving node changes appearance + (VT crossfaded this — acceptable so far); the toolbox is out of scope for + the motion layer, so tool flips snap (VT used to crossfade them). +- **Solid's synchronous rendering pulled its weight**: measure → `go()` → + measure again all inside one event task, no scheduling ceremony, paint + never sees the intermediate state. + +## Open questions + +- Trigger-annotation surface for the 9 rules: which grab-points per rule? +- Anchor semantics: dragology uses the grab point in the dragged element's + local frame; with boxes we'll approximate via relative offset within the + box — revisit if it feels off for wide nodes. +- Drop choice: dragology picks nearest candidate *anchor*, not max blend + weight — keep or align? (They can disagree; with measured geometry maybe + more often.) +- Overlay coordinate root and scroll handling; theme/dark-mode context + container for clones. +- How chain re-measurement interacts with the depth-rescale (scene zoom + mid-chain). + +## Step-1 findings, round 3: performance + remaining fidelity (2026-07-29) + +- **Measured the "waggy frames": not per-frame cost, but two paint storms.** + rAF instrumentation during morphs: median frame 8.3ms (≈120fps), setup + burst (measure→apply→measure→clones) only ~6ms — but a ~150ms stall right + after overlay build (first rasterization of all layer textures: stacked + blurred box-shadows at dpr 2.2) and a ~117ms stall exactly at teardown + (unhiding the visibility-hidden stage forces a from-scratch repaint). + This is precisely VT's architectural advantage: view transitions *reuse + the already-painted page* as textures and never re-raster. +- **Fixes:** (1) dim, don't hide — the real content keeps `opacity: 0.001` + (via `.motion-dimmed > :not(#motion-overlay)`, since opacity on the + container would dim the overlay inside it) so its texture stays warm and + teardown is a pure compositor flip: the end stall is *eliminated*. + (2) translation-only layers (the common case, especially rigid groups) + are driven by the compositable `translate` property + `will-change` — + rasterize once, move on the compositor; only size-changing layers pay + per-frame layout/paint. `translate` rather than `transform` so clone + animations (pulse-scale etc. animate `transform`) compose instead of + clobbering. Result: one ~95ms hitch at press (reads as input latency), + none mid-flight or at the end. Report lesson: a capture substrate must + manage *texture lifetime*, not just boxes — "keep everything painted; + reveal by opacity; move by transform" is the VT playbook, reconstructible + by hand. +- **A/B verdict: stacked blurred box-shadows are 100% of the remaining + press-time hitch.** Shift+S toggles `body.noshadows`; measured stalls per + morph: ~170ms with shadows, zero frames >20ms without. The ten-layer + soft-shadow stacks (blur radii up to ~1.6em at 2.2×dpr) re-rasterize for + every clone at every morph. Options if it matters later: fewer shadow + layers globally, a cheaper shadow while morphing, or accept the hitch. + Related fixes: the tween clock starts after the first painted frame (so + raster cost delays the start instead of swallowing the animation's + opening), and exit ghosts sort at (old depth − 0.25) so eliminated + wrappers recede under the survivors that replace them (the identity-elim + blue-flash bug). +- **Shadow hunt, concluded (GPU trace + bisection).** The long tasks were on + the GPU process, not the renderer — texture re-raster, not script/layout. + Three separate shadow-raster sources, three different treatments: + 1. *Overlay clones*: always-on `box-shadow: none` for `.motion-layer` + (moving nodes are shadowless in flight — motion masks it). Crucially + the rule is ALWAYS-ON: toggling a class on the real stage per-morph + invalidates the whole tree and costs ~76ms re-raster by itself — the + first "shadowless while morphing" attempt was self-defeating. Flatten + invalidation, not just geometry. + 2. *The dimmed stage*: permanently promoted (`will-change: opacity` on + the stage root) so its shadowed texture stays warm; dimming is a pure + opacity flip on an existing compositor layer. + 3. *The toolbox* — the sneaky one: every exp change flips some rows' + match/NoMatch classes, and those rows repaint their 10-layer pat + shadows even on keyboard-applied transforms (no toolbox DOM change at + all). `will-change: opacity` on `.source`/`.result` makes the + container-opacity part compositor-only (65→33ms, zero visual change); + the last 33ms only goes away by cheapening the pat shadow stack — + shipped as Shift+D (`body.cheapshadows`, calibrated 4-layer + approximation, max pixel delta 26/255 vs the original) for aesthetic + judgment. Defaults: ~30ms residual (≈2 frames at 120Hz); with + cheapshadows: zero stalls. + Report lesson: in a capture substrate the perf battle is fought in the + compositor's invalidation/promotion model, and *bystander* UI (the + toolbox) can dominate the cost of animating the stage. +- **Clone animations are kept, only transitions are killed.** The blanket + `animation:none` on clones had disabled the selected-head glow + (`pulse-scale` animates filter brightness) — glow vanished for the morph's + duration. Transitions still must die (they fight per-frame style writes); + animations don't touch driven properties (and `translate` driving keeps + clear of their `transform`). Measurement happens under a `motion-measuring` + freeze so animated scale never contaminates boxes. +- **Selection morph reinstated as a synthetic layer.** VT's + `flip-node-selected` used to slide the white selection glow between nodes' + outlines. Now: on a pure selection change (geometry otherwise still), a + single synthetic div carrying the captured outline/box-shadow/radius + tweens between the old and new selected boxes over the *live* (undimmed) + stage, while `.selection-morphing` suppresses the real glow. This + foreshadows the "selection as its own overlay entity" idea for drags. +- **Guard threshold matters:** selection border changes jiggle boxes ~0.9px; + at a 0.5px threshold every selection click ran a full dim+overlay morph + (invisible but costly, and it shadowed the selection path). Threshold now + 1.5px. +- Toolbox shadow reverted to the deployed-exolivelit soft variant — the + harder one came from the unpushed "css tweaks" commit ported earlier and + had never actually shipped. + +## Report notes (capture-vs-internalize observations as they accrue) + +- (seed) Costs unique to capture: clone fidelity tax (computed-style + snapshot), boundary-swap discipline, re-measure on resize/font-load, + no continuous optimization (`vary`) without per-frame mounting. +- (seed) Costs unique to internalize: own text metrics, reimplement the + cascade's conveniences (theming, hover styling), port every layout mode, + lose free browser layout for everything else in the app. +- (seed) Both need: stable ids across states, emerge provenance, retargetable + origin/target tweening, trigger-annotated rules. diff --git a/design/drag-target-decomposition.md b/design/drag-target-decomposition.md new file mode 100644 index 0000000..67ae2c5 --- /dev/null +++ b/design/drag-target-decomposition.md @@ -0,0 +1,121 @@ +# Drag targets: tree edit vs. layout reaction + +Figure: `figs/rail-variants.png` (source: `figs/rail-variants.html`) — +the three rail variants drawn over real measured geometry (live layout, +the app's actual computed rails, and the true committed factored layout) +for commute-grab-✖️, factor-grab-sum, and factor-grab-die, plus the +staged choreography's two phases. + +Context: Andrew observed that drag movements are keyed to the actual +laid-out target state, which conflates "changing tree structure" with +"re-laying-out the new tree" — making target positions feel less +predictable than tree-structure intuition suggests. He proposed a +two-stage idea (drag against a tree-transform-only target, then +transition to the re-laid-out version on release) while doubting it was +well-defined, since "any actual position is laid out." This note is the +analysis of that question (reconstructed from the discussion, +2026-07-30). Status at time of writing: the pattern-level mover filter +(step 1 of the recommended ordering below) has since landed; the +site-frame rail correction and staged choreography have not. + +## The decomposition + +When a candidate is probed, the grabbed node's displacement from live to +target is a sum of two things: (1) **slot change** — it sits at a +different position in the tree, so it renders somewhere else; and (2) +**world reaction** — everything reflows around the edit. And (2) is +bigger and weirder than it looks in this codebase, for two concrete +reasons: the probe is center-aligned to the live scene as a *whole* +(`probe_candidate` matches bounding-box centers), so any edit that +changes the tree's overall extent shifts *every* anchor a little; and +the stage's font size is a function of tree *depth* (`stage_scale`), so +a rewrite that changes depth — associate does — rescales the entire +scene in the candidate. When a rail points somewhere you didn't expect, +it's usually these two, not the slot change. So the perception is +accurate: the rails measure "my move plus the world's entire reaction," +and the reaction can dominate. + +## Is "tree transform only" well-defined? + +The instinct that "any actual position is laid out" is correct — there +is no un-laid-out *layout*. But here's the reframe that dissolves the +worry: the motion layer's interpolation space consists almost entirely +of non-layouts already. Every mid-morph frame is a geometrically +inconsistent arrangement that no layout engine would produce. So the +intermediate state doesn't need to be a layout — it needs to be a +**keyframe in blend space**, and that is definable: *the state where the +movers have traveled to their new slots' current positions and nothing +else has budged.* "New slot's current position" is well-defined against +the live frame — commute's operand targets its sibling's box *as it is +now*; associate's inner + targets the left-slot region where `a` +currently sits. It only goes undefined where structure is created or +consumed, and the emerge/converge provenance machinery already exists to +answer exactly that question (what box a new node comes from / a dying +node goes into). So the two-stage picture is coherent: **phase 1, +drag-driven: movers travel through a frozen world; phase 2, on release: +the world reflows** — and phase 2 is literally the existing commit +handoff (animate() picking up the blend and continuing to the true +state). Nothing new has to be invented for it, choreographically; the +current system just runs both phases *simultaneously* instead of staged. + +## Two grades of adopting it + +There's a cheap version and a full version, independently valuable. + +**Cheap: fix only the rail geometry.** Define the anchor not as "grabbed +node's box in the laid-out probe" but as its probe displacement +*measured within the rewrite site's frame, mapped back into the live +site's frame*. That subtracts the recentering and the global rescale — +the two biggest predictability killers — while leaving the drag's visual +behavior (full simultaneous preview morph) untouched. + +**Full: also stage the choreography.** During the drag only movers +interpolate while passengers hold their live boxes, and the reflow plays +after release. Implementable with per-layer time-staggering in the +motion layer, but it has a genuine cost worth naming: the current +simultaneous morph is a *preview of the result* — riding the rail you +watch the entire future state assemble, which carries real information +(arguably a feature of the captured-geometry approach). Staging trades +result-preview for self-motion legibility. Both are defensible defaults; +it's even possible the right split is "staged during drag, full preview +in the adjacent-possible panel." + +## Relation to the mover question + +It's the same underlying split, applied to a different slot in the +pipeline. Slot-change vs world-reaction is one decomposition; the mover +question uses its *logical* shadow for **eligibility** (are you a thing +that changes slot at all?), while this observation is about using its +*geometric* rendering for **rail direction** and possibly +**choreography**. They're siblings, not the same thing — and they +corroborate each other at the case that started this: under the logical +mover test, root-grab-associate is ineligible because the root's slot +doesn't change; under frozen-frame rails, its rail length would be ~zero +for the same reason stated geometrically. When two independent +formalizations agree on the suss case, that's usually a sign the +decomposition is right. The "which + is which" correspondence question +stays fully orthogonal — a pattern-annotation choice about identity that +feeds *into* both (it decides who the movers are), but no mover +criterion or rail geometry will fix a wrong correspondence, and vice +versa. + +## Wrinkles + +For the full-staged version: during phase 1, movers arrive at slots +whose current occupants haven't yielded yet — deliberate overlap. Not +fatal (it reads as "placing onto"), and the pinned drag-overlap +legibility work (shadow + scale on the held subtree) is exactly the kind +of treatment that makes it read intentionally — but it's a real +aesthetic commitment, and it interacts with rules where multiple nodes +move at once (commute moves *both* operands: does the non-grabbed one +also travel in phase 1, or wait and swap in phase 2? Movers-are-movers +says it travels, but that's a choice to feel). + +## Suggested order + +1. The logical/pattern-level mover test (eligibility) — **landed**. +2. The cheap site-frame rail correction — small, pure predictability + win, no visual philosophy change. +3. Staged movers-then-reflow choreography — a designed experiment, held + until the first two can be felt, since it's the only one of the three + with a real tradeoff attached. diff --git a/design/figs/rail-variants.html b/design/figs/rail-variants.html new file mode 100644 index 0000000..142cf2f --- /dev/null +++ b/design/figs/rail-variants.html @@ -0,0 +1,176 @@ + + + +
+
+ Same gesture, three definitions of the rail — now with outcomes. Each row is one + concrete drag ("grab X to perform T"); each column is one option. Top scene: the drag — + the arrow is the direction you pull, from the grab point (⬤, dead-center on the icon). + Below it: what the stage shows after release. Options 1 and 2 morph the whole scene toward + the result while you pull and differ only in rail direction; option 3 moves only the + grabbed thing during the pull (frozen world), then reflows on release — so it gets three + stages. Zoomed to the lower subtree. +
+
+

Gesture 1 — factor by dragging the shared 🎲   (🎲✖️🦠) ➕ (🎲✖️🐝) → 🎲 ✖️ (🦠➕🐝)

+
+

1 · current (today)

+
DRAG — pull ↙ down-left (the "down" is layout drift, not the die's move)
+ +
AFTER RELEASE — the factored layout; the die now leads the promoted ✖️
+
+

2 · site-frame rails (cheap fix)

+
DRAG — pull ← straight left: the clean "extract" motion; visuals identical to 1
+ +
AFTER RELEASE — identical outcome; only the pull direction differed
+
+

3 · staged choreography

+
NO DRAG EXISTS — in a frozen world the die's new seat is the seat it already occupies (zero rail)
+ +

This grip only exists under options 1 and 2 — a real conflict between staged rail + geometry and the pattern-level mover rule that offers the die.

+
+
+
+

Gesture 2 — factor by dragging the sum ➕   same rewrite, different grip

+
+

1 · current (today)

+
DRAG — pull ↘ toward the ➕'s final resting spot; whole scene morphs as you pull
+ +
AFTER RELEASE — the ➕ sits inside the promoted ✖️
+
+

2 · site-frame rails (cheap fix)

+
DRAG — pull ↘, nearly identical here (~10px correction; the fix matters for gesture 1, not this one)
+ +
AFTER RELEASE — identical outcome
+
+

3 · staged choreography — three stages

+
DRAG — pull → right; ONLY the ➕ travels toward 🦠's seat, rest frozen
+ +
END OF PULL — the ➕ overlaps 🦠's spot; its old seat sits vacated; nothing else has moved
+ +
AFTER RELEASE — the world reflows to the same factored layout as 1 and 2
+
+
+
+
+

Gesture 3 — commute by dragging a ✖️ product   (🎲✖️🦠) ➕ (🎲✖️🐝) → products swap +  ·  all three options are identical here (equal-size swap, no layout reaction)

+
+

1 = 2 = 3

+
DRAG — pull ↓ straight down onto the other product's seat
+
+

 

+
AFTER RELEASE — the products have traded places (🦠-product below, 🐝-product above)
+
+
+
+
+
+ diff --git a/design/figs/rail-variants.png b/design/figs/rail-variants.png new file mode 100644 index 0000000..e115a12 Binary files /dev/null and b/design/figs/rail-variants.png differ diff --git a/design/pinned.md b/design/pinned.md new file mode 100644 index 0000000..b20054d --- /dev/null +++ b/design/pinned.md @@ -0,0 +1,88 @@ +# Pinned: things to come back to + +Deliberately parked items, so nothing gets lost while the dragging work +proceeds. Add entries with enough context to resume cold; strike out when +resolved. + +## Shadows: achieve the effect by other means (pinned 2026-07-29) + +Current state (see design/captured-geometry.md, "Shadow hunt, concluded"): +moving overlay clones are shadowless in flight; the toolbox pat-shadow cost +is halved by side promotion, with an optional Shift+D 4-layer approximation +(`body.cheapshadows`) that reaches zero stalls at a subtle fidelity cost. + +What's unsatisfying: +- Shadows popping out/in on morphing nodes doesn't feel perfect. +- Shift+D is a visible fidelity decrease; whether it's worth it is an open + aesthetic call (Andrew: "could be worth the sacrifice, not totally sure"). +- Default (10-layer) still has a ~30ms toolbox-repaint residual per action. + +The underlying observation: this effect is NOT intrinsically expensive — +games render vastly fancier soft shadows cheaply by pre-baking them into +textures. Browser box-shadow is expensive because Skia re-runs a gaussian +blur on every tile rasterization with no caching across elements, frames, or +identical shadow specs. Blur cost ∝ area × radius × layers × dpr². + +Avenues to evaluate (roughly in order of promise): +1. **Nine-slice shadow sprite** — render the layered shadow ONCE (offline or + at startup to a canvas), use as `border-image` (or a stretched ::before + with an image). Raster becomes a texture blit; the 10-layer look is + preserved exactly. Complication: nool's border-radius varies per node + (asymmetric leaf shapes) — may need a few radius variants or accept + slight corner mismatch under the content. +2. **Strip shadows only from layers that actually move** (from ≠ to boxes): + stationary clones keep shadows; they raster once at overlay build (cost + returns partially, but only for the stationary subset). +3. **Fade shadows rather than pop**: a dedicated shadow element/pseudo per + layer whose opacity animates — compositor-only once rasterized. (A + promoted shadow-only layer rasters its blur once, then opacity is free.) +4. **Fewer layers globally** — recalibrate the resting stack itself to 4-5 + layers designed to be indistinguishable (the current Shift+D variant was + calibrated quickly; a careful match could close the 26/255 gap). +5. **Canvas/WebGL underlay** for all node chrome (shadows, maybe + backgrounds) — heavyweight, but on the road to the eventual custom + geometry engine / 3D anyway. Probably the long-term answer. + +Related loose ends: +- Decide Shift+D default (Andrew to revisit after living with it). +- Selected-head pulse animates `filter: brightness + drop-shadow` per frame + on its clone — same raster family, currently cheap because heads are + small, but worth folding into whatever shadow strategy wins. + +## Drag overlap legibility (pinned 2026-07-29) + +Translucent node backgrounds compose confusingly when subtrees cross during +drags. Options discussed (none adopted yet): +1. Solid backdrop traveling with moving nodes — correct fix for composition + confusion, but changes how node colors read (they're translucent tints + over the scene by design). Revisit jointly with the shadow-sprite work + (opaque card-backs simplify nine-slice shadows). +2. Mask-style tint on the moving subtree — rejected as default: washes + glyphs, and reads as "preview highlight" not "in hand". +3. Restore box-shadow on ONLY the held subtree's layer (+ optional 1.02-1.05 + scale-up) — cheap (one layer's raster), adds a depth cue so crossing + reads as occlusion. Current lean. +4. Dim non-participating layers slightly during drag (opacity ~0.75, + compositor-free). Composes with 3. + +## Toolbox motion (pinned 2026-07-29) + +The toolbox is outside the motion layer: tool flips and match-status changes +snap instantly (VT used to crossfade them). Fine for now; revisit when the +toolbox itself becomes a drag surface (tools-as-draggables), which will want +the box-tween treatment extended to `pat-*` ids. + +## Mobile (pinned 2026-07-30) + +Mobile pass v1 landed: tap-highlight/callout/selection suppressed on the +play surface, touch-action:none so drags own the gesture, 100dvh, viewport +font clamp (narrow OR short screens), portrait stacks stage-over-noolbox, +coarse-pointer tap-target enlargement, drag mode defaults on for touch. +Open items: +- Toolbox scrolling is wheel-only, so touch can't scroll the tool window + (only the visible rows' loadout toggles are reachable). Options: touch + drag-to-scroll on #noolbox, or show all rules on mobile (tools.size = all). +- Landscape phones keep the side-by-side layout; possibly cramped with big + expressions. Revisit after real-device testing. +- Keyboard-only affordances (1-4/space/arrows, Shift+S/D debug) have no + touch equivalents. diff --git a/design/sound.md b/design/sound.md new file mode 100644 index 0000000..683e308 --- /dev/null +++ b/design/sound.md @@ -0,0 +1,110 @@ +# Sound design (drag-era) + +Principle: each MEANING owns one AUDIO CHANNEL, so the sound system +stays legible as it grows — the ear reads pitch register as one thing, +chord quality as another, pan as another, without cross-talk. + +## Channel assignments + +| channel | meaning | status | +|----------------|----------------------------------|-------------------| +| register | tree depth (select sound pitch) | landed (pre-drag) | +| contour | drag direction (advance/recede) | landed | +| pitch content | the rule's operator multisets | landed 2026-07-30 | +| dynamics | crescendo toward commit | landed 2026-07-30 | +| pan | drag direction on screen | open (sketched) | +| timbre | (unassigned) | open | + +(An earlier chord-quality channel — iso/grow/shrink triads from the +size delta, ladder rung 1 — was landed then superseded the same day by +rung 2: the multiset makes grow/shrink audible as content arriving or +departing, and says WHICH operator changed.) + +## Decisions (2026-07-30) + +- **Drags speak in plucks; the rewrite samples are button-mode sounds.** + Verdict of a six-way bake-off (granular scrub of the commit sample, + scrub-only, mechanical detents, tension noise, plucks, commit-only — + all built behind a Shift+A cycle, since removed). The scrub read as + stuck tape; plucks won. Grammar: quantized notes at the rail's + quarter points, ascending advancing, dropped an octave receding — so + a completed rising figure IS the commit signal and a revert walks + back down. No separate commit accent (the octave-completing note is + the natural addition if one is ever wanted). +- **Chord quality = structural effect, derived from the patterns.** + size(result) − size(source): isomorphic rearrangements (commute, + associate) pluck open fifths — nothing created or destroyed; growing + rewrites a major triad; shrinking ones minor. Nothing hand-assigned; + new rules get sounds automatically; and since flip swaps the + patterns, a reversed rule is automatically its forward form's + harmonic opposite. The sound algebra inherits the rewrite algebra. +- Drag commits play NO sample (deliberate — see Update.sound comment). +- Undo/redo are currently silent (open: should they speak? A candidate: + the pluck contour of the undone rule, receding). + +## The derivation ladder (how "non-arbitrary" gets deeper) + +Each rung subsumes the previous; all derive the sound from the rule's +own patterns, pushing the axiomatic (hand-chosen) part smaller: + +1. **Size delta** (landed, then superseded by 2): iso / grow / shrink + → chord quality. +2. **Operator multiset** (landed 2026-07-30): the multiset of operators + (comp heads) in the patterns. Alphabet: ➕→C, ✖️→G, ➖→Eb, other→D, + ∅→A3 (the bare-variable world). The quarter-point plucks walk the + content source → result: ¼ = source multiset, ½ = the invariant core + (∩), ¾ = result — created content audibly arrives near commit, + consumed content departs. Multiplicity stacks octaves (associate = + the plus-note doubled). Advancing plucks crescendo; receding drops + the chord an octave, quieter. + Distinguishes what 1 cannot: commute_plus is {+} but associate_plus + is {+,+} — "associativity has two pluses" becomes literally audible + (multiplicity → octave doubling or repetition). Distinguishes + families too (plus-rules vs times-rules vs mixed: distribute is + {×,×,+} ⇄ {×,+}). The multiset CHANGES across a rewrite, so the + drag can sonify the delta (identity-intro: a + note fades in). + Axiomatic residue: one pitch class per operator symbol — a small + "sound alphabet" (like a color basis); everything else derives. The + alphabet carries forward unchanged if rung 3 lands. +3. **Pattern melody** (stretch): walk the result pattern in reading + order — operator → its alphabet pitch, variables → scale degrees, + depth → octave, quantized to a consonant scale. Each rule's motif IS + its pattern: distribute's repeated variable is a repeated note, + commute's figure is its operand order reversed. Subsumes 2 (the + multiset is the melody's pitch content, unordered). Risk: derived + motifs sounding like a modem; needs scale-quantization discipline. + +Current toolbox operator multisets (source ⇄ result), for reference: +commute+ {+}⇄{+} · assoc+ {+,+}⇄{+,+} · identity+ {}⇄{+} · +inverse+ {}⇄{+,−} · dbl-neg {}⇄{−,−} · commute× {×}⇄{×} · +assoc× {×,×}⇄{×,×} · identity× {}⇄{×} · distribute {×,+}⇄{×,×,+} + +Known asymmetry to listen for: shrinking rules lose their content at +the ½ pluck (∩ = the smaller multiset) while growing rules gain theirs +at ¾ — factoring "finishes early" sonically. If that grates, walking +½ linearly source → result instead of through the intersection is a +one-line change. Also queued: pan (below); a commit accent (the +octave-completing fourth pluck) if the completed-triad-as-commit-signal +ever feels insufficient. + +## Harmony experiment (Shift+A, evaluating) + +Layers the retired quality channel back UNDER the content chords: one +added note, a third above each chord's root — major (4 semitones) when +the rewrite grows, minor (3) when it shrinks, none when isomorphic +(open/hollow stays open). Content letters untouched; the third only +colors the mood. Off by default ("content"); Shift+A toggles +("content+harmony"), logged to console. Keep or delete after listening. + +## Pan (concept 3, sketched) + +Stereo pan per pluck = the active rail's horizontal direction +(normalized dx). Geometry channel, deliberately projection-relative: +the ear hears WHERE the gesture goes, pitch says WHAT it does. One +Panner node; per-pluck, not continuous. + +## Angle→pitch, rejected + +Mapping rail angle to pitch was considered and rejected: the same rule +drags at different angles in different projections, so identity would +be projection-dependent. Geometry belongs on pan, identity on pitch. diff --git a/src/Action.tsx b/src/Action.tsx index 072fdfe..4f6413d 100644 --- a/src/Action.tsx +++ b/src/Action.tsx @@ -11,14 +11,16 @@ export type Direction = "up" | "down" | "left" | "right"; export type Action = | { t: "restart" } + | { t: "hardReset" } | { t: "setSetting"; action: Settings.Action } | { t: "setHover"; target: Hover.t } | { t: "setSelect"; path: Path.t } - | { t: "moveStage"; direction: Direction } | { t: "moveTool"; direction: Direction } - | { t: "wheelTools"; offset: number} | { t: "wheelNumTools"; offset: number} | { t: "unsetSelections" } + | { t: "strikeSeed" } + | { t: "undo" } + | { t: "redo" } | { t: "transformNode"; idx: number; @@ -35,6 +37,7 @@ export type Action = | { t: "applyTransform"; idx: number; direction: "forward" | "reverse" } | { t: "applyTransformSelected" } | { t: "flipTransform"; idx: number } + | { t: "toggleDragTool"; idx: number } | { t: "Noop" }; export type t = Action; diff --git a/src/Animate.tsx b/src/Animate.tsx deleted file mode 100644 index 52370bc..0000000 --- a/src/Animate.tsx +++ /dev/null @@ -1,23 +0,0 @@ -const blah = (s: string) => ` -::view-transition-old(${s}), -::view-transition-new(${s}) { - transition: transform 550ms cubic-bezier(0.68, -0.6, 0.32, 1.6); -} -`; - -export const init = ():void => { - //TODO: unhardcode id max - var style = document.createElement("style"); - for (let id = 0; id < 100; id++) { - style.innerHTML += `#node-${id}.animate { view-transition-name: flip-node-${id}; }\n`; - style.innerHTML += `#main.unsetSelections #sym-${id}, #main.setSelect #sym-${id}, #main.moveStage #sym-${id} { view-transition-name: flip-sym-${id}; }\n`; - - //style.innerHTML += `#main.setSelect #pat-${id} { view-transition-name: flip-pat-${id}; }`; - //style.innerHTML += blah(`flip-node-${id}`); - //style.innerHTML += blah(`flip-pat-${id}`); - } - style.innerHTML += `#main.setSelect .logo, #main.unsetSelections .logo { view-transition-name: setSelect-logo }\n`; - style.innerHTML += `#main.setSelect #seed, #main.unsetSelections #seed { view-transition-name: setSelect-seed }\n`; - //style.innerHTML += `#node.selected { view-transition-name: flip-node-selected; }\n`; - document.getElementsByTagName("head")[0].appendChild(style); -}; diff --git a/src/App.tsx b/src/App.tsx index ceb24c5..a0afbd7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,47 +7,82 @@ import * as Keyboard from "./Keyboard"; import { SettingsView } from "./view/SettingsView"; import { Seed } from "./view/SeedView"; import * as ExpToPat from "./syntax/ExpToPat"; -import * as Animate from "./Animate"; -//import { Toolbar } from "./view/ToolsView"; +import * as Motion from "./motion/Motion"; +import * as Sound from "./Sound"; +import * as Persist from "./Persist"; +import * as Drag from "./drag/Drag"; +import { id_at } from "./syntax/Node"; export type SetModel = SetStoreFunction; - const App: Component = () => { - const [model, setModel] = createStore({ ...Model.init }); - Animate.init(); + const [model, setModel] = createStore(Persist.load() ?? { ...Model.init }); + const inject = (a: Action.t) => { - console.log(a); - if (a.t === "setHover" || !document.startViewTransition) { - console.log("sethover dont transition:" + a.t); + if (a.t === "setHover") { go(model, setModel, a); return; } - const guy2 = document.getElementById("main"); - guy2 ? guy2.classList.add(a.t) : console.log("no guy r add"); - let v = document.startViewTransition(() => go(model, setModel, a)); - v.finished.then(() => - guy2 ? guy2.classList.remove(a.t) : console.log("no guy 2 rm") + /* Click-path emerge/converge: transforms applied at the selection get + * the same provenance geometry as drags (clone/grow, merge/absorb; + * the selected node plays the grab's trigger role). Drag commits pass + * through untouched: in drag mode the selection is always unselected, + * and their provenance already resolved during the manual morph. */ + const at_selection = + (a.t === "transformNode" || + a.t === "transformNodeAndFlipTransform" || + a.t === "applyTransform" || + a.t === "applyTransformSelected") && + model.stage.selection !== "unselected" + ? [...(model.stage.selection as number[])] + : null; + const exp_before = model.stage.exp; + const mk_opts = at_selection + ? ( + before: ReadonlyMap, + after: ReadonlyMap + ) => + Drag.provenance( + exp_before, + before, + model.stage.exp, + after, + at_selection, + `node-${id_at(at_selection, exp_before)}` + ) + : undefined; + Motion.animate( + () => go(model, setModel, a), + model.settings.motion !== "Off", + mk_opts ); }; - document.addEventListener("keydown", Keyboard.keydown(inject), false); + document.addEventListener("keydown", Keyboard.keydown(inject, model), false); document.addEventListener("keyup", Keyboard.keyup(inject), false); - // document.addEventListener("transitionstart", (e) => { - // in_transition = true; - // }); - // document.addEventListener("transitionend", (e) => { - // in_transition = false; - // }); + // Debug toggles: Shift+S all shadows off; Shift+D cheap toolbox + // shadows; Shift+A toggles the drag-pluck harmony experiment + document.addEventListener("keydown", (e) => { + if (e.key === "S") document.body.classList.toggle("noshadows"); + if (e.key === "D") document.body.classList.toggle("cheapshadows"); + if (e.key === "A") console.log("drag sound:", Sound.toggle_harmony()); + }); return (
- {/* ); }; diff --git a/src/Keyboard.tsx b/src/Keyboard.tsx index 90c3bf5..500b20c 100644 --- a/src/Keyboard.tsx +++ b/src/Keyboard.tsx @@ -1,17 +1,27 @@ import * as Action from "./Action"; +import * as Model from "./Model"; +import * as Navigate from "./Navigate"; +import * as Drag from "./drag/Drag"; -const action_of = (key: string): Action.t | "NoBinding" => { +const arrow_of = (key: string): Navigate.Direction | null => { switch (key) { - case "Escape": - return { t: "restart" }; case "ArrowLeft": - return { t: "moveStage", direction: "left" }; + return "left"; case "ArrowRight": - return { t: "moveStage", direction: "right" }; + return "right"; case "ArrowUp": - return { t: "moveStage", direction: "up" }; + return "up"; case "ArrowDown": - return { t: "moveStage", direction: "down" }; + return "down"; + default: + return null; + } +}; + +const action_of = (key: string): Action.t | "NoBinding" => { + switch (key) { + case "Escape": + return { t: "restart" }; case "1": return { t: "applyTransform", idx: 0, direction: "forward" }; case "2": @@ -35,13 +45,34 @@ const action_of = (key: string): Action.t | "NoBinding" => { } }; -export const keydown = (inject: Action.Inject) => (event: KeyboardEvent) => { - //console.log("keydown:" + keyName); - let action = action_of(event.key); - if (action == "NoBinding") return; - event.preventDefault(); - inject(action); -}; +export const keydown = + (inject: Action.Inject, model: Model.t) => (event: KeyboardEvent) => { + if ( + (event.metaKey || event.ctrlKey) && + (event.key === "z" || event.key === "Z") + ) { + event.preventDefault(); + /* not mid-drag: the drag's probes and tween assume a stable world */ + if (!Drag.drag_in_progress()) + inject({ t: event.shiftKey ? "redo" : "undo" }); + return; + } + const dir = arrow_of(event.key); + if (dir) { + event.preventDefault(); + /* drag mode has no selection mechanic to move */ + if (model.settings.dragging) return; + /* Screen-space movement, measured off the rendered stage at press + * time; no candidate in that direction means no move (and no sound) */ + const path = Navigate.next(model.stage, dir); + if (path) inject({ t: "setSelect", path }); + return; + } + let action = action_of(event.key); + if (action == "NoBinding") return; + event.preventDefault(); + inject(action); + }; export const keyup = (_inject: Action.Inject) => (event: KeyboardEvent) => { //console.log("keyup:" + keyName); diff --git a/src/Model.tsx b/src/Model.tsx index 67c073f..78826af 100644 --- a/src/Model.tsx +++ b/src/Model.tsx @@ -2,12 +2,18 @@ import * as Settings from "./Settings"; import * as Stage from "./Stage"; import * as Hover from "./Hover"; import * as ToolBox from "./ToolBox"; +import { Exp } from "./syntax/Exp"; + +/* Undo/redo over WORLD states (stage exps): every successful rewrite — + * button, drag, or restart — pushes the pre-state; undo/redo walk it. */ +export type History = { past: Exp[]; future: Exp[] }; export type Model = { stage: Stage.t; tools: ToolBox.t; settings: Settings.t; hover: Hover.t; + history: History; }; export type t = Model; @@ -17,4 +23,5 @@ export const init: Model = { tools: ToolBox.init, settings: Settings.init, hover: Hover.init, + history: { past: [], future: [] }, }; diff --git a/src/Navigate.tsx b/src/Navigate.tsx new file mode 100644 index 0000000..57a94d1 --- /dev/null +++ b/src/Navigate.tsx @@ -0,0 +1,224 @@ +import * as Stage from "./Stage"; +import * as Path from "./syntax/Path"; +import * as Statics from "./Statics"; + +/* Screen-space arrow-key navigation, columnar model. + * + * The half-flat projections lay every target out in bands along one + * axis: columns in TreeLeft (depth grows rightward), rows in TreeTop + * (depth grows downward), a single line in the linear projections. + * Movement reads off that structure: + * + * - along the depth axis, rootward: straight to the parent — the one + * direction where the tree has a unique answer, so descend-then- + * ascend always returns; + * - along the depth axis, deeper: hop to the nearest band in that + * direction, landing on its least-sideways candidate (near-ties go + * to the tree-closest — from the root, a child head beats an + * equally-near grandchild); + * - across it: scan within the current band to the next candidate; + * - when the banded rule finds nothing (band exhausted, the root's + * solo band, no bands ahead), fall back to the nearest candidate in + * a cone opening in the pressed direction — movement never feels + * dead where the screen plainly has something that way. + * + * Everything is measured from the rendered stage: nodes are points (an + * atom its own center, a comp its head's center — the head is where you + * visually "are"), the depth axis is detected from the spread of the + * root's kids (siblings fan out along the breadth axis), and a band is + * the set of points whose depth coordinate matches to within a fraction + * of the stage font. No per-projection dispatch; the linear projections + * degenerate to line-order for free. + */ + +export type Direction = "up" | "down" | "left" | "right"; + +type Pt = { x: number; y: number }; + +type Item = { pt: Pt; path: Path.t }; + +const VEC: Record = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, +}; + +/* Fallback cone half-width: forward offset must exceed this fraction of + * the sideways offset (~63° half-angle). */ +const CONE = 0.5; + +/* Candidates within this factor (plus jitter) of the best count as tied. */ +const TIE_BAND = 1.15; + +/* Position jitter tolerance, px. */ +const EPS = 2; + +/* Same-band tolerance, as a fraction of the stage font size: bands sit + * about a head-width apart, members within a couple of pixels. */ +const BAND_EM = 0.35; + +/* Edges between two nodes along tree paths (via their common prefix). */ +const tree_dist = (a: Path.t, b: Path.t): number => { + let c = 0; + while (c < a.length && c < b.length && a[c] === b[c]) c++; + return a.length - c + (b.length - c); +}; + +const center = (r: DOMRect): Pt => ({ + x: r.x + r.width / 2, + y: r.y + r.height / 2, +}); + +/* A node's navigation point: atoms their own center, comps their head's. + * (Motion-layer clones never match here: their ids are stripped.) */ +const point_of = (el: HTMLElement): Pt => { + const head = el.querySelector(":scope > .head"); + return center((head ?? el).getBoundingClientRect()); +}; + +/* Best of scored candidates: nearest, near-ties to the tree-closest. */ +const pick = ( + cands: { dist: number; path: Path.t }[], + from: Path.t +): Path.t | null => { + if (cands.length === 0) return null; + const nearest = Math.min(...cands.map((c) => c.dist)); + return cands + .filter((c) => c.dist <= nearest * TIE_BAND + EPS) + .reduce((a, b) => { + const ta = tree_dist(from, a.path); + const tb = tree_dist(from, b.path); + return tb < ta || (tb === ta && b.dist < a.dist) ? b : a; + }).path; +}; + +const nearest_in_cone = ( + cur: Pt, + from: Path.t, + items: Item[], + dir: Direction +): Path.t | null => { + const d = VEC[dir]; + const cands: { dist: number; path: Path.t }[] = []; + for (const it of items) { + const dx = it.pt.x - cur.x; + const dy = it.pt.y - cur.y; + const along = dx * d.x + dy * d.y; + const ortho = Math.abs(dx * d.y) + Math.abs(dy * d.x); + if (along <= CONE * ortho || along <= EPS) continue; + cands.push({ dist: Math.hypot(dx, dy), path: it.path }); + } + return pick(cands, from); +}; + +/* The selection to move to for an arrow press, or null for no move. */ +export const next = (stage: Stage.t, dir: Direction): Path.t | null => { + const container = document.querySelector( + "#stage .node-container" + ); + if (!container) return null; + /* Nothing selected: the first press steps from the window into the + * root of the tree, whatever the direction. */ + if (stage.selection === "unselected") return []; + const cur_el = container.querySelector( + '.node.selected[id^="node-"]' + ); + if (!cur_el) return []; + const cur = point_of(cur_el); + const from = stage.selection; + + const all: Item[] = []; + const items: Item[] = []; // all minus the current node + for (const el of Array.from( + container.querySelectorAll('.node[id^="node-"]') + )) { + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; // hidden probe/measure els + const id = parseInt(el.id.slice("node-".length), 10); + const item = { pt: point_of(el), path: Statics.get(stage.info, id).path }; + all.push(item); + if (el !== cur_el) items.push(item); + } + if (items.length === 0) return null; + + /* Siblings spread out along the breadth axis; the depth axis — the + * one bands quantize — is the other. Detected from the spread of the + * root's kids. (In the linear projections the kids spread on x and + * the "depth" axis degenerates to y: its single band is the whole + * line, cross-hops find no other band, and within-band scanning is + * exactly line order.) */ + let axis: "x" | "y" = "x"; + const kids = all.filter((i) => i.path.length === 1); + if (kids.length > 0) { + const range = (vals: number[]): number => + Math.max(...vals) - Math.min(...vals); + axis = range(kids.map((k) => k.pt.x)) > range(kids.map((k) => k.pt.y)) + ? "y" + : "x"; + } + const depth = (p: Pt): number => (axis === "x" ? p.x : p.y); + const breadth = (p: Pt): number => (axis === "x" ? p.y : p.x); + const d = VEC[dir]; + const depth_sign = axis === "x" ? d.x : d.y; + const band_px = + BAND_EM * parseFloat(getComputedStyle(container).fontSize) || 16; + + if (depth_sign !== 0) { + /* Rootward, the tree has exactly one right answer: the parent. The + * geometric hop lands there anyway whenever bands are generation- + * aligned; this makes the wobbly cases exact (TreeTop's sibling + * rows start at different depths, so the nearest band above can + * hold only a far-sideways sibling while the parent sits a band + * further). Rootward-ness is read from the geometry: the arrow + * points toward the root's end of the depth axis. In the linear + * projections the root shares the single band and no direction is + * ever rootward. */ + const root = all.find((i) => i.path.length === 0); + if (root && from.length > 0) { + const d_root = depth(root.pt) - depth(cur); + if (Math.abs(d_root) > band_px && Math.sign(d_root) === depth_sign) { + return from.slice(0, -1); + } + } + /* Band hop: nearest band in the pressed direction, least-sideways + * candidate within it. */ + const ahead = items.filter( + (i) => (depth(i.pt) - depth(cur)) * depth_sign > band_px + ); + if (ahead.length > 0) { + const min_d = Math.min( + ...ahead.map((i) => Math.abs(depth(i.pt) - depth(cur))) + ); + const res = pick( + ahead + .filter((i) => Math.abs(depth(i.pt) - depth(cur)) <= min_d + band_px) + .map((i) => ({ + dist: Math.abs(breadth(i.pt) - breadth(cur)), + path: i.path, + })), + from + ); + if (res) return res; + } + } else { + /* Scan within the current band. */ + const breadth_sign = axis === "x" ? d.y : d.x; + const res = pick( + items + .filter( + (i) => + Math.abs(depth(i.pt) - depth(cur)) <= band_px && + (breadth(i.pt) - breadth(cur)) * breadth_sign > EPS + ) + .map((i) => ({ + dist: Math.abs(breadth(i.pt) - breadth(cur)), + path: i.path, + })), + from + ); + if (res) return res; + } + /* The banded rule found nothing: diagonal fallback. */ + return nearest_in_cone(cur, from, items, dir); +}; diff --git a/src/Persist.tsx b/src/Persist.tsx new file mode 100644 index 0000000..bda4749 --- /dev/null +++ b/src/Persist.tsx @@ -0,0 +1,65 @@ +import * as Model from "./Model"; +import * as Stage from "./Stage"; +import * as ID from "./syntax/ID"; + +/* Session persistence: the model's own state (world, selection, settings, + * loadout, history) survives refresh via localStorage — it's a few KB of + * plain JSON, so synchronous storage keeps boot simple (no async flash; + * IndexedDB would buy nothing here). Derived state (statics, transforms) + * is rebuilt. The ID counter is persisted too: restored exps carry minted + * ids, and a fresh counter would re-mint them into collisions. */ + +const KEY = "nool-state-v1"; +let timer: number | undefined; + +export const save_soon = (model: Model.t): void => { + clearTimeout(timer); + timer = window.setTimeout(() => { + try { + localStorage.setItem( + KEY, + JSON.stringify({ + id_gen: ID.current(), + exp: model.stage.exp, + selection: model.stage.selection, + settings: model.settings, + dragActive: model.tools.dragActive, + size: model.tools.size, + history: model.history, + }) + ); + } catch { + /* storage full/unavailable: session just won't persist */ + } + }, 250); +}; + +export const clear = (): void => { + try { + localStorage.removeItem(KEY); + } catch {} +}; + +export const load = (): Model.t | null => { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return null; + const s = JSON.parse(raw); + if (!s || !s.exp) return null; + ID.bump(s.id_gen ?? 0); + const stage = Stage.put_exp(Model.init.stage, s.exp); + return { + ...Model.init, + stage: { ...stage, selection: s.selection ?? "unselected" }, + settings: { ...Model.init.settings, ...(s.settings ?? {}) }, + tools: { + ...Model.init.tools, + dragActive: s.dragActive ?? Model.init.tools.dragActive, + size: s.size ?? Model.init.tools.size, + }, + history: s.history ?? { past: [], future: [] }, + }; + } catch { + return null; + } +}; diff --git a/src/Settings.tsx b/src/Settings.tsx index 4cc1600..7a84105 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -6,6 +6,10 @@ export type symbols = "Emoji" | "SingleChar"; export type theme = "Light" | "Dark"; +/* drag dispatch mechanic: knob-on-rails vs per-frame nearest-candidate + * (experimental comparison toggle; see design/captured-geometry.md) */ +export type dragMechanic = "Rails" | "Closest"; + export type t = { sound: boolean; motion: motion; @@ -13,6 +17,12 @@ export type t = { projection: projection; symbols: symbols; theme: theme; + /* drag mode: stage pointerdown grabs nodes for drag-rewrites instead of + * selecting for the noolbox */ + dragging: boolean; + dragMechanic: dragMechanic; + /* drag debug overlay: rails, anchor dots, live t readout */ + dragDebug: boolean; }; export type Action = @@ -21,7 +31,16 @@ export type Action = | "TogglePreview" | "ToggleProjection" | "ToggleSymbols" - | "ToggleDark"; + | "ToggleDark" + | "ToggleDragging" + | "ToggleDragMechanic" + | "ToggleDragDebug"; + +/* Touch devices default to drag mode (their natural interaction). */ +const coarse_pointer = + typeof window !== "undefined" && + !!window.matchMedia && + window.matchMedia("(pointer: coarse)").matches; export const init: t = { sound: true, @@ -30,6 +49,9 @@ export const init: t = { projection: "TreeLeft", symbols: "Emoji", theme: "Light", + dragging: coarse_pointer, + dragMechanic: "Rails", + dragDebug: true, }; export const update = (settings: t, action: Action): t => { @@ -73,5 +95,14 @@ export const update = (settings: t, action: Action): t => { case "Dark": return { ...settings, theme: "Light" }; } + case "ToggleDragging": + return { ...settings, dragging: !settings.dragging }; + case "ToggleDragMechanic": + return { + ...settings, + dragMechanic: settings.dragMechanic === "Rails" ? "Closest" : "Rails", + }; + case "ToggleDragDebug": + return { ...settings, dragDebug: !settings.dragDebug }; } }; diff --git a/src/Sound.tsx b/src/Sound.tsx index 333cc3f..5bf8553 100644 --- a/src/Sound.tsx +++ b/src/Sound.tsx @@ -47,6 +47,124 @@ export const sfx_reverse = (sfx: Sfxbank) => () => { p.start(); }; +/* # Drag plucks — quantized chords as a drag crosses the quarter points + * of its rail, dropped an octave when it recedes. Drags speak in plucks; + * the rewrite samples are button-mode sounds. (Chosen over granular + * sample-scrubbing, detents, and a tension layer — all tried, + * 2026-07-30.) + * + * Pluck CONTENT is the rule's operator multiset (see design/sound.md): + * each operator symbol has a pitch class — the sound ALPHABET, the one + * axiomatic choice; everything above it derives. A rule's quarter-point + * plucks walk its content from source to result: ¼ = source multiset, + * ½ = the invariant core (source ∩ result), ¾ = result multiset. + * Multiplicity stacks octaves ("associativity has two pluses" is the + * plus-note doubled), created content audibly arrives, consumed content + * departs, and flip swaps the walk. Advancing plucks crescendo; + * receding replays the chord an octave down, quieter. */ +const OP_PITCH: Record = { "➕": "C", "✖️": "G", "➖": "Eb" }; +const OP_FALLBACK = "D"; // operators outside the alphabet +const EMPTY_NOTE = "A3"; // the bare-variable world: no structure yet +const OCTAVES = [4, 5, 3, 6]; // multiplicity → octave stacking + +const op_note = (op: string): string => OP_PITCH[op] ?? OP_FALLBACK; + +const chord_of = (ops: string[]): string[] => { + if (ops.length === 0) return [EMPTY_NOTE]; + const sorted = [...ops].sort( + (a, b) => op_note(a).localeCompare(op_note(b)) || a.localeCompare(b) + ); + const counts = new Map(); + return sorted.map((op) => { + const n = counts.get(op) ?? 0; + counts.set(op, n + 1); + return `${op_note(op)}${OCTAVES[Math.min(n, OCTAVES.length - 1)]}`; + }); +}; + +/* multiset intersection */ +const isect = (a: string[], b: string[]): string[] => { + const rest = [...b]; + return a.filter((x) => { + const i = rest.indexOf(x); + if (i < 0) return false; + rest.splice(i, 1); + return true; + }); +}; + +/* # Harmony experiment (Shift+A toggles): layer the retired quality + * channel back UNDER the content chords — one added note, a third above + * each chord's root: major (4 semitones) when the rewrite grows, minor + * (3) when it shrinks, none when isomorphic. The content letters are + * untouched; the third only colors the mood. */ +let harmonized = false; +export const toggle_harmony = (): string => + (harmonized = !harmonized) ? "content+harmony" : "content"; + +const SEMI_OF: Record = { + C: 0, "C#": 1, Db: 1, D: 2, Eb: 3, E: 4, F: 5, + "F#": 6, Gb: 6, G: 7, Ab: 8, A: 9, Bb: 10, B: 11, +}; +const SEMI_NAMES = ["C","C#","D","Eb","E","F","F#","G","Ab","A","Bb","B"]; +const transpose = (note: string, semis: number): string => { + const m = note.match(/^([A-G][b#]?)(\d)$/); + if (!m) return note; + const midi = SEMI_OF[m[1]] + 12 * (+m[2] + 1) + semis; + return `${SEMI_NAMES[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`; +}; + +let drag_active = false; +let drag_last_t = 0; +let pluck_chords: string[][] = []; + +const pluck = (notes: readonly string[], vol: number): void => { + synth.volume.value = vol; + synth.triggerAttackRelease(notes as string[], "64n"); +}; + +const octave_down = (n: string): string => n.replace(/\d/, (d) => `${+d - 1}`); + +const PLUCK_TS = [0.25, 0.5, 0.75]; +const PLUCK_VOLS = [-20, -17, -14]; // crescendo toward commit + +export const drag_sound_start = ( + source_ops: string[], + result_ops: string[] +): void => { + drag_active = true; + drag_last_t = 0; + pluck_chords = [ + chord_of(source_ops), + chord_of(isect(source_ops, result_ops)), + chord_of(result_ops), + ]; + if (harmonized) { + const delta = result_ops.length - source_ops.length; + if (delta !== 0) + pluck_chords = pluck_chords.map((c) => [ + ...c, + transpose(c[0], delta > 0 ? 4 : 3), + ]); + } +}; + +export const drag_sound_set = (t: number): void => { + if (!drag_active) return; + const tt = Math.max(0, Math.min(1, t)); + PLUCK_TS.forEach((th, i) => { + if (drag_last_t < th && tt >= th) pluck(pluck_chords[i], PLUCK_VOLS[i]); + if (drag_last_t >= th && tt < th) + pluck(pluck_chords[i].map(octave_down), -24); + }); + drag_last_t = tt; +}; + +export const drag_sound_stop = (): void => { + drag_active = false; + drag_last_t = 0; +}; + const player = new Tone.Player(pew).toDestination(); let rev2 = new Tone.Reverb(2).toDestination(); player.connect(rev2); @@ -65,6 +183,30 @@ synth.connect(rev); let tremolo = new Tone.Tremolo(70, 1.0).toDestination().start(); synth.connect(tremolo); + +/* # The bong — striking the seed (the ∞ between the boxes), a demo's + * closing touchstone. A deep membrane boom on C — the pluck alphabet's + * root — with the alphabet's open fifth (C+G: plus and times) chiming + * above it, left to ring through a long tail. */ +const bong_verb = new Tone.Reverb({ decay: 6, wet: 0.5 }).toDestination(); +const bong_drum = new Tone.MembraneSynth({ + pitchDecay: 0.09, + octaves: 7, + envelope: { attack: 0.001, decay: 1.1, sustain: 0.01, release: 1.6 }, +}).toDestination(); +bong_drum.connect(bong_verb); +bong_drum.volume.value = -2; +const bong_chime = new Tone.PolySynth(Tone.Synth, { + envelope: { attack: 0.004, decay: 1.2, sustain: 0.04, release: 2.5 }, +}).toDestination(); +bong_chime.connect(bong_verb); +bong_chime.volume.value = -16; + +export const bong = (): void => { + const now = Tone.now(); + bong_drum.triggerAttackRelease("C2", "2n", now); + bong_chime.triggerAttackRelease(["C4", "G4"], "1n", now + 0.02); +}; //let vib = new Tone.Vibrato ( 10,0.3 ).toDestination(); //synth.connect(vib); diff --git a/src/Stage.tsx b/src/Stage.tsx index 325068e..3695951 100644 --- a/src/Stage.tsx +++ b/src/Stage.tsx @@ -3,7 +3,6 @@ import * as Statics from "./Statics"; import * as Path from "./syntax/Path"; import * as Projector from "./Projector"; import * as World from "./data/World"; -import { is_path_valid } from "./syntax/Node"; export type selection = "unselected" | Path.t; @@ -38,79 +37,3 @@ export const put_selection = (stage: Stage, path: selection): Stage => ({ export const unset_selection = (stage: Stage): Stage => put_selection(stage, "unselected"); - -const rev = (path: Path.t): Path.t => { - const blah = [...path]; - return blah.reverse(); -}; - -const move_up = (exp: Exp, selection: number[]): Path.t => { - if (selection.length === 0) return selection; - const [last, ...tl] = rev(selection); - const new_path = rev([last - 1, ...tl]); - //TODO: hack, hardcoded skipping of head via !=1 check - if (last != 1 && is_path_valid(new_path, exp)) { - return new_path; - } else return selection; -}; - -const move_down = (exp: Exp, selection: number[]): Path.t => { - /* first, try to increment last idx of selection. - if that gives a valid path, return it. - then try to increment second last index, etc. - If none of those are valid, - drop the last element of the selection, and recurse. - */ - if (selection.length === 0) return selection; - for (let i = selection.length - 1; i >= 0; i--) { - const new_path = [...selection]; - new_path[i]++; - for (let j = i + 1; j < new_path.length; j++) { - new_path[j] = 1; /* 1 is starting index as we're skipping the head */ - } - if (is_path_valid(new_path, exp)) { - return new_path; - } - } - return move_down(exp, Array(selection.length).fill(0)); -}; - -const move_left = (_exp: Exp, selection: number[]): Path.t => { - if (selection.length === 0) { - return selection; - } else { - return selection.slice(0, selection.length - 1); - } -}; - -const move_right = (exp: Exp, selection: number[]): Path.t => { - const new_selection = [...selection, 1]; - if (is_path_valid(new_selection, exp)) { - return new_selection; - } else { - return selection; - } -}; - -export const move_ = ( - stage: Stage, - direction: "up" | "down" | "left" | "right" -): Path.t => { - if (stage.selection === "unselected") return []; - const selection = stage.selection; - switch (direction) { - case "up": - return move_up(stage.exp, selection); - case "down": - return move_down(stage.exp, selection); - case "left": - return move_left(stage.exp, selection); - case "right": - return move_right(stage.exp, selection); - } -}; - -export const move = ( - stage: Stage, - direction: "up" | "down" | "left" | "right" -) => put_selection(stage, move_(stage, direction)); diff --git a/src/ToolBox.tsx b/src/ToolBox.tsx index b48e0eb..fd1691a 100644 --- a/src/ToolBox.tsx +++ b/src/ToolBox.tsx @@ -6,17 +6,26 @@ import * as Action from "./Action"; export type t = { selector: Path.t; transforms: Transform.t[]; - offset: number; size: number; + /* which tools generate drag candidates (index-aligned with transforms; + * both directions of a tool together) */ + dragActive: boolean[]; }; export const init: t = { selector: [], transforms: Tools.init, - offset: 0, size: 5, + /* default loadout: associativity (+,×), commutativity (+,×), + * additive identity, and distributivity */ + dragActive: Tools.init.map((_, i) => [0, 1, 2, 4, 5, 7].includes(i)), }; +export const toggle_drag_tool = (tools: t, idx: number): t => ({ + ...tools, + dragActive: tools.dragActive.map((a, i) => (i === idx ? !a : a)), +}); + export const update_selector = (tools: t, f: (_: Path.t) => Path.t): t => ({ ...tools, selector: f(tools.selector), diff --git a/src/Update.tsx b/src/Update.tsx index 1e779ca..a1bdc12 100644 --- a/src/Update.tsx +++ b/src/Update.tsx @@ -1,5 +1,4 @@ import { at_path } from "./Transform"; -import Flipping from "flipping/lib/adapters/web"; import * as Model from "./Model"; import * as Sound from "./Sound"; import * as Settings from "./Settings"; @@ -8,13 +7,11 @@ import * as Action from "./Action"; import * as Transform from "./Transform"; import * as ToolBox from "./ToolBox"; import * as Hover from "./Hover"; +import * as Persist from "./Persist"; import { freshen } from "./syntax/Node"; -import * as Exp from "./syntax/Exp"; import * as Pat from "./syntax/Pat"; import { SetStoreFunction } from "solid-js/store"; import * as Path from "./syntax/Path"; -import * as Animate from "./Animate"; -import * as Util from "./Util"; export type result = Model.t | "NoChange"; @@ -41,14 +38,18 @@ export const sound = (model: Model.t, action: Action.t): void => { Sound.noop(); } break; + /* transformNode (drag commits) is deliberately absent: drags speak in + * plucks (Sound.drag_sound_*); the samples are button-mode sounds */ case "setSelect": Sound.select(action.path.length, pitch, volume); break; case "unsetSelections": Sound.unselect("D2", 0.6); break; + case "strikeSeed": + Sound.bong(); + break; case "moveTool": - case "moveStage": Sound.select(model.stage.selection.length, pitch, volume); break; case "setSetting": @@ -69,13 +70,14 @@ const update_stage = (model: Model.t, result: Pat.TransformResult): result => /* Freshening as-is is a hack to deal with e.g. distributivity which copies nodes */ result == "NoMatch" ? "NoChange" - : { ...model, stage: Stage.put_exp(model.stage, freshen(result)) }; - -type ModelField = - | { t: "stage"; path: Path.t; updater: any } - | { t: "tools"; path: Path.t; updater: any } - | { t: "hover"; hover: Hover.t } - | { t: "settings"; settings: Settings.t }; + : { + ...model, + stage: Stage.put_exp(model.stage, freshen(result)), + history: { + past: [...model.history.past, model.stage.exp], + future: [], + }, + }; /*export const imperative_update = ( setModel: SetStoreFunction, @@ -107,12 +109,66 @@ type ModelField = export const update = (model: Model.t, action: Action.t): result => { switch (action.t) { case "restart": - return Model.init; - case "setSetting": + /* undoable: Escape shouldn't be able to destroy work */ + return { + ...Model.init, + history: { + past: [...model.history.past, model.stage.exp], + future: [], + }, + }; + case "hardReset": + /* everything back to factory: world, settings, loadout, history, + * and the persisted session */ + Persist.clear(); + return { ...Model.init }; + case "undo": { + const { past, future } = model.history; + if (past.length === 0) return "NoChange"; return { ...model, - settings: Settings.update(model.settings, action.action), + stage: Stage.unset_selection( + Stage.put_exp(model.stage, past[past.length - 1]) + ), + hover: Hover.init, + history: { + past: past.slice(0, -1), + future: [...future, model.stage.exp], + }, }; + } + case "redo": { + const { past, future } = model.history; + if (future.length === 0) return "NoChange"; + return { + ...model, + stage: Stage.unset_selection( + Stage.put_exp(model.stage, future[future.length - 1]) + ), + hover: Hover.init, + history: { + past: [...past, model.stage.exp], + future: future.slice(0, -1), + }, + }; + } + case "setSetting": { + const settings = Settings.update(model.settings, action.action); + /* Entering drag mode retires the selection mechanic wholesale + * (pointerdown grabs; see also App/SeedView/Keyboard gating). */ + const entering_drag = settings.dragging && !model.settings.dragging; + return { + ...model, + settings, + ...(entering_drag + ? { + stage: Stage.unset_selection(model.stage), + tools: ToolBox.unset(model.tools), + hover: Hover.init, + } + : {}), + }; + } case "setSelect": if ( model.stage.selection === "unselected" @@ -124,8 +180,6 @@ export const update = (model: Model.t, action: Action.t): result => { case "setHover": if (Hover.eq(model.hover, action.target)) return "NoChange"; return { ...model, hover: action.target }; - case "moveStage": - return { ...model, stage: Stage.move(model.stage, action.direction) }; case "moveTool": let tools = ToolBox.move(model.tools, action.direction); const hover: Hover.t = { @@ -135,6 +189,9 @@ export const update = (model: Model.t, action: Action.t): result => { }; return { ...model, tools, hover: hover }; case "unsetSelections": + /* striking the seed doubles as a background click: same clearing, + * its own sound */ + case "strikeSeed": return { ...model, stage: Stage.unset_selection(model.stage), @@ -191,20 +248,13 @@ export const update = (model: Model.t, action: Action.t): result => { ...model, tools: ToolBox.flip_transform(model.tools, action.idx), }; - case "Noop": - return "NoChange"; - case "wheelTools": - console.log("wheelTools:" + action.offset + ":" + model.tools.offset); + case "toggleDragTool": return { ...model, - tools: { - ...model.tools, - offset: Util.mod( - model.tools.offset + action.offset, - model.tools.transforms.length - ), - }, + tools: ToolBox.toggle_drag_tool(model.tools, action.idx), }; + case "Noop": + return "NoChange"; case "wheelNumTools": const clamp = (x:number, a:number, b:number) => Math.max( a, Math.min(x, b) ); console.log("wheelNumTools:" + action.offset + ":" + model.tools.size); @@ -230,12 +280,6 @@ export const go = ( action: Action.t ): void => { if (model.settings.sound) sound(model, action); - /* Catching because problem on build server */ - try { - //Animate.read(model, action); - } catch (e) { - console.error(e); - } const result = update(model, action); if (result == "NoChange") { //Sound.noop(); @@ -244,6 +288,7 @@ export const go = ( } else { console.log("Action Success: " + action.t); setModel(result); + Persist.save_soon(result); } /* HACK: We want transforms the duplicate subtrees e.g. distributivity to * retain their duplicate ids for animations, but then we need to freshen @@ -257,10 +302,4 @@ export const go = ( stage: Stage.put_exp(model.stage, freshened), }); }, 250);*/ - /* Catching because problem on build server */ - try { - //Animate.flip(model, action); - } catch (e) { - console.error(e); - } }; diff --git a/src/data/Tools.tsx b/src/data/Tools.tsx index 5305cc4..9234c91 100644 --- a/src/data/Tools.tsx +++ b/src/data/Tools.tsx @@ -137,8 +137,8 @@ const makers = [ //zap, ]; -const mk = ({ source, result, sound }: Base): Transform.t => ({ - name: "", +const mk = (name: string, { source, result, sound }: Base): Transform.t => ({ + name, source, result, sound: Sound.sfx(sound), @@ -146,16 +146,18 @@ const mk = ({ source, result, sound }: Base): Transform.t => ({ reversed: false, }); -export const init = [ +/* Entry keys become the transforms' names (drag-debug codes derive from + * them); object literal order is the toolbox order. */ +export const init = Object.entries({ associate_plus, commute_plus, identity_plus, + commute_times, + distribute_times_plus, inverse_plus, associate_times, - commute_times, identity_times, - distribute_times_plus, double_neg, -].map(mk); +}).map(([name, base]) => mk(name, base)); -export const _init =makers.map(mk); \ No newline at end of file +export const _init = makers.map((base) => mk("", base)); \ No newline at end of file diff --git a/src/drag/Drag.tsx b/src/drag/Drag.tsx new file mode 100644 index 0000000..d75710f --- /dev/null +++ b/src/drag/Drag.tsx @@ -0,0 +1,968 @@ +/* Drag: whole-scene morph dragging (plan steps 2-3). + * + * Grabbing a stage node (drag mode on) enumerates the states reachable by + * applying enabled transforms at that node, renders each candidate into a + * hidden probe inside #stage, and batch-measures per-id boxes. Probes stay + * alive for the drag so their elements can be cloned into motion layers. + * + * Dragging is closest-of-betweens over the candidates (dragology's algebra + * on measured boxes): the pointer projects onto the segment from the grab + * point to each candidate's anchor (the grabbed node's would-be position); + * the nearest segment wins and its t drives Motion's manual mode, morphing + * the whole scene toward that candidate. Release past t=0.5 commits (the + * blend hands off seamlessly to the commit animation); otherwise the scene + * springs back. See design/captured-geometry.md. + */ +import { render } from "solid-js/web"; +import * as Model from "./../Model"; +import * as Statics from "./../Statics"; +import * as Settings from "./../Settings"; +import * as Action from "./../Action"; +import * as Exp from "./../syntax/Exp"; +import * as ID from "./../syntax/ID"; +import * as Motion from "./../motion/Motion"; +import { at_path, flip, Transform } from "./../Transform"; +import * as Sound from "./../Sound"; +import * as Pat from "./../syntax/Pat"; +import { depth, freshen, id_at, subtree_at } from "./../syntax/Node"; +import { ViewOnly } from "./../view/ExpView"; + +export type Candidate = { + transform: Transform; + idx: number; // index into tools.transforms + reversed: boolean; + site: number[]; // path the transform applies at (the grab or an ancestor) + exp: Exp.t; + measured: Map; + dispose: () => void; + /* grabbed node's box in this candidate (always present: candidates where + * the grab vanishes are not offered — like the demo, triggers survive) */ + anchor: Motion.Box; + /* provenance geometry: which box each created node emerges from (clone + * vs grow) and which box each consumed node converges into (merge vs + * absorb). Derived by structural equality against the pre-state — + * provenance-lite until rewrites record it themselves (see design doc). */ + emerge: Map; + converge: Map; +}; + +/* # Provenance-lite: structural diffing of live vs candidate expressions. + * A created node that structurally equals an existing (site-scoped) node + * is a CLONE of it; otherwise it is genuinely new and GROWs from the grab. + * A removed subtree that structurally equals a surviving candidate subtree + * MERGEs into it; otherwise it is ABSORBed into the site's replacement. */ + +const all_subtrees = (e: Exp.t, out: Exp.t[] = []): Exp.t[] => { + out.push(e); + if (e.t === "Comp") e.kids.forEach((k) => all_subtrees(k, out)); + return out; +}; + +const index_by_id = (e: Exp.t, m: Map = new Map()): Map => { + m.set(e.id, e); + if (e.t === "Comp") e.kids.forEach((k) => index_by_id(k, m)); + return m; +}; + +const head_sym = (e: Exp.t): string | null => + e.t === "Comp" && e.kids[0]?.t === "Atom" ? e.kids[0].sym : null; + +/* Exported: the button path (App.inject) uses the same provenance for + * click-applied transforms — the site is the selection, the trigger the + * selected node. Only key-membership is read from the box maps. */ +export const provenance = ( + liveExp: Exp.t, + live: ReadonlyMap, + candExp: Exp.t, + cand: ReadonlyMap, + site: number[], + grabbedKey: string +): { emerge: Map; converge: Map } => { + const emerge = new Map(); + const converge = new Map(); + const candIdx = index_by_id(candExp); + const liveIdx = index_by_id(liveExp); + const siteLive = subtree_at(site, liveExp); + const siteSubs = siteLive ? all_subtrees(siteLive) : []; + /* HEAD origin, not center origin: when a grow source (or absorb + * target) is a comp, anchor at its head's box — new material sprouting + * from a big composite's geometric center reads as a dot materializing + * amid its children (identity-intro on a large operand was the tell). */ + const head_key_in = ( + idx: Map, + boxes: ReadonlyMap, + key: string + ): string => { + const n = idx.get(+key.slice(5)); + return n?.t === "Comp" && n.kids[0] && boxes.has(`node-${n.kids[0].id}`) + ? `node-${n.kids[0].id}` + : key; + }; + const grow_src = head_key_in(liveIdx, live, grabbedKey); + /* Bystanders are not twins: a comp only clones from / merges into a + * same-headed comp that itself CHANGED in the rewrite. Without this, + * double-neg elimination merged its dying negations onto any minus + * that happened to survive in the site (the mushroom's), and intro + * cloned its new negations out of it. And comp HEADS never twin-match + * on their own — they follow their comp (or grow/absorb with it); + * otherwise the dying glyph flies to an unrelated same-glyph head. */ + const changed_in_rewrite = (id: number): boolean => { + const l = liveIdx.get(id); + const c = candIdx.get(id); + return !l || !c || !Exp.equals(l, c); + }; + const comp_heads = new Set(); + for (const idx of [candIdx, liveIdx]) + for (const n of idx.values()) + if (n.t === "Comp" && n.kids[0]?.t === "Atom") comp_heads.add(n.kids[0].id); + /* enters (node layers) */ + for (const key of cand.keys()) { + if (live.has(key) || !key.startsWith("node-")) continue; + const n = candIdx.get(+key.slice(5)); + if (!n) continue; + let spec: Motion.EmergeSpec = { source: grow_src, mode: "grow" }; + if (n.t === "Atom") { + const m = comp_heads.has(n.id) + ? undefined + : siteSubs.find((s) => s.t === "Atom" && Exp.equals(s, n)); + if (m && live.has(`node-${m.id}`)) + spec = { source: `node-${m.id}`, mode: "clone" }; + } else { + const hs = head_sym(n); + const m = hs + ? siteSubs.find( + (s) => + head_sym(s) === hs && + live.has(`node-${s.id}`) && + changed_in_rewrite(s.id) + ) + : undefined; + if (m) { + spec = { source: `node-${m.id}`, mode: "clone" }; + /* the clone's head separates from the source's head */ + const nh = n.kids[0]; + const mh = (m as Exp.t & { t: "Comp" }).kids[0]; + if (nh && mh && !live.has(`node-${nh.id}`) && live.has(`node-${mh.id}`)) + emerge.set(`node-${nh.id}`, { source: `node-${mh.id}`, mode: "clone" }); + } + } + if (!emerge.has(key)) emerge.set(key, spec); + } + /* sym layers follow their node's source (sym→sym when possible) */ + for (const key of cand.keys()) { + if (live.has(key) || !key.startsWith("sym-")) continue; + const nodeSpec = emerge.get(`node-${key.slice(4)}`); + if (!nodeSpec) continue; + const symSrc = `sym-${nodeSpec.source.slice(5)}`; + emerge.set(key, { + source: live.has(symSrc) ? symSrc : nodeSpec.source, + mode: nodeSpec.mode, + }); + } + /* exits: per-node, exactly dual to enters. Atoms merge into a + * site-scoped structural twin; comps merge into a site-scoped same-head + * comp (their heads following head→head); anything without a + * counterpart absorbs into the site's replacement. */ + const siteRoot = id_at(site, candExp); + const siteKey = siteRoot !== undefined ? `node-${siteRoot}` : null; + const siteCand = subtree_at(site, candExp); + const siteCandSubs = siteCand ? all_subtrees(siteCand) : []; + for (const [key] of live) { + if (cand.has(key) || !key.startsWith("node-")) continue; + const g = liveIdx.get(+key.slice(5)); + if (!g) continue; + let spec: Motion.ConvergeSpec | null = null; + if (g.t === "Atom") { + const twin = comp_heads.has(g.id) + ? undefined + : siteCandSubs.find( + (s) => s.t === "Atom" && Exp.equals(s, g) && cand.has(`node-${s.id}`) + ); + if (twin) spec = { target: `node-${twin.id}`, mode: "merge" }; + } else { + const hs = head_sym(g); + const twin = hs + ? siteCandSubs.find( + (s) => + head_sym(s) === hs && + cand.has(`node-${s.id}`) && + changed_in_rewrite(s.id) + ) + : undefined; + if (twin) { + spec = { target: `node-${twin.id}`, mode: "merge" }; + /* the comp's head merges into the twin's head */ + const gh = g.kids[0]; + const th = (twin as Exp.t & { t: "Comp" }).kids[0]; + if (gh && th && !cand.has(`node-${gh.id}`) && cand.has(`node-${th.id}`)) + converge.set(`node-${gh.id}`, { + target: `node-${th.id}`, + mode: "merge", + }); + } + } + if (!spec && siteKey && cand.has(siteKey)) + spec = { target: head_key_in(candIdx, cand, siteKey), mode: "absorb" }; + if (spec && !converge.has(key)) converge.set(key, spec); + } + /* sym exits follow their node's target (sym→sym when possible) */ + for (const [key] of live) { + if (cand.has(key) || !key.startsWith("sym-")) continue; + const nodeSpec = converge.get(`node-${key.slice(4)}`); + if (!nodeSpec) continue; + const symTgt = `sym-${nodeSpec.target.slice(5)}`; + converge.set(key, { + target: cand.has(symTgt) ? symTgt : nodeSpec.target, + mode: nodeSpec.mode, + }); + } + return { emerge, converge }; +}; + +/* Prefer-the-grabbed-copy (the demo's rule, done at the candidate level): + * if a rewrite consumed the grabbed subtree but kept a structural twin + * (merge rules pick an arbitrary copy's ids), splice the grabbed subtree — + * ids and all — over the twin. The grab then survives, both copies afford + * the drag, and the OTHER copy becomes the exit that merges into it. Sound + * because we commit the exact candidate exp. */ +const replace_by_id = (e: Exp.t, targetId: number, repl: Exp.t): Exp.t => { + if (e.id === targetId) return repl; + if (e.t === "Atom") return e; + const kids = e.kids.map((k) => replace_by_id(k, targetId, repl)); + return kids.every((k, i) => k === e.kids[i]) ? e : { ...e, kids }; +}; + +const prefer_grab = ( + candExp: Exp.t, + site: number[], + liveGrab: Exp.t | undefined +): Exp.t => { + if (!liveGrab) return candExp; + if (index_by_id(candExp).has(liveGrab.id)) return candExp; // grab survives + const siteCand = subtree_at(site, candExp); + const twin = siteCand + ? all_subtrees(siteCand).find((s) => Exp.equals(s, liveGrab)) + : undefined; + return twin ? replace_by_id(candExp, twin.id, liveGrab) : candExp; +}; + +/* Must mirror StageView's depth-derived scale. */ +const stage_scale = (d: number) => (d == 0 ? 1 : 4 / (d + 1)); + +const clamp01 = (x: number) => Math.max(0, Math.min(1, x)); + +/* All states reachable by applying an enabled transform (either direction) + * at the grabbed node OR any of its ancestors, ids preserved, structurally + * deduplicated. Ancestor sites are what make dragging feel right: commuting + * a parent doesn't move the parent, it moves its operands — so the operand + * is what you grab, and its anchor is what travels. (Rules at sites where + * the grab doesn't move are filtered later by minimum travel.) */ +export const enumerate = ( + model: Model.t, + grabbedId: ID.t +): { + transform: Transform; + idx: number; + reversed: boolean; + site: number[]; + exp: Exp.t; +}[] => { + const path = Statics.get(model.stage.info, grabbedId).path; + const sites: number[][] = []; + for (let n = path.length; n >= 0; n--) sites.push(path.slice(0, n)); + const out: { + transform: Transform; + idx: number; + reversed: boolean; + site: number[]; + exp: Exp.t; + }[] = []; + for (const site of sites) { + model.tools.transforms.forEach((t, idx) => { + /* per-rule loadout toggles (the circles beside the rules) */ + if (!model.tools.dragActive[idx]) return; + for (const [transform, reversed] of [ + [t, false], + [flip(t), true], + ] as const) { + const r = at_path(transform, site)(model.stage.exp); + if (r === "NoMatch") continue; + const exp = freshen(r); // same normalization a commit would get + if (out.some((c) => Exp.equals(c.exp, exp))) continue; + out.push({ transform, idx, reversed, site, exp }); + } + }); + } + return out; +}; + +/* Render a candidate into a hidden probe inside #stage and measure per-id + * boxes, center-aligned to the live scene (approximating flex centering). + * The probe stays mounted (hidden) so its elements can be cloned; call + * dispose() when the drag ends. */ +const probe_candidate = ( + exp: Exp.t, + settings: Settings.t +): { measured: Map; dispose: () => void } | null => { + const stageEl = document.getElementById("stage"); + const live = stageEl?.querySelector(".node-container"); + const main = document.getElementById("main"); + if (!stageEl || !live || !main) return null; + const host = document.createElement("div"); + host.className = "drag-probe"; + const mainPx = parseFloat(getComputedStyle(main).fontSize); + host.style.fontSize = `${mainPx * stage_scale(depth(exp))}px`; + stageEl.appendChild(host); + const dispose_solid = render( + () => ( +
+ +
+ ), + host + ); + const dispose = () => { + dispose_solid(); + host.remove(); + }; + const cont = host.firstElementChild as HTMLElement | null; + if (!cont) { + dispose(); + return null; + } + const lr = live.getBoundingClientRect(); + const pr = cont.getBoundingClientRect(); + const dx = lr.x + lr.width / 2 - (pr.x + pr.width / 2); + const dy = lr.y + lr.height / 2 - (pr.y + pr.height / 2); + return { measured: Motion.measure_root(cont, dx, dy), dispose }; +}; + +/* # Pattern-level moverhood (see design/captured-geometry.md) + * + * The affordance filter reads the grab's role out of the rule's own + * patterns. Matching the source pattern against the site classifies the + * grabbed node as matched structure (a pattern comp/const), the root of + * a variable's binding, or strictly inside one. Inside-a-binding nodes + * are passengers (they ride; grab the binding's root instead). Everyone + * else moves iff their pattern-level position differs between source and + * result — positions are pattern paths annotated with the pattern ids + * they descend through, and multi-occurrence variables/nodes compare + * their whole occurrence sets. So the shared factor in distribute/factor + * is a mover in BOTH directions (its occurrence set changes shape), + * while a rewrite's fixed points (the root plus under commute or + * associate) stay parked; consumed/created grabs always participate. */ + +type GrabRole = + | { t: "structure"; pid: number } + | { t: "binding"; name: string } + | { t: "inside" } + | null; + +const contains_id = (e: Exp.t, id: ID.t): boolean => + e.id === id || (e.t === "Comp" && e.kids.some((k) => contains_id(k, id))); + +/* Walk pattern and (matching) exp in parallel to classify the grab. */ +const grab_role = (pat: Pat.t, exp: Exp.t, id: ID.t): GrabRole => { + if (pat.t === "Atom" && pat.sym.t === "Var") { + if (exp.id === id) return { t: "binding", name: pat.sym.name }; + return contains_id(exp, id) ? { t: "inside" } : null; + } + if (exp.id === id) return { t: "structure", pid: pat.id }; + if (pat.t === "Comp" && exp.t === "Comp") + for (let i = 0; i < pat.kids.length && i < exp.kids.length; i++) { + const r = grab_role(pat.kids[i], exp.kids[i], id); + if (r) return r; + } + return null; +}; + +/* Every occurrence's annotated path, per variable name and pattern id. */ +const pat_positions = ( + p: Pat.t +): { vars: Map; nodes: Map } => { + const vars = new Map(); + const nodes = new Map(); + const go = (n: Pat.t, path: string): void => { + if (n.t === "Atom" && n.sym.t === "Var") + vars.set(n.sym.name, [...(vars.get(n.sym.name) ?? []), path]); + else nodes.set(n.id, [...(nodes.get(n.id) ?? []), path]); + if (n.t === "Comp") + n.kids.forEach((k, i) => go(k, `${path}${n.id}.${i}/`)); + }; + go(p, ""); + return { vars, nodes }; +}; + +const occ_key = (paths: string[] | undefined): string => + paths ? [...paths].sort().join("|") : "∅"; + +const grab_is_mover = ( + t: Transform, + site: Exp.t | undefined, + grabbedId: ID.t +): boolean => { + if (!site) return true; + const role = grab_role(t.source, site, grabbedId); + if (role === null) return true; // outside the match: genuine participant + if (role.t === "inside") return false; + const src = pat_positions(t.source); + const res = pat_positions(t.result); + return role.t === "binding" + ? occ_key(src.vars.get(role.name)) !== occ_key(res.vars.get(role.name)) + : occ_key(src.nodes.get(role.pid)) !== occ_key(res.nodes.get(role.pid)); +}; + +export const candidates = (model: Model.t, grabbedId: ID.t): Candidate[] => { + const container = document.querySelector( + "#stage .node-container" + ); + const live = container ? Motion.measure_root(container) : new Map(); + const key = `node-${grabbedId}`; + const liveGrab = index_by_id(model.stage.exp).get(grabbedId); + const out: Candidate[] = []; + for (const raw of enumerate(model, grabbedId)) { + const c = { ...raw, exp: prefer_grab(raw.exp, raw.site, liveGrab) }; + const probe = probe_candidate(c.exp, model.settings); + if (!probe) continue; + const anchor = probe.measured.get(key)?.box; + /* No anchor = the grab is consumed by this rewrite; it has no track to + * ride. Such rules are reached by grabbing a node that survives (e.g. + * factoring via the shared factor or the sum, not the product). */ + if ( + !anchor || + !grab_is_mover(c.transform, subtree_at(c.site, model.stage.exp), grabbedId) + ) { + probe.dispose(); + continue; + } + const { emerge, converge } = provenance( + model.stage.exp, + live, + c.exp, + probe.measured, + c.site, + key + ); + out.push({ + ...c, + measured: probe.measured, + dispose: probe.dispose, + anchor, + emerge, + converge, + }); + } + return out; +}; + +// # Debug visualization: tracks (grab→anchor segments) + anchor dots + +let vis: HTMLElement | null = null; + +type VisState = { + lines: SVGLineElement[]; + dots: (HTMLElement | null)[]; + foot: HTMLElement; + tlabel: HTMLElement; + a0: { x: number; y: number }; + targets: { ax: number; ay: number }[]; +}; + +let vis_state: VisState | null = null; + +/* a pattern's operator multiset (comp heads), for the pluck content */ +const pat_ops = (p: Pat.t, out: string[] = []): string[] => { + if (p.t === "Comp") { + const h = p.kids[0]; + if (h?.t === "Atom" && h.sym.t === "Const") out.push(h.sym.name); + p.kids.forEach((k) => pat_ops(k, out)); + } + return out; +}; + +const SVGNS = "http://www.w3.org/2000/svg"; + +const ensure_vis = (): HTMLElement => { + if (vis && vis.isConnected) return vis; + vis = document.createElement("div"); + vis.id = "drag-vis"; + document.body.appendChild(vis); + return vis; +}; + +/* Rails live in a separate negative-z layer BEHIND the stage — the + * translucent node pills let them show through — while dots and readouts + * stay in the top layer. */ +let vis_rails: HTMLElement | null = null; +const ensure_rails = (): HTMLElement => { + if (vis_rails && vis_rails.isConnected) return vis_rails; + vis_rails = document.createElement("div"); + vis_rails.id = "drag-vis-rails"; + document.body.appendChild(vis_rails); + return vis_rails; +}; + +const clear_vis = (): void => { + vis?.replaceChildren(); + vis_rails?.replaceChildren(); + vis_state = null; + tool_glow(null, 0); +}; + +/* # Noolbox rule glow — while a drag rides a rail, the rule it would + * enact lights up with the rule-press flashbang (.transform-view:active's + * pink drop-shadow + brightness), scaled continuously by the rail + * parameter, with a soft smoothstepped bump crossing COMMIT_T; a + * committed drag flashes the full effect. Driven imperatively (like the + * overlay): per-frame model round-trips for a decoration would be waste, + * and filter values can't ride a CSS custom property. */ +let glow_el: HTMLElement | null = null; +let glow_g = 0; +const fades = new Map(); // fading row -> raf id +const glow_strength = (t: number): number => { + const s = Math.max(0, Math.min(1, (t - (COMMIT_T - 0.08)) / 0.16)); + return Math.min(1, Math.max(0, t) + 0.3 * s * s * (3 - 2 * s)); +}; +const glow_filter = (g: number): string => + `drop-shadow(0 0 ${(1.2 * g).toFixed(2)}em ` + + `rgb(255 153 153 / ${g.toFixed(3)})) ` + + `brightness(${(1 + 0.45 * g).toFixed(3)})`; +const cancel_fade = (el: HTMLElement): void => { + const raf = fades.get(el); + if (raf !== undefined) { + cancelAnimationFrame(raf); + fades.delete(el); + } +}; +const finish_fade = (el: HTMLElement): void => { + cancel_fade(el); + el.classList.remove("drag-glow"); + el.style.removeProperty("filter"); +}; +/* Exit: exponential decay whose half-life GROWS as it runs — a rapid + * falloff that genuinely levels off (a constant half-life reads as a + * uniform wipe: brightness perception is roughly logarithmic). Fades are + * per-row, so a rail change never hard-clears the previous rule. */ +const fade_glow = (el: HTMLElement, from: number): void => { + cancel_fade(el); + let g = from; + const start = performance.now(); + let last = start; + const step = (): void => { + if (!fades.has(el)) return; + const now = performance.now(); + /* growing half-life: rapid at first, easing into an afterglow tail + * that fully clears in about a second */ + const half_life = Math.min(40 + 0.5 * (now - start), 200); + g *= Math.pow(2, -(now - last) / half_life); + last = now; + if (g < 0.005) return finish_fade(el); + el.style.filter = glow_filter(g); + fades.set(el, requestAnimationFrame(step)); + }; + fades.set(el, requestAnimationFrame(step)); +}; +const tool_glow = (idx: number | null, t: number): void => { + const el = + idx === null ? null : document.getElementById(`transform-${idx}`); + if (glow_el && glow_el !== el) fade_glow(glow_el, glow_g); + glow_el = el; + glow_g = el ? glow_strength(t) : 0; + if (!el) return; + cancel_fade(el); // reacquired mid-fade: live glow takes over + el.classList.add("drag-glow"); + el.style.filter = glow_filter(glow_g); +}; +const flash_tool = (idx: number): void => { + const el = document.getElementById(`transform-${idx}`); + if (!el) return; + el.classList.remove("drag-glow-flash"); + void el.offsetWidth; // restart the animation if re-flashed + el.classList.add("drag-glow-flash"); + window.setTimeout(() => el.classList.remove("drag-glow-flash"), 330); +}; + +/* Track color is keyed to the RULE (toolbox index, golden-angle spread), + * not the candidate's position in this grab's list — the same transform + * is the same color across grabs and projections. Reversed applications + * share the rule's hue, lightened. */ +const track_color = (c: { idx: number; reversed: boolean }): string => + `hsl(${Math.round(c.idx * 137.508) % 360} 70% ${c.reversed ? 62 : 45}%)`; + +/* Rule code from the transform's name: first-word initial (inverse takes + * V, leaving I to identity) plus the first operator glyph. Rendered as + * the letter with one compact column beside it — reversal r superscript + * over the operator subscript. Unnamed transforms fall back to their + * toolbox index. */ +const OP_GLYPH: Record = { plus: "+", times: "×", neg: "−" }; +const code_el = (c: Candidate): HTMLElement => { + const name = c.transform.name; + const span = document.createElement("span"); + span.className = "rule-code"; + if (!name) { + span.textContent = `${c.idx}${c.reversed ? "ʳ" : ""}`; + return span; + } + const [head, ...rest] = name.split("_"); + span.append(head === "inverse" ? "V" : head[0].toUpperCase()); + const op = rest.map((w) => OP_GLYPH[w]).find(Boolean) ?? ""; + if (op || c.reversed) { + const stack = document.createElement("span"); + stack.className = "code-stack"; + const sup = document.createElement("span"); + sup.textContent = c.reversed ? "r" : ""; + const sub = document.createElement("span"); + sub.textContent = op; + stack.append(sup, sub); + span.append(stack); + } + return span; +}; + +const show_vis = ( + grabbedId: ID.t, + cands: Candidate[], + targets: { ax: number; ay: number }[], + a0: { x: number; y: number } +): void => { + const v = ensure_vis(); + v.replaceChildren(); + const cur = document.getElementById(`node-${grabbedId}`)?.getBoundingClientRect(); + if (cur) { + const box = document.createElement("div"); + box.className = "grab-box"; + box.style.left = `${cur.x}px`; + box.style.top = `${cur.y}px`; + box.style.width = `${cur.width}px`; + box.style.height = `${cur.height}px`; + v.appendChild(box); + } + /* tracks: one segment per reachable candidate, tick at the commit point */ + const svg = document.createElementNS(SVGNS, "svg"); + ensure_rails().replaceChildren(svg); + const lines: SVGLineElement[] = []; + const dots: (HTMLElement | null)[] = []; + cands.forEach((c, i) => { + const tg = targets[i]; + const color = track_color(c); + const reachable = Math.hypot(tg.ax - a0.x, tg.ay - a0.y) >= MIN_TRAVEL; + const line = document.createElementNS(SVGNS, "line"); + line.setAttribute("x1", `${a0.x}`); + line.setAttribute("y1", `${a0.y}`); + line.setAttribute("x2", `${tg.ax}`); + line.setAttribute("y2", `${tg.ay}`); + line.setAttribute("stroke", color); + line.classList.add("track"); + if (!reachable) line.classList.add("unreachable"); + svg.appendChild(line); + lines.push(line); + if (reachable) { + /* commit-threshold tick, perpendicular at t = COMMIT_T */ + const mx = a0.x + (tg.ax - a0.x) * COMMIT_T; + const my = a0.y + (tg.ay - a0.y) * COMMIT_T; + const len = Math.hypot(tg.ax - a0.x, tg.ay - a0.y); + const px = (-(tg.ay - a0.y) / len) * 5; + const py = ((tg.ax - a0.x) / len) * 5; + const tick = document.createElementNS(SVGNS, "line"); + tick.setAttribute("x1", `${mx - px}`); + tick.setAttribute("y1", `${my - py}`); + tick.setAttribute("x2", `${mx + px}`); + tick.setAttribute("y2", `${my + py}`); + tick.setAttribute("stroke", color); + tick.classList.add("track-tick"); + svg.appendChild(tick); + } + const dot = document.createElement("div"); + dot.className = "anchor-dot"; + dot.style.left = `${tg.ax}px`; + dot.style.top = `${tg.ay}px`; + /* reversed: inverted — all-white dot, the rule's solid color as ink + * (the white field is the reversal marker; ink matches the forward + * dot) */ + const solid = track_color({ idx: c.idx, reversed: false }); + dot.style.background = c.reversed ? "white" : solid; + dot.style.borderColor = c.reversed ? "white" : solid; + if (c.reversed) dot.style.color = solid; + dot.replaceChildren(code_el(c)); + v.appendChild(dot); + dots.push(dot); + }); + /* projection foot + live t readout, shown while a candidate is active */ + const foot = document.createElement("div"); + foot.className = "track-foot"; + foot.style.display = "none"; + v.appendChild(foot); + const tlabel = document.createElement("div"); + tlabel.className = "t-label"; + tlabel.style.display = "none"; + v.appendChild(tlabel); + vis_state = { lines, dots, foot, tlabel, a0, targets }; +}; + +const update_vis = (cands: Candidate[], active: number, t: number): void => { + const s = vis_state; + if (!s) return; + s.lines.forEach((ln, i) => ln.classList.toggle("active", i === active)); + s.dots.forEach((d, i) => d?.classList.toggle("active", i === active)); + const tg = active >= 0 ? s.targets[active] : null; + if (!tg) { + s.foot.style.display = "none"; + s.tlabel.style.display = "none"; + return; + } + const fx = s.a0.x + (tg.ax - s.a0.x) * t; + const fy = s.a0.y + (tg.ay - s.a0.y) * t; + const c = cands[active]; + s.foot.style.display = ""; + s.foot.style.left = `${fx}px`; + s.foot.style.top = `${fy}px`; + s.foot.style.borderColor = track_color(c); + s.foot.classList.toggle("committing", t > COMMIT_T); + s.tlabel.style.display = ""; + s.tlabel.style.left = `${fx}px`; + s.tlabel.style.top = `${fy}px`; + s.tlabel.replaceChildren( + code_el(c), + ` t=${t.toFixed(2)}${t > COMMIT_T ? " ✓" : ""}` + ); + const dark = `hsl(${Math.round(c.idx * 137.508) % 360} 70% 35%)`; + s.tlabel.style.background = c.reversed ? "white" : dark; + s.tlabel.style.color = c.reversed ? dark : "white"; +}; + +// # The drag itself + +/* Candidates whose anchor displacement is below this are unreachable by + * projection and are skipped (coincident-anchor conflicts land here too). */ +const MIN_TRAVEL = 12; +const ENGAGE_PX = 4; +const COMMIT_T = 0.5; +/* The knob counts as "at the hub" (free to change rails) within this many + * px of the grab point. */ +const HUB_PX = 10; +/* Max knob speed along the rails, px per frame (~120Hz). Fast enough to + * feel 1:1 under the pointer; slow enough that flowing out of the hub onto + * a new rail reads as motion, not teleportation. */ +const KNOB_SPEED = 20; + +/* Only one drag at a time: a new grab (or any stuck state) force-ends the + * previous one. */ +let end_current: (() => void) | null = null; + +/* e.g. undo mid-drag would yank the world out from under the probes */ +export const drag_in_progress = (): boolean => end_current !== null; + +export const grab = ( + model: Model.t, + inject: Action.Inject, + grabbedId: ID.t, + e: PointerEvent +): void => { + end_current?.(); + const exp_at_grab = model.stage.exp; + const cands = candidates(model, grabbedId); + const live = document.getElementById(`node-${grabbedId}`)?.getBoundingClientRect(); + /* grab offset within the node, mapped proportionally into each candidate's + * anchor box (the poor man's local-frame anchor) */ + const rel = live + ? { + x: (e.clientX - live.x) / Math.max(1, live.width), + y: (e.clientY - live.y) / Math.max(1, live.height), + } + : { x: 0.5, y: 0.5 }; + const a0 = { x: e.clientX, y: e.clientY }; + const targets = cands.map((c) => ({ + ax: c.anchor.x + rel.x * c.anchor.w, + ay: c.anchor.y + rel.y * c.anchor.h, + })); + /* Debug overlay (rails, anchor dots, t readout) — toggleable. update_vis + * degrades to a no-op when show_vis hasn't run. */ + if (model.settings.dragDebug) { + show_vis(grabbedId, cands, targets, a0); + console.table( + cands.map((c, i) => ({ + tool: c.idx, + dir: c.reversed ? "reverse" : "forward", + target: `${Math.round(targets[i].ax)},${Math.round(targets[i].ay)}`, + travel: Math.round( + Math.hypot(targets[i].ax - a0.x, targets[i].ay - a0.y) + ), + nodes: c.measured.size, + })) + ); + } + + /* # The knob-on-rails mechanic. + * + * The drag state is not "which candidate is nearest the pointer" (a + * classifier — discontinuous at its decision boundaries) but the position + * of a knob on a star-shaped rail network joined at the grab point. The + * pointer doesn't set the state, it PULLS on it: each frame the knob + * chases the pointer's projection along its current rail at bounded + * speed, and may change rails only at the hub. Continuity is by + * construction; commitment far from the hub is topology, not a tuned + * threshold. */ + const rails = targets.map((tg) => { + const vx = tg.ax - a0.x; + const vy = tg.ay - a0.y; + const len = Math.hypot(vx, vy); + return { vx, vy, len, ok: len >= MIN_TRAVEL }; + }); + const proj_t = (p: { x: number; y: number }, i: number): number => { + const r = rails[i]; + return clamp01( + ((p.x - a0.x) * r.vx + (p.y - a0.y) * r.vy) / (r.len * r.len) + ); + }; + const perp_d = (p: { x: number; y: number }, i: number, t: number): number => { + const r = rails[i]; + return Math.hypot(p.x - (a0.x + t * r.vx), p.y - (a0.y + t * r.vy)); + }; + /* Best rail to flow onto from the hub: pointer must pull beyond the hub + * radius on it; nearest by perpendicular distance wins. */ + const outward_rail = (p: { x: number; y: number }): number => { + let best = -1; + let bestD = Infinity; + rails.forEach((r, i) => { + if (!r.ok) return; + const t = proj_t(p, i); + if (t * r.len <= HUB_PX) return; + const d = perp_d(p, i, t); + if (d < bestD) { + bestD = d; + best = i; + } + }); + return best; + }; + + let engaged = false; + let active = -1; // current rail / active candidate + let activeT = 0; // blend parameter on it + let pointer = { x: e.clientX, y: e.clientY }; + let raf = 0; + + const set_active = (i: number, t: number): void => { + if (i !== active) { + active = i; + Motion.manual_start(cands[i].measured, { + emerge: cands[i].emerge, + converge: cands[i].converge, + }); + /* drag plucks; content = the rule's operator multisets, walked + * source → result across the quarter points (see Sound.tsx) */ + if (model.settings.sound) + Sound.drag_sound_start( + pat_ops(cands[i].transform.source), + pat_ops(cands[i].transform.result) + ); + } + activeT = t; + Motion.manual_set(t); + update_vis(cands, active, t); + tool_glow(active >= 0 ? cands[active].idx : null, t); + Sound.drag_sound_set(t); + }; + + /* Rails: the knob chases the pointer along its current rail at bounded + * speed; rails change only at the hub. Runs on its own clock. */ + const rails_step = (): void => { + if (engaged) { + if (active < 0) { + const j = outward_rail(pointer); + if (j >= 0) set_active(j, 0); + } else { + /* At the hub, rails may be changed freely — and this must be + * checked BEFORE chasing: a diagonal pull toward another rail + * usually still projects positively onto the current one, and the + * knob would exit the hub along the wrong rail. */ + const knobPx = activeT * rails[active].len; + if (knobPx <= HUB_PX) { + const j = outward_rail(pointer); + if (j >= 0 && j !== active) + set_active(j, Math.min(knobPx / rails[j].len, 1)); + } + /* chase the pointer's projection along the current rail */ + const tTar = proj_t(pointer, active); + const dPx = (tTar - activeT) * rails[active].len; + const move = Math.max(-KNOB_SPEED, Math.min(KNOB_SPEED, dPx)); + set_active(active, clamp01(activeT + move / rails[active].len)); + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__knob = { + rail: active, + t: +activeT.toFixed(3), + engaged, + px: { ...pointer }, + frames: (((window as any).__knob?.frames as number) ?? 0) + 1, + }; + raf = requestAnimationFrame(rails_step); + }; + + /* Closest: memoryless per-frame nearest-segment dispatch (the original, + * pre-stickiness behavior) — kept for comparison via the mechanic + * toggle. Jumps at decision boundaries are inherent to it. */ + const closest_move = (): void => { + let best = -1; + let bestD = Infinity; + let bestT = 0; + rails.forEach((r, i) => { + if (!r.ok) return; + const t = proj_t(pointer, i); + const d = perp_d(pointer, i, t); + if (d < bestD) { + bestD = d; + best = i; + bestT = t; + } + }); + if (best >= 0) set_active(best, bestT); + }; + + const mechanic = model.settings.dragMechanic; + if (mechanic === "Rails") raf = requestAnimationFrame(rails_step); + + const onMove = (ev: PointerEvent): void => { + pointer = { x: ev.clientX, y: ev.clientY }; + if (!engaged && Math.hypot(pointer.x - a0.x, pointer.y - a0.y) >= ENGAGE_PX) + engaged = true; + if (engaged && mechanic === "Closest") closest_move(); + }; + + const onUp = (): void => { + end_current = null; + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + cancelAnimationFrame(raf); + clear_vis(); + Sound.drag_sound_stop(); + if (engaged && active >= 0) { + if (activeT > COMMIT_T) { + const c = cands[active]; + flash_tool(c.idx); + /* animate() captures the manual blend as its origin. Commit the + * EXACT candidate exp: re-deriving the rewrite would mint different + * fresh ids for created nodes, and the handoff would see the + * almost-faded-in nodes as exits plus identical enters — a double + * pop. (Fallback re-derives if the exp changed mid-drag.) */ + inject({ + t: "transformNode", + idx: -1, + transform: c.transform, + f: (cur) => + cur === exp_at_grab ? c.exp : at_path(c.transform, c.site)(cur), + }); + } else { + Motion.manual_release(); + } + } + cands.forEach((c) => c.dispose()); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + end_current = onUp; +}; diff --git a/src/index.css b/src/index.css index 5a0453c..1217768 100644 --- a/src/index.css +++ b/src/index.css @@ -12,6 +12,106 @@ transform: none !important; } +/* Motion overlay: flat box-tween layer replacing view transitions. + Layers are absolutely positioned shallow clones driven per-frame by + src/motion/Motion.tsx; see design/captured-geometry.md. */ +#motion-overlay { + position: fixed; + left: 0; + top: 0; + width: 0; + height: 0; + overflow: visible; + pointer-events: none; + z-index: 40; +} +#motion-overlay .motion-layer { + position: absolute; + margin: 0; + box-sizing: border-box; + /* punch through the hidden stage container the overlay lives inside */ + visibility: visible; +} +/* Transitions would fight the per-frame style writes; animations (pulse + glows etc.) are kept — they're part of node appearance and don't touch + driven properties. */ +#motion-overlay .motion-layer, +#motion-overlay .motion-layer * { + transition: none !important; +} +/* While morphing, the real content stays painted but invisible (dimmed, not + visibility-hidden) so revealing it at teardown is compositor-only work. + The overlay itself must not be dimmed — it is a sibling of the content. */ +#stage .node-container.motion-dimmed > :not(#motion-overlay) { + opacity: 0.001 !important; + pointer-events: none !important; +} +/* While a morph carries the glow as a synthetic overlay layer, suppress + the in-scene selection decoration so it doesn't double — on the live + node AND on clones (which bake in the post-state's classes). The + head's white pulse is part of the decoration. */ +#stage .node-container.selection-morphing .node.selected { + outline: 0 !important; + box-shadow: none !important; +} +#stage .node-container.selection-morphing .node.selected > .head { + animation: none !important; +} +/* Context shells: boxless wrappers replicating a clone's real ancestor + classes so descendant selectors and inheritance apply to layers. */ +#motion-overlay .motion-shell { + display: contents !important; +} +/* Freeze pulse/hover animations while measuring boxes. */ +#stage .node-container.motion-measuring * { + animation: none !important; + transition: none !important; + scale: none !important; +} +/* Debug (Shift+S): kill shadows to compare morph rasterization cost. */ +body.noshadows #seed, +body.noshadows #seed * { + box-shadow: none !important; + text-shadow: none !important; +} +/* Toolbox rows repaint when their match status flips; promoting the sides + makes the container-opacity part of that flip compositor-only (halves the + repaint stall, zero visual change). */ +#noolbox .source, +#noolbox .result { + will-change: opacity; +} +/* Debug (Shift+D): 4-layer approximation of the 10-layer toolbox pat shadow. + Eliminates the remaining toolbox repaint stall (~33ms) at the cost of a + subtle softness difference (max pixel delta 26/255 measured). */ +body.cheapshadows.noshadows #seed * { + box-shadow: none !important; /* Shift+S still wins when both are on */ +} +.Light body.cheapshadows .transform-view .pat.node, +body.cheapshadows .Light .transform-view .pat.node { + box-shadow: + 0.1em 0.1em 0.25em #90c2b366, + 0.4em 0.4em 1.2em #90c2b34e, + -0.1em -0.1em 0.25em #e4ffe966, + -0.4em -0.4em 1.2em #d6fff04e !important; +} +/* EXPERIMENT (step 1 of shadow-strategy ladder): clone shadow strip + disabled to re-measure post-rigid-grouping. A/B on the 6-projection- + toggle workload showed identical mount stalls (~85ms, DOM-bound) and + frame times (8.2ms mean @120Hz) either way — the ~170ms shadow-raster + cost from the drag-dev era is gone on desktop. Re-feel on mobile and + worst-case drags before deleting this block for good. +#motion-overlay .motion-layer:not(.motion-selection), +#motion-overlay .motion-layer:not(.motion-selection) * { + box-shadow: none !important; +} +*/ +/* Keep the stage content permanently on its own compositor layer so dimming + it for a morph is an opacity flip on an existing texture, not a re-raster. */ +#stage .node-container > .node { + will-change: opacity; +} + @keyframes spin { 0% { transform: rotate(0deg); @@ -188,9 +288,9 @@ body { height: 100vh; overflow: hidden; margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", - sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; /*background-color: #9cffcc !important;*/ @@ -207,8 +307,8 @@ body { } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", - monospace; + font-family: + source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } #main { @@ -222,14 +322,15 @@ code { /* mix-blend-mode: color-dodge; */ outline: 0.1em solid white; } -#main.setSelect.selected, -#main.unsetSelections.selected { - box-shadow: inset 0px 0px 0.6em #ffffff; - /* mix-blend-mode: color-dodge; */ +/* Root-selection press feedback: #main carries .selected when the stage + selection is "unselected" (the root — the window itself — is what's + selected). Holding the pointer down on the background creeps the window + corners in (the border-radius transition above), relaxing on release. + Pressing a node removes .selected in the same tick, so this only bites + on background presses. */ +#main.selected:active { border-radius: 5em; - outline: 0.1em solid white; } - .logo { position: absolute; width: 5em; @@ -261,6 +362,8 @@ code { border-radius: 1em; /*0.5em 1.3em 0.9em 0.9em;*/ padding: 0 0.25em; justify-content: center; + box-shadow: none; + border: none; } #stage .node.atom:hover, .transform .result.match:hover, @@ -493,8 +596,11 @@ code { box-shadow: #0000; } -.node-container.LinearInfix .node.selected:has(> *), -.node-container.LinearInfix .node.selected:has(> *):hover { +/* Selected comps only (their kids get dotted outlines below): atoms also + have a child (the sym wrapper), so :has(> *) would suppress their + selection glow while giving them no dotted replacement. */ +.node-container.LinearInfix .node.selected:has(> .node), +.node-container.LinearInfix .node.selected:has(> .node):hover { border: 0; outline: 0.03em solid #ffffff; box-shadow: 0px 0px 0.6em #ffffff; @@ -581,8 +687,11 @@ code { }*/ #noolbox { - box-shadow: inset -8px 0px 8px #3ca38211, inset -4px 0px 4px #3ca38211, - inset -2px 0px 2px #3ca38211, inset -1px 0px 1px #3ca38211, + box-shadow: + inset -8px 0px 8px #3ca38211, + inset -4px 0px 4px #3ca38211, + inset -2px 0px 2px #3ca38211, + inset -1px 0px 1px #3ca38211, inset -0.5px 0px 0.5px #3ca38211; position: relative; display: grid; @@ -594,7 +703,24 @@ code { border-bottom: 0.04em solid #53f5ff66; /* border-top: 0.02em solid #f8a7ff66; */ width: fit-content; - padding: 0.3em 0.4em 0.3em 0; + /* left padding: becoming a scroll container forced horizontal clipping + (overflow-y non-visible drags overflow-x along), so rule shadows need + room to fall off before the clip edge */ + padding: 0.3em 0.4em 0.3em 0.7em; + /* native continuous scrolling; MANDATORY snap = smooth mid-gesture but + every rest position is row-aligned at the top (max-height comes + inline from tools.size — shift+wheel resizes) */ + overflow-y: auto; + overscroll-behavior: contain; + scroll-snap-type: y mandatory; + scroll-padding-top: 0.3em; + scrollbar-width: none; +} +#noolbox::-webkit-scrollbar { + display: none; +} +#noolbox .transform-view { + scroll-snap-align: start; } .TreeLeft #noolbox { height: 9.2em; @@ -605,6 +731,38 @@ code { flex-direction: row-reverse; }*/ +/* Drag-to-rule feedback: while a drag rides a rail, the rule it would + enact lights up with the rule-press flashbang (.transform-view:active + below), intensity driven per frame from Drag.tsx as an inline filter — + continuous in the rail t, soft bump at the commit threshold. A + committed drag flashes the full effect. */ +/* The flashbang blowout is the row itself compositing plus-lighter (the + rule-press :active look). Deliberate tradeoff, Andrew's call: the + blend mode can't fade — removing the class at the end snaps the row + back to normal compositing — but this is the real blowout. The filter + fades on the rapid-falloff-into-shallow-tail curve (linear timing; + the curve is encoded in the keyframes). */ +.transform-view.drag-glow-flash { + mix-blend-mode: plus-lighter; + animation: drag-glow-flash 0.28s linear both; +} +@keyframes drag-glow-flash { + 0% { + filter: drop-shadow(0 0 1.3em rgb(255 153 153 / 1)) brightness(1.5); + } + 20% { + filter: drop-shadow(0 0 0.55em rgb(255 153 153 / 0.45)) brightness(1.22); + } + 50% { + filter: drop-shadow(0 0 0.28em rgb(255 153 153 / 0.16)) brightness(1.08); + } + 80% { + filter: drop-shadow(0 0 0.15em rgb(255 153 153 / 0.05)) brightness(1.025); + } + 100% { + filter: drop-shadow(0 0 0.1em rgb(255 153 153 / 0)) brightness(1); + } +} .transform-view { position: relative; z-index: 10; @@ -704,8 +862,10 @@ v .transform-view .label { filter: saturate(1) opacity(0.5) hue-rotate(277deg) blur(0.2em) !important; } .previews .node-container:hover { - filter: saturate(1) brightness(0.1) invert(1) opacity(1) hue-rotate(210deg); - mix-blend-mode: exclusion; + /* !important: must outrank the rest-state blur above, which carries + !important — without this the previews were permanently blurred */ + filter: saturate(1) brightness(0.1) invert(1) opacity(1) hue-rotate(210deg) !important; + mix-blend-mode: exclusion !important; } .previews .node.comp { background-color: #005bff26; @@ -719,9 +879,6 @@ v .transform-view .label { .previews .id-view { display: none; } -#main.setSelect.selected #seed .previews { - mix-blend-mode: color-burn !important; -} #toolbar { grid-row: 2 / span 1; @@ -909,35 +1066,185 @@ v .transform-view .label { scale: 0.8; } +/* Corner L-clusters (matching SettingsView order): + top-left: 1 sound · 2 preview · 3 reset + top-right: 4 theme · 5 projection · 6 symbols + bottom-left: 7 drag mode · 8 drag mechanic · 9 drag debug */ #settings-panel .icon:nth-child(1) { left: 0; top: 0; } - #settings-panel .icon:nth-child(2) { - right: 0; - bottom: 0; + left: 0.75em; + top: 0; } - #settings-panel .icon:nth-child(3) { left: 0; - bottom: 0; + top: 0.75em; } - #settings-panel .icon:nth-child(4) { right: 0; top: 0; } - #settings-panel .icon:nth-child(5) { right: 0.75em; top: 0; } - #settings-panel .icon:nth-child(6) { - right: 0em; + right: 0; top: 0.75em; } +#settings-panel .icon:nth-child(7) { + left: 0; + bottom: 0; +} +#settings-panel .icon:nth-child(8) { + left: 0.75em; + bottom: 0; +} +#settings-panel .icon:nth-child(9) { + left: 0; + bottom: 0.75em; +} + +/* In drag mode, grabbing shouldn't shake the tree (the :active wobble). */ +#seed.dragging #stage .node:active { + animation: none !important; +} + +/* Drag-loadout toggles: small circles left of each rule, centered on the + row (in line with the arrow); drag mode only. */ +.transform-view { + position: relative; +} +/* Drag-mode loadout: the whole rule row is the toggle; the equals sign + carries the on/off state (white glow when the rule generates drag + candidates). */ +#seed.dragging .transform-view { + cursor: pointer; +} +#seed.dragging .transform-arrow.drag-on { + /* solid-white glyph (emoji can't be colored; black-then-invert can) + with a tight halo, the retired dot's look */ + filter: brightness(0) invert(1) drop-shadow(0 0 1.5px #ffffff) + drop-shadow(0 0 5px #ffffffaa); +} +/* Drag probe: hidden mount for measuring candidate states. */ +#stage .drag-probe { + position: absolute; + left: 0; + top: 0; + visibility: hidden; + pointer-events: none; +} + +/* Drag debug overlay: candidate anchor dots while grabbing (plan step 2). */ +#drag-vis { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 70; +} +#drag-vis .grab-box { + position: absolute; + border: 2px dashed #fffc; + border-radius: 10px; + box-shadow: 0 0 8px #0004; +} +#drag-vis .anchor-dot { + position: absolute; + width: 13px; + height: 13px; + border-radius: 50%; + transform: translate(-50%, -50%); + border: 2px solid; + box-shadow: 0 0 4px #0008; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-family: monospace; + color: white; +} +#drag-vis .anchor-dot.vanishing { + color: inherit; +} +/* Rule codes: letter + one compact column (reversal r superscript over + the operator subscript). The empty sup slot keeps operators sitting + subscript-low on unreversed codes. */ +#drag-vis .rule-code { + display: inline-flex; + align-items: center; +} +#drag-vis .code-stack { + display: inline-flex; + flex-direction: column; + font-size: 0.62em; + line-height: 1.05; + text-align: left; + margin-left: 0.1em; +} +#drag-vis .code-stack > span { + min-height: 1em; +} +#drag-vis .anchor-dot.active { + scale: 1.35; + box-shadow: 0 0 8px #000a; +} +/* Rails layer: fixed full-viewport like #drag-vis but BEHIND the stage + (negative z-index paints above the page background, under in-flow + content); the translucent node pills let the rails show through. */ +#drag-vis-rails { + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; +} +#drag-vis-rails svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} +#drag-vis-rails .track { + stroke-width: 1; + opacity: 0.55; +} +#drag-vis-rails .track.active { + stroke-width: 3; + opacity: 0.95; +} +#drag-vis-rails .track.unreachable { + opacity: 0.15; +} +#drag-vis-rails .track-tick { + stroke-width: 2; + opacity: 0.6; +} +#drag-vis .track-foot { + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + border: 3px solid; + background: white; + transform: translate(-50%, -50%); + box-shadow: 0 0 6px #0008; +} +#drag-vis .track-foot.committing { + background: #2eeb76; +} +#drag-vis .t-label { + position: absolute; + transform: translate(14px, -140%); + font-family: monospace; + font-size: 11px; + color: white; + padding: 2px 6px; + border-radius: 6px; + white-space: nowrap; +} .toolbar2 { } @@ -976,8 +1283,7 @@ v .transform-view .label { height: calc(1em * 0.5); position: relative; margin: 0.5em; - transition: transform 1s cubic-bezier(0.68, -0.6, 0.32, 1.6), - margin 0.1s linear; + transition: transform 1s cubic-bezier(0.68, -0.6, 0.32, 1.6); } /* Actual image container */ @@ -1002,10 +1308,32 @@ v .transform-view .label { } #seed .icon2:active { /* animation: spin 0.4s infinite linear; */ - margin: 1em; opacity: 1; transform: rotate(-359deg); } + +/* Pressing the seed displaces both flanks — by compositor transform, not + margin: the old margin bump re-laid-out and repainted the shadow-heavy + neighbors every frame of the transition. #stage itself must stay + transform-free (it would become the containing block for the + fixed-position, viewport-addressed #motion-overlay), so the stage's + side rides its root node, already on its own layer via will-change. */ +#noolbox { + transition: transform 0.1s linear; +} +#stage .node-container > .node { + /* restate .node's scale transition — this higher-specificity rule + replaces it wholesale */ + transition: + scale 0.1s linear, + transform 0.1s linear; +} +#seed:has(.icon2:active) #noolbox { + transform: translateX(-0.5em); +} +#seed:has(.icon2:active) #stage .node-container > .node { + transform: translateX(0.5em); +} #seed .icon2:active .inner { /* animation: spin 0.4s infinite linear; */ /* filter: invert(1) drop-shadow(0px 0px 8px rgb(0, 93, 52)); */ @@ -1085,9 +1413,13 @@ body:has(#main.Dark) { mix-blend-mode: screen; } .Dark .transform-view .node:hover { - box-shadow: inset 0px -1px 1px #fde8ff, inset 0px -1px 1px #9d80ff33, - inset 0px -2px 2px #a000ff, inset 0px -4px 4px #9d80ff33, - inset 0px -8px 8px #9d80ff33, inset 0px -16px 16px #9d80ff33; + box-shadow: + inset 0px -1px 1px #fde8ff, + inset 0px -1px 1px #9d80ff33, + inset 0px -2px 2px #a000ff, + inset 0px -4px 4px #9d80ff33, + inset 0px -8px 8px #9d80ff33, + inset 0px -16px 16px #9d80ff33; } /* HACKY INTERACTIONS */ @@ -1114,91 +1446,92 @@ body:has(#main.Dark) { color: black !important; } -::view-transition-old(flip-node-selected), -::view-transition-new(flip-node-selected) { - /*transition: none; - transition: transform 250ms cubic-bezier(0.68, -0.6, 0.32, 1.6) !important;*/ - /*animation-duration: 200ms !important; - transition: transform 250ms cubic-bezier(0.68, -0.6, 0.32, 1.6) !important; - height: auto; - right: 0; - left: auto; - transform-origin: right center;*/ -} -.selected { - /* needs some tlc. dont want internals to show during transition - */ - view-transition-name: flip-node-selected; -} -#main.setSelect .selected, -#main.unsetSelections .selected { - /* needs some tlc. dont want internals to show during transition - */ - view-transition-name: flip-node-selected !important; -} - -#seed .icon { - view-transition-name: flip-seed-icon; -} -#seed #noolbox { - view-transition-name: flip-noolbox; -} -#seed .previews { - /* view-transition-name: flip-previews; */ -} - -::view-transition-group(flip-node-selected) { - view-transition-name: selected; - animation-timing-function: easeInBack; /*easeInBack; easeOutBounce;*/ - animation-duration: calc(var(--anim-factor) * 0.2s); -} -::view-transition-old(flip-node-selected) { -} -::view-transition-new(flip-node-selected) { -} - -::view-transition-group(*) { - /*pointer-events: none;*/ - animation-duration: calc(var(--anim-factor) * 0.25s); - animation-fill-mode: both; - animation-timing-function: cubic-bezier(0.68, -0.6, 0.32, 1.6); +.Light .transform-view .pat.node { + box-shadow: + 0.05em 0.05em 0.1em #90c2b344, + 0.1em 0.1em 0.2em #90c2b344, + 0.2em 0.2em 0.4em #90c2b344, + 0.4em 0.4em 0.8em #90c2b344, + 0.8em 0.8em 1.6em #90c2b344, + -0.05em -0.05em 0.1em #e0ffe544, + -0.1em -0.1em 0.2em #ebfff744, + -0.2em -0.2em 0.4em #d2fff044, + -0.4em -0.4em 0.8em #cff2e644, + -0.8em -0.8em 1.6em #d2fff044; +} + +/* ============ Mobile / touch ============ */ + +/* Kill the tap-highlight rectangle (the "weird misshapen focus box" on + mobile) and long-press callout/selection on the play surface; pointer + interactions provide their own feedback. */ +#seed, +#seed *, +#settings-panel .icon, +#drag-vis, +#drag-vis * { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; } -::view-transition-new(*) { - height: 100%; - width: 100%; - /*animation-name: none !important;*/ - /*animation-name: none !important; - animation-fill-mode: forwards; - opacity: 0;*/ - /*animation-name: none;*/ +/* Touch drags must own the gesture: no scroll/pull-to-refresh stealing + pointermoves mid-drag. Inert on mouse devices. */ +#seed { + touch-action: none; } -::view-transition-old(*) { - height: 100%; - width: 100%; - /*animation-name: none !important;*/ - /*animation-name: none !important;*/ - /*pointer-events: none;*/ - /*animation-name: none;*/ +/* Keep the app inside the VISIBLE viewport despite mobile URL bars. */ +@supports (height: 100dvh) { + body, + #seed { + height: 100dvh; + } } -::view-transition { - /*pointer-events: none;*/ +/* Small screens (narrow OR short — landscape phones are short): scale the + whole scene with the viewport (em-based layout makes this a single knob). + Desktop is untouched. */ +@media (max-width: 820px), (max-height: 520px) { + #main { + font-size: clamp(1.05rem, min(6vw, 8.5vh), 3rem); + } } -.selected-transition * { - visibility: hidden; -} -.selected-transition { - background-color: red !important; - outline: 0.2em solid red; +/* Portrait: stack — stage on top, noolbox below (thumb-reachable). + Projection-specific #seed directions (TreeTop/Linear) still win. */ +@media (max-width: 820px) and (orientation: portrait) { + #seed { + flex-direction: column-reverse; + justify-content: center; + gap: 1em; + } } -.Light .transform-view .pat.node { - box-shadow: 0.05em 0.05em 0.1em #90c2b344, 0.1em 0.1em 0.2em #90c2b344, - 0.2em 0.2em 0.4em #90c2b344, 0.4em 0.4em 0.8em #90c2b344, - 0.8em 0.8em 1.6em #90c2b344, -0.05em -0.05em 0.1em #e0ffe544, - -0.1em -0.1em 0.2em #ebfff744, -0.2em -0.2em 0.4em #d2fff044, - -0.4em -0.4em 0.8em #cff2e644, -0.8em -0.8em 1.6em #d2fff044; +/* Coarse pointers: keep tap targets finger-sized even at small em, and + spread the corner clusters so the bigger targets don't overlap. */ +@media (pointer: coarse) { + #settings-panel .icon { + width: max(0.5em, 30px); + padding: max(0.15em, 8px); + } + #settings-panel .icon:nth-child(2) { + left: max(0.75em, 52px); + } + #settings-panel .icon:nth-child(3) { + top: max(0.75em, 52px); + } + #settings-panel .icon:nth-child(5) { + right: max(0.75em, 52px); + } + #settings-panel .icon:nth-child(6) { + top: max(0.75em, 52px); + } + #settings-panel .icon:nth-child(8) { + left: max(0.75em, 52px); + } + #settings-panel .icon:nth-child(9) { + bottom: max(0.75em, 52px); + } } diff --git a/src/motion/Motion.tsx b/src/motion/Motion.tsx new file mode 100644 index 0000000..38204d4 --- /dev/null +++ b/src/motion/Motion.tsx @@ -0,0 +1,868 @@ +/* Motion: retargetable box-snapshot animation layer. + * + * Replaces the View Transitions API. Around each model update we measure + * per-id boxes of the stage nodes before and after, then animate overlay + * clones from the before-boxes to the after-boxes while the real scene is + * hidden. The origin of an in-flight animation is whatever is currently + * displayed, so retargeting mid-flight never jumps. + * + * Two drivers share all of the machinery: + * - animate(apply): clock-driven, wraps a model update (button clicks). + * - manual_start/set/release: pointer-driven (drags). The target boxes + * come from a hidden probe (see src/drag/Drag.tsx) instead of the live + * DOM. Committing mid-drag just calls animate(): it captures the manual + * blend as its origin, so the handoff is seamless. + * + * Layer granularity: subtrees that move RIGIDLY (uniform scale + translate, + * no internal enter/exit) stay whole — one layer, full clone — so filters, + * blend effects, shadows and descendant selectors apply to the composite + * exactly as in the live DOM. Only genuinely deforming regions split into + * per-node shallow layers; those are wrapped in display:contents "context + * shells" carrying their real ancestor classes so descendant selectors and + * CSS inheritance keep working, and ancestor filters are copied onto them. + * See design/captured-geometry.md. + */ + +export type Box = { x: number; y: number; w: number; h: number; font: number }; + +export type Measured = { + el: HTMLElement; + box: Box; + depth: number; + parentId: string | null; +}; + +type BeforeInfo = { + box: Box; + opacity: number; + depth: number; + parentId: string | null; +}; + +type Member = { rel: Box; depth: number; parentId: string | null }; + +type Layer = { + el: HTMLElement; // the positioned clone (styles driven per frame) + mount: HTMLElement; // outermost context shell (what goes in the overlay) + from: Box; + to: Box; + fromOpacity: number; + toOpacity: number; + depth: number; + parentId: string | null; + members: Map | null; // group layers: contained ids + /* "translate": size/font constant — base styles set once, per-frame motion + * via the compositable `translate` property (rasterize once, no repaint). + * `translate` rather than `transform` so clone animations (pulse-scale + * etc., which animate transform) compose instead of clobbering it. + * "box": size changes — left/top/size/font interpolated per frame. */ + mode: "translate" | "box"; +}; + +type Tween = { + layers: Map; + start: number; + duration: number; + raf: number; + /* per-tween easing; easeInOutBack when unset */ + ease?: (x: number) => number; + /* present while a drag drives t directly; no clock runs */ + manual?: { t: number }; +}; + +let overlay: HTMLElement | null = null; +let tween: Tween | null = null; + +const clamp01 = (x: number): number => Math.max(0, Math.min(1, x)); + +const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; + +const lerpBox = (a: Box, b: Box, t: number): Box => ({ + x: lerp(a.x, b.x, t), + y: lerp(a.y, b.y, t), + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + font: lerp(a.font, b.font, t), +}); + +/* Member boxes are stored relative to their group's to-box so they can be + * reconstructed against any blended box on retarget. */ +const rel_of = (m: Box, root: Box): Box => ({ + x: (m.x - root.x) / root.w, + y: (m.y - root.y) / root.h, + w: m.w / root.w, + h: m.h / root.h, + font: root.font > 0 ? m.font / root.font : 1, +}); + +const from_rel = (root: Box, r: Box): Box => ({ + x: root.x + r.x * root.w, + y: root.y + r.y * root.h, + w: r.w * root.w, + h: r.h * root.h, + font: r.font * root.font, +}); + +/* Analytic stand-in for the old cubic-bezier(0.68, -0.6, 0.32, 1.6) */ +const easeInOutBack = (x: number): number => { + const c1 = 1.70158; + const c2 = c1 * 1.525; + return x < 0.5 + ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2 + : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; +}; + +/* Fast start, gentle landing — for retargeted glow hops, where most of + * the travel must happen in the few frames before the next key repeat. */ +const easeOutCubic = (x: number): number => 1 - Math.pow(1 - x, 3); + +const anim_factor = (): number => { + const v = parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--anim-factor") + ); + return isNaN(v) ? 1 : v; +}; + +const stage_container = (): HTMLElement | null => + document.querySelector("#stage .node-container"); + +/* Measure per-id boxes under a root; dx/dy shift the recorded coordinates + * (used to align hidden probes with the live scene). */ +export const measure_root = ( + root: HTMLElement, + dx = 0, + dy = 0 +): Map => { + const out = new Map(); + /* Freeze pulse/hover animations so boxes are measured at rest. */ + root.classList.add("motion-measuring"); + const els = root.querySelectorAll('[id^="node-"], [id^="sym-"]'); + for (const el of Array.from(els)) { + let depth = 0; + let parentId: string | null = null; + for (let p = el.parentElement; p && p !== root; p = p.parentElement) { + if (p.id.startsWith("node-")) { + depth++; + if (parentId === null) parentId = p.id; + } + } + const r = el.getBoundingClientRect(); + const font = parseFloat(getComputedStyle(el).fontSize); + out.set(el.id, { + el, + box: { x: r.x + dx, y: r.y + dy, w: r.width, h: r.height, font }, + depth, + parentId, + }); + } + root.classList.remove("motion-measuring"); + return out; +}; + +const measure = (): Map => { + const root = stage_container(); + return root ? measure_root(root) : new Map(); +}; + +const strip_ids = (c: HTMLElement): void => { + c.removeAttribute("id"); + c.querySelectorAll("[id]").forEach((d) => d.removeAttribute("id")); +}; + +/* Shallow: the node's own visuals only; layered descendants are stripped. */ +const shallow_clone = (el: HTMLElement): HTMLElement => { + const c = el.cloneNode(true) as HTMLElement; + c.querySelectorAll('[id^="node-"], [id^="sym-"]').forEach((d) => d.remove()); + strip_ids(c); + c.classList.add("motion-layer"); + c.dataset.motionId = el.id; + return c; +}; + +/* Full: the whole subtree, for rigidly-moving groups. */ +const full_clone = (el: HTMLElement): HTMLElement => { + const c = el.cloneNode(true) as HTMLElement; + strip_ids(c); + c.classList.add("motion-layer"); + c.dataset.motionId = el.id; + return c; +}; + +const shrink = (b: Box, factor: number): Box => ({ + x: b.x + (b.w * (1 - factor)) / 2, + y: b.y + (b.h * (1 - factor)) / 2, + w: b.w * factor, + h: b.h * factor, + font: b.font * factor, +}); + +const set_base_box = (l: Layer): void => { + const s = l.el.style; + s.left = `${l.from.x}px`; + s.top = `${l.from.y}px`; + s.width = `${l.from.w}px`; + s.height = `${l.from.h}px`; + s.fontSize = `${l.from.font}px`; + if (l.mode === "translate") { + s.willChange = "translate, opacity"; + } else { + s.translate = ""; // clear residue on reused (retargeted) elements + s.willChange = ""; + } +}; + +const apply_frame = (layers: Map, t: number): void => { + for (const l of layers.values()) { + const b = lerpBox(l.from, l.to, t); + const s = l.el.style; + if (l.mode === "translate") { + s.translate = `${b.x - l.from.x}px ${b.y - l.from.y}px`; + } else { + s.left = `${b.x}px`; + s.top = `${b.y}px`; + s.width = `${b.w}px`; + s.height = `${b.h}px`; + s.fontSize = `${b.font}px`; + } + s.opacity = `${clamp01(lerp(l.fromOpacity, l.toOpacity, t))}`; + } +}; + +const layer_mode = (from: Box, to: Box): "translate" | "box" => + Math.abs(from.w - to.w) < 0.5 && + Math.abs(from.h - to.h) < 0.5 && + Math.abs(from.font - to.font) < 0.5 + ? "translate" + : "box"; + +const eased_now = (tw: Tween): number => + tw.manual + ? tw.manual.t + : (tw.ease ?? easeInOutBack)( + clamp01((performance.now() - tw.start) / tw.duration) + ); + +const ensure_overlay = (): HTMLElement | null => { + /* The overlay lives INSIDE the (hidden) stage container so clones keep + * the full cascade context; layers punch back through with + * visibility:visible. */ + const container = stage_container(); + if (!container) return null; + if (!overlay || !overlay.isConnected) { + overlay = document.createElement("div"); + overlay.id = "motion-overlay"; + } + if (overlay.parentElement !== container) container.appendChild(overlay); + return overlay; +}; + +/* The real content is dimmed (opacity ~0) rather than visibility-hidden so + * its texture stays rasterized: revealing it at teardown is then a pure + * compositor operation instead of a full repaint (which measured ~120ms). */ +const set_stage_dimmed = (dimmed: boolean): void => { + const c = stage_container(); + if (c) c.classList.toggle("motion-dimmed", dimmed); +}; + +/* Cached selection glow styling, for morphing to "unselected". */ +let sel_style: { outline: string; boxShadow: string; radius: string } | null = + null; + +const finish = (): void => { + if (tween) { + cancelAnimationFrame(tween.raf); + tween = null; + } + if (overlay) overlay.replaceChildren(); + set_stage_dimmed(false); + stage_container()?.classList.remove("selection-morphing"); +}; + +/* What is currently displayed — the mid-flight blend if a tween is active, + * else the live DOM. Group members are reconstructed from their stored + * group-relative boxes. Consumes (cancels) any active tween. */ +const capture_before = (): { + before: Map; + exit_sources: Map; + prev: Tween | null; + /* true when `before` came from a consumed full morph's overlay layers — + * their els may be adopted as exit clones. False when `before` is a live + * measurement (no tween, or only the synthetic glow was in flight). */ + reuse_exits: boolean; + /* mid-flight box of an interrupted selection-glow layer, for retargeting */ + glow_box: Box | null; +} => { + const before = new Map(); + const exit_sources = new Map(); + const prev = tween; + let glow_box: Box | null = null; + if (prev) { + const t = eased_now(prev); + for (const [id, l] of prev.layers) { + if (id === "@selection") { + /* synthetic glow layer, not a node: capture its blend so the next + * selection morph can pick up where it left off, but keep it out + * of the node before-state */ + glow_box = lerpBox(l.from, l.to, t); + continue; + } + const op = clamp01(lerp(l.fromOpacity, l.toOpacity, t)); + if (op < 0.01 && l.toOpacity === 0) continue; // fully-faded exit: drop + const blend = lerpBox(l.from, l.to, t); + before.set(id, { box: blend, opacity: op, depth: l.depth, parentId: l.parentId }); + exit_sources.set(id, l.el); + if (l.members) + for (const [mid, m] of l.members) + before.set(mid, { + box: from_rel(blend, m.rel), + opacity: op, + depth: m.depth, + parentId: m.parentId, + }); + } + cancelAnimationFrame(prev.raf); + tween = null; + } + const reuse_exits = before.size > 0; + if (!reuse_exits) { + /* No real node layers in flight — the live DOM is fully painted and + * authoritative. (Capturing a glow-only tween's layers here instead + * made every node read as an enter: the stage dimmed and faded back + * in on each rapid selection change.) */ + for (const [id, m] of measure()) { + before.set(id, { box: m.box, opacity: 1, depth: m.depth, parentId: m.parentId }); + exit_sources.set(id, m.el); + } + } + return { before, exit_sources, prev, reuse_exits, glow_box }; +}; + +/* Provenance-driven enter/exit geometry (the demo's emergeFrom/emergeMode, + * adapted). Two enter modes: "clone" (a duplicate of an existing node — + * appears full-size AT its original and separates) and "grow" (genuinely + * new — scales up from almost nothing at its source). Two exit modes: + * "merge" (an identical twin survives — travels to coincide with it, no + * fade) and "absorb" (genuinely deleted — shrinks into its target while + * fading). */ +export type EmergeSpec = { source: string; mode: "clone" | "grow" }; +export type ConvergeSpec = { target: string; mode: "merge" | "absorb" }; +export type EmergeOpts = { + emerge?: Map; + converge?: Map; +}; + +/* Build the flat layer set morphing `before` (blend/live boxes) into + * `after` (measured target: live DOM or a hidden probe). */ +const build_layers = ( + before: Map, + after: Map, + exit_sources: Map, + reuse_exit_els: boolean, + opts?: EmergeOpts +): Map => { + /* Adjacency for the after and before structures. */ + const after_kids = new Map(); + for (const [id, m] of after) { + const arr = after_kids.get(m.parentId) ?? []; + arr.push(id); + after_kids.set(m.parentId, arr); + } + const before_kids = new Map(); + for (const [id, b] of before) { + const arr = before_kids.get(b.parentId) ?? []; + arr.push(id); + before_kids.set(b.parentId, arr); + } + const descendants = (id: string, kids: Map): string[] => { + const out = [id]; + for (const k of kids.get(id) ?? []) out.push(...descendants(k, kids)); + return out; + }; + + /* A subtree is rigid when every before-descendant survives in place and + * every member's box maps through the root's (uniform) scale+translate. */ + const subtree_rigid = (rootId: string): string[] | null => { + const rb = before.get(rootId); + const ra = after.get(rootId); + if (!rb || !ra || rb.box.w <= 0 || rb.box.h <= 0) return null; + const sw = ra.box.w / rb.box.w; + const sh = ra.box.h / rb.box.h; + if (Math.abs(sw - sh) > 0.02) return null; + const members = descendants(rootId, after_kids); + if (descendants(rootId, before_kids).length !== members.length) return null; + for (const id of members) { + const b = before.get(id); + const a = after.get(id); + if (!b || !a || b.opacity < 0.999) return null; + if ( + Math.abs(ra.box.x + (b.box.x - rb.box.x) * sw - a.box.x) > 1 || + Math.abs(ra.box.y + (b.box.y - rb.box.y) * sh - a.box.y) > 1 || + Math.abs(b.box.w * sw - a.box.w) > 1 || + Math.abs(b.box.h * sh - a.box.h) > 1 || + Math.abs(b.box.font * sw - a.box.font) > 1 + ) + return null; + } + return members; + }; + + /* Context shells: display:contents wrappers carrying the real ancestor + * classes, so descendant selectors and inheritance work on clones. */ + const parent_chain = (parentId: string | null): string[] => { + const chain: string[] = []; + for (let p = parentId; p; ) { + chain.push(p); + p = after.get(p)?.parentId ?? before.get(p)?.parentId ?? null; + } + return chain; // innermost first + }; + const mount_with_shells = ( + layerEl: HTMLElement, + parentId: string | null + ): HTMLElement => { + let mount = layerEl; + for (const p of parent_chain(parentId)) { + const src = after.get(p)?.el; + const shell = document.createElement("div"); + shell.className = (src ? src.className + " " : "") + "motion-shell"; + shell.appendChild(mount); + mount = shell; + } + return mount; + }; + + /* Ancestor filters don't reach clones through display:contents shells; + * copy them onto the layer (pixel-wise color filters compose well). */ + const with_ancestor_filters = ( + layerEl: HTMLElement, + sourceEl: HTMLElement | null, + parentId: string | null + ): void => { + const ancestor_fs: string[] = []; + for (const p of parent_chain(parentId)) { + const el = after.get(p)?.el; + if (!el) continue; + const f = getComputedStyle(el).filter; + if (f && f !== "none") ancestor_fs.push(f); + } + if (ancestor_fs.length === 0) return; // own class filter already applies + const own = + sourceEl && sourceEl.isConnected ? getComputedStyle(sourceEl).filter : ""; + layerEl.style.filter = [own !== "none" ? own : "", ...ancestor_fs] + .filter(Boolean) + .join(" "); + }; + + const layers = new Map(); + + const walk = (id: string): void => { + const a = after.get(id)!; + const b = before.get(id); + const group = b ? subtree_rigid(id) : null; + if (group && group.length > 1) { + const el = full_clone(a.el); + with_ancestor_filters(el, a.el, a.parentId); + const members = new Map(); + for (const mid of group) { + if (mid === id) continue; + const m = after.get(mid)!; + members.set(mid, { + rel: rel_of(m.box, a.box), + depth: m.depth, + parentId: m.parentId, + }); + } + layers.set(id, { + el, + mount: mount_with_shells(el, a.parentId), + from: b!.box, + to: a.box, + fromOpacity: b!.opacity, + toOpacity: 1, + depth: a.depth, + parentId: a.parentId, + members, + mode: layer_mode(b!.box, a.box), + }); + return; + } + const el = shallow_clone(a.el); + with_ancestor_filters(el, a.el, a.parentId); + /* enters with an emerge source start at (clone) or grow out of (grow) + * the source's box, fully visible — creation reads as pulling out or + * scaling up, not fading in */ + const spec = b ? undefined : opts?.emerge?.get(id); + const srcBox = spec ? before.get(spec.source)?.box : undefined; + const from = b + ? b.box + : srcBox + ? spec!.mode === "grow" + ? shrink(srcBox, 0.15) + : srcBox + : shrink(a.box, 0.5); + layers.set(id, { + el, + mount: mount_with_shells(el, a.parentId), + from, + to: a.box, + fromOpacity: b ? b.opacity : srcBox ? 1 : 0, + toOpacity: 1, + /* emerging nodes sit slightly behind established ones, so creation + * reads as coming out from behind */ + depth: !b && srcBox ? a.depth - 0.4 : a.depth, + parentId: a.parentId, + members: null, + mode: layer_mode(from, a.box), + }); + for (const k of after_kids.get(id) ?? []) walk(k); + }; + for (const r of after_kids.get(null) ?? []) walk(r); + + /* Exits: per-node layers, exactly dual to enters. With a converge target, + * merges travel to coincide with the surviving twin (full opacity) and + * absorptions shrink into the target's box (full opacity — the dual of + * grow); nothing fades. Without provenance (click-path), fall back to + * fade-in-place. */ + for (const [id, b] of before) { + if (after.has(id)) continue; + const src = exit_sources.get(id); + if (!src) continue; + const el = reuse_exit_els ? src : shallow_clone(src); + with_ancestor_filters(el, null, b.parentId); + const cv = opts?.converge?.get(id); + const tgtBox = cv ? after.get(cv.target)?.box : undefined; + layers.set(id, { + el, + mount: mount_with_shells(el, b.parentId), + from: b.box, + to: tgtBox + ? cv!.mode === "absorb" + ? shrink(tgtBox, 0.15) + : tgtBox + : shrink(b.box, 0.8), + fromOpacity: b.opacity, + toOpacity: tgtBox ? 1 : 0, + /* Nudge below same-depth survivors: when a wrapper is eliminated its + * child takes its old depth, and the receding ghost (often a large + * tinted box) must paint UNDER the arriving content, not over it. */ + depth: b.depth - 0.25, + parentId: b.parentId, + members: null, + mode: "box", + }); + } + + return layers; +}; + +const mount_layers = (layers: Map, dim: boolean): boolean => { + const ov = ensure_overlay(); + if (!ov) return false; + const ordered = [...layers.values()].sort((a, b) => a.depth - b.depth); + ov.replaceChildren(...ordered.map((l) => l.mount)); + if (dim) set_stage_dimmed(true); + for (const l of layers.values()) set_base_box(l); + return true; +}; + +const start_clock = (duration: number, immediate = false): void => { + if (!tween) return; + tween.duration = duration; + const step = (): void => { + if (!tween) return; + const x = (performance.now() - tween.start) / tween.duration; + if (x >= 1) { + apply_frame(tween.layers, 1); + finish(); + return; + } + apply_frame(tween.layers, (tween.ease ?? easeInOutBack)(clamp01(x))); + tween.raf = requestAnimationFrame(step); + }; + /* Immediate: no warm-up, for cheap single-layer tweens that may only + * get a frame or two before being retargeted again (key repeat). */ + if (immediate) { + tween.raf = requestAnimationFrame(step); + return; + } + /* The first painted frame pays the layer rasterization cost (tens of ms). + * Start the clock only after it has presented — otherwise the first + * visible frame lands mid-curve and the animation's opening is skipped. */ + tween.raf = requestAnimationFrame(() => { + if (!tween) return; + tween.raf = requestAnimationFrame(() => { + if (!tween) return; + tween.start = performance.now(); + step(); + }); + }); +}; + +const run_tween = ( + layers: Map, + duration: number, + dim: boolean, + opts?: { ease?: (x: number) => number; immediate?: boolean } +): void => { + if (!mount_layers(layers, dim)) return; + tween = { layers, start: performance.now(), duration, raf: 0, ease: opts?.ease }; + apply_frame(layers, 0); + start_clock(duration, opts?.immediate); +}; + +/* Run a model update, animating stage nodes from where they are displayed + * now to where the update puts them. `apply` must update the DOM + * synchronously (solid store writes do). `mk_opts`, when given, supplies + * emerge/converge provenance for the morph from the before/after box + * maps — the click-path twin of the drag path's candidate provenance. */ +export const animate = ( + apply: () => void, + enabled: boolean, + mk_opts?: ( + before: ReadonlyMap, + after: ReadonlyMap + ) => EmergeOpts +): void => { + if (!enabled || typeof document === "undefined") { + apply(); + finish(); + return; + } + + const pre_selected = + stage_container()?.querySelector(".node.selected")?.id ?? null; + + const { before, exit_sources, prev, reuse_exits, glow_box } = + capture_before(); + + apply(); + + const after = measure(); + + /* Keep the selection glow styling cached while a selected node exists — + * but not mid-morph: the selection-morphing class suppresses the live + * glow, so capturing then would cache an invisible style and rapid + * selection changes would travel an invisible outline. */ + const post_selected_el = + stage_container()?.querySelector(".node.selected") ?? null; + const post_selected = post_selected_el?.id ?? null; + if ( + post_selected_el && + !stage_container()?.classList.contains("selection-morphing") + ) { + const cs = getComputedStyle(post_selected_el); + sel_style = { + outline: cs.outline, + boxShadow: cs.boxShadow, + radius: cs.borderRadius, + }; + } + + /* No-change guard: skip the overlay when geometry is undisturbed. A pure + * selection change still gets a morph: a synthetic glow layer travels + * from the old selected node's box to the new one's (the live glow is + * suppressed meanwhile), recreating the old view-transition effect. + * `before` is a live measurement whenever reuse_exits is false, so the + * comparison is meaningful even when a glow-only tween was consumed. */ + if (!reuse_exits) { + let changed = after.size !== before.size; + if (!changed) { + /* 1.5px: selection border/outline changes jiggle boxes by ~1px; a + * full morph for that is invisible-but-costly, and it would shadow + * the selection-glow morph below. */ + for (const [id, m] of after) { + const b = before.get(id); + if ( + !b || + Math.abs(b.box.x - m.box.x) > 1.5 || + Math.abs(b.box.y - m.box.y) > 1.5 || + Math.abs(b.box.w - m.box.w) > 1.5 || + Math.abs(b.box.h - m.box.h) > 1.5 + ) { + changed = true; + break; + } + } + } + if (!changed) { + /* Run the glow morph when the selection changed, or when an + * interrupted glow still needs to finish traveling (e.g. held + * arrow keys at the edge of the tree re-trigger no-op updates). */ + if ((pre_selected !== post_selected || glow_box) && sel_style) { + /* Retarget from the interrupted glow's mid-flight box when there + * is one, so rapid selection changes read as one continuous + * outline chasing the selection. */ + const root_box = main_box(); + const from_box = + glow_box ?? + (pre_selected ? before.get(pre_selected)?.box : undefined) ?? + root_box; + const to_box = + (post_selected ? after.get(post_selected)?.box : undefined) ?? + root_box; + if (from_box || to_box) { + stage_container()?.classList.add("selection-morphing"); + /* Retargets (key repeat) get a short, fast-starting hop with no + * warm-up — un-scaled by anim factor, since keeping up with the + * input is responsiveness, not spectacle. The outline then rides + * about a hop behind and settles when the keys stop. */ + const retarget = !!glow_box; + run_tween( + new Map([["@selection", mk_selection_layer(from_box, to_box)]]), + retarget ? 120 : 200 * anim_factor(), + false, + retarget ? { ease: easeOutCubic, immediate: true } : undefined + ); + return; + } + } + /* Consumed a tween but ran nothing in its place: tear down so the + * live DOM (and its selection glow) isn't left suppressed. */ + if (prev) finish(); + return; + } + } + + const layers = build_layers( + before, + after, + exit_sources, + reuse_exits, + mk_opts?.(before, after) + ); + /* When a full morph MOVES the selection between nodes, the glow rides + * it as its own layer: clones bake in the post-state's .selected + * decoration, which would otherwise flash fully-formed on a node still + * mid-flight (factoring: a times turns white instantly, then travels). + * Suppress the baked decoration (selection-morphing — heads' pulse + * included, see CSS) and fly the glow frame from the old selected box + * to the new one; it lands exactly as the live decoration returns at + * teardown. Unselecting morphs (undo) send it out to the window, per + * the root metaphor. A STATIONARY selection (commute at the selected + * node: same node, only its children rearrange) keeps its baked + * decoration riding the clone — that's the truthful rendering, and + * suppressing it read as the highlight flickering off. */ + if (sel_style && (pre_selected !== post_selected || glow_box)) { + const root_box = main_box(); + const from_box = + glow_box ?? + (pre_selected ? before.get(pre_selected)?.box : undefined) ?? + root_box; + const to_box = + (post_selected ? after.get(post_selected)?.box : undefined) ?? root_box; + if (from_box || to_box) { + layers.set("@selection", mk_selection_layer(from_box, to_box)); + stage_container()?.classList.add("selection-morphing"); + } + } + run_tween(layers, 250 * anim_factor(), true); +}; + +/* With no node selected the root (#main — the whole window) IS the + * selection: glows expand out to the window's box on unselect and shrink + * back down from it on select. (The outline ends up just past the + * viewport edge, where #main's own permanent white outline conceptually + * lives.) */ +const main_box = (): Box | undefined => { + const el = document.getElementById("main"); + if (!el) return undefined; + const r = el.getBoundingClientRect(); + return { + x: r.x, + y: r.y, + w: r.width, + h: r.height, + font: parseFloat(getComputedStyle(el).fontSize), + }; +}; + +const mk_selection_layer = ( + from_box: Box | undefined, + to_box: Box | undefined +): Layer => { + const el = document.createElement("div"); + el.className = "motion-layer motion-selection"; + if (sel_style) { + el.style.outline = sel_style.outline; + el.style.boxShadow = sel_style.boxShadow; + el.style.borderRadius = sel_style.radius; + } + el.dataset.motionId = "@selection"; + const from = from_box ?? to_box!; + const to = to_box ?? from_box!; + return { + el, + mount: el, + from, + to, + fromOpacity: from_box ? 1 : 0, + toOpacity: to_box ? 1 : 0, + depth: 999, + parentId: null, + members: null, + mode: layer_mode(from, to), + }; +}; + +// # Manual (pointer-driven) mode — drags + +/* Begin a pointer-driven morph from the current display toward `after` + * (typically a hidden-probe measurement of a candidate state). Retargeting + * to a different candidate mid-drag: just call again — the current blend + * becomes the new origin. */ +export const manual_start = ( + after: Map, + opts?: EmergeOpts +): boolean => { + const { before, exit_sources, reuse_exits } = capture_before(); + if (before.size === 0) { + /* a silent false here leaves the previous overlay frozen — if the + * "stuck mid-blend drag" ever recurs, look for this warning */ + console.warn("manual_start: empty before-capture; drag will not animate"); + return false; + } + const layers = build_layers(before, after, exit_sources, reuse_exits, opts); + if (!mount_layers(layers, true)) { + console.warn("manual_start: overlay mount failed; drag will not animate"); + return false; + } + tween = { + layers, + start: performance.now(), + duration: 1, + raf: 0, + manual: { t: 0 }, + }; + apply_frame(layers, 0); + return true; +}; + +export const manual_set = (t: number): void => { + if (!tween?.manual) return; + tween.manual.t = clamp01(t); + apply_frame(tween.layers, tween.manual.t); +}; + +export const manual_t = (): number => tween?.manual?.t ?? 0; + +/* Release without committing: clock-tween from the current blend back to + * where things were. (To commit, just inject the action — animate() picks + * the blend up as its origin.) */ +export const manual_release = (): void => { + if (!tween?.manual) return; + const t = tween.manual.t; + for (const l of tween.layers.values()) { + const blend = lerpBox(l.from, l.to, t); + const blendOp = clamp01(lerp(l.fromOpacity, l.toOpacity, t)); + l.to = l.from; + l.toOpacity = l.fromOpacity; + l.from = blend; + l.fromOpacity = blendOp; + l.mode = layer_mode(l.from, l.to); + set_base_box(l); + } + tween.manual = undefined; + tween.start = performance.now(); + apply_frame(tween.layers, 0); + start_clock(200 * anim_factor()); +}; diff --git a/src/syntax/ID.tsx b/src/syntax/ID.tsx index 7253050..3dec77d 100644 --- a/src/syntax/ID.tsx +++ b/src/syntax/ID.tsx @@ -3,3 +3,10 @@ export type ID = t; var id_gen = 0; export const mk = () => id_gen++; + +/* persistence: restored exps carry minted ids — the counter must resume + * past them or fresh mints would collide */ +export const current = (): t => id_gen; +export const bump = (n: t): void => { + if (n > id_gen) id_gen = n; +}; diff --git a/src/view/ExpView.tsx b/src/view/ExpView.tsx index 0f01ffd..d3cfcf2 100644 --- a/src/view/ExpView.tsx +++ b/src/view/ExpView.tsx @@ -1,6 +1,5 @@ import { Component } from "solid-js"; -import { For, Show, Index } from "solid-js"; -//import Rand from "rand-seed"; +import { For, Show } from "solid-js"; import * as Pat from "../syntax/Pat"; import { Exp } from "../syntax/Exp"; import * as Action from "../Action"; @@ -10,21 +9,32 @@ import * as Stage from "../Stage"; import * as Names from "../Names"; import * as Settings from "../Settings"; +/* These are real components (invoked as JSX, not called as functions) so + * solid keeps DOM nodes stable across model updates: class/attribute + * bindings update in place, and keyed on kid object identity reuses + * subtrees the update didn't touch. The motion layer depends on this. */ + type expviewprops = { node: Exp; info: Statics.InfoMap; selection: Stage.selection; - animate: boolean; is_head: boolean; inject: Action.Inject; mask: Pat.Binding[]; symbols: Settings.symbols; + /* drag mode: pointerdown grabs the node instead of selecting it */ + grab?: (id: number, e: PointerEvent) => void; }; -const setSelect = (props: expviewprops) => (e: Event) => { +const setSelect = (props: expviewprops) => (e: PointerEvent) => { + /* preventDefault also suppresses the compatibility mousedown, which would + * otherwise bubble to #seed and fire unsetSelections */ e.preventDefault(); - //above modulates whether shake occurs for some reason? e.stopPropagation(); + if (props.grab) { + props.grab(props.node.id, e); + return; + } props.inject({ t: "setSelect", path: Statics.get(props.info, props.node.id).path, @@ -43,67 +53,54 @@ const common_clss = ({ node, mask, info, selection }: expviewprops): string => { }; const ExpViewGo: Component = (props) => { - const eff = (props: expviewprops): boolean => { - /* search mask for a binding whose first id is this node's id. - then check if it's a val binding. if so return true. else false. */ - const binding = props.mask.find( - ({ ids: [_, id_stage], t }) => id_stage == props.node.id && t == "Val" - ); - // if binding is undefind rerturn false. else return true. - return binding?.t == "Val" ? false : true; - }; - switch (props.node.t) { - case "Atom": - return ( + const sym = () => (props.node.t === "Atom" ? props.node.sym : ""); + const kids = () => (props.node.t === "Comp" ? props.node.kids : []); + return ( + -
{Names.get(props.symbols, props.node.sym)}
+
+ {Names.get(props.symbols, sym())} +
} > -
- {Names.get(props.symbols, props.node.sym)} +
+ {Names.get(props.symbols, sym())}
- ); - case "Comp": - return ( -
- { - - {(kid, i) => ( - - )} - - } -
- ); - } + } + > +
+ + {(kid, i) => ( + + )} + +
+ + ); }; export const ExpView: Component<{ @@ -111,29 +108,31 @@ export const ExpView: Component<{ inject: Action.Inject; mask: Pat.Binding[]; symbols: Settings.symbols; -}> = (props) => - ExpViewGo({ - info: props.stage.info, - selection: props.stage.selection, - node: props.stage.exp, - inject: props.inject, - mask: props.mask, - is_head: false, - animate: true, - symbols: props.symbols, - }); + grab?: (id: number, e: PointerEvent) => void; +}> = (props) => ( + +); export const ViewOnly: Component<{ node: Exp; symbols: Settings.symbols; -}> = (props) => - ExpViewGo({ - info: Statics.mk(props.node, []), - selection: "unselected", - node: props.node, - animate: false, - is_head: false, - inject: (_) => {}, - mask: [], - symbols: props.symbols, - }); +}> = (props) => ( + {}} + mask={[]} + symbols={props.symbols} + /> +); diff --git a/src/view/PreView.tsx b/src/view/PreView.tsx index d3d5238..84a667c 100644 --- a/src/view/PreView.tsx +++ b/src/view/PreView.tsx @@ -1,5 +1,5 @@ import { Component } from "solid-js"; -import { For, Show, Switch, Match } from "solid-js"; +import { For, Show } from "solid-js"; import { Transform, at_path, flip } from "../Transform"; import { subtree_at } from "../syntax/Node"; import { ViewOnly } from "./ExpView"; @@ -26,7 +26,7 @@ const transformer = const directed = (transforms: Transform[]): Transform[] => transforms.flatMap((t) => [t, flip(t)]); -const do_transforms = ( +export const do_transforms = ( exp: Exp.t, transforms: Transform[] ): [Transform, Exp.t][] => @@ -41,40 +41,40 @@ const do_transforms = ( // unduplicate ids to avoid messing with transition animations .map(([t, e]): [Transform, Exp.t] => [t, map_ids(() => Id.mk(), e)]); -const preview = ( - node: Exp.t, - settings: Settings.t, - transformer: (_e: Event) => void -) => ( -
- {ViewOnly({ node: node, symbols: settings.symbols })} -
-); - export const AdjacentPossible: Component<{ stage: Stage.t; tools: Transform[]; inject: Action.Inject; settings: Settings.t; }> = (props) => { - if (props.stage.selection === "unselected") return
; - const selection = subtree_at(props.stage.selection, props.stage.exp); - if (selection === undefined) return
; + const selected = () => + props.stage.selection === "unselected" + ? undefined + : subtree_at(props.stage.selection, props.stage.exp); return ( -
- - {([transform, node]) => { - if (props.stage.selection === "unselected") return
; - return preview( - node, - props.settings, - transformer(props.inject, transform, props.stage.selection) - ); - }} -
-
+
}> + {(sel) => ( +
+ + {([transform, node]) => ( +
{} + : transformer( + props.inject, + transform, + props.stage.selection + ) + } + > + +
+ )} +
+
+ )} +
); }; diff --git a/src/view/SeedView.tsx b/src/view/SeedView.tsx index b08c6a2..cbb5437 100644 --- a/src/view/SeedView.tsx +++ b/src/view/SeedView.tsx @@ -1,53 +1,52 @@ -import { Component } from "solid-js"; +import { Component, Show } from "solid-js"; import { StageView } from "../view/StageView"; import { ToolsView } from "../view/ToolsView"; import { AdjacentPossible } from "../view/PreView"; import * as Model from "../Model"; import * as Action from "../Action"; -import infinity from "../assets/nool-seed-infinity-only.svg" - -const blah = () => ( -
- {" "} -
    -
  • A
  • -
  • B
  • -
  • C
  • -
  • D
  • -
  • E
  • -
-
-); export const Seed: Component<{ model: Model.t; inject: Action.Inject }> = ( props ) => (
{ e.preventDefault(); - props.inject({ t: "unsetSelections" }); + /* drag mode has no selection mechanic; background clicks are inert */ + if (!props.model.settings.dragging) + props.inject({ t: "unsetSelections" }); }} > - {ToolsView({ model: props.model, inject: props.inject })} - {/*
♾️
*/} -
-
+ +
{ + e.preventDefault(); + e.stopPropagation(); + props.inject({ t: "strikeSeed" }); + }} + > +
- {StageView({ model: props.model, inject: props.inject })} - {props.model.settings.preview - ? AdjacentPossible({ - stage: props.model.stage, - tools: props.model.tools.transforms, - inject: props.inject, - settings: props.model.settings, - }) - : null} + + + +
); diff --git a/src/view/SettingsView.tsx b/src/view/SettingsView.tsx index f701aee..d82a20c 100644 --- a/src/view/SettingsView.tsx +++ b/src/view/SettingsView.tsx @@ -20,6 +20,12 @@ import palette_1 from "../assets/icons/noun-palette-1918496.svg"; import alphabet from "../assets/icons/noun-alphabet-3591519.svg" import ded from "../assets/icons/noun-dead-5130035.svg" import prims from "../assets/icons/noun-geometry-4695832.svg" +import drag_off from "../assets/icons/noun-tool-3376727.svg" +import drag_on from "../assets/icons/noun-transformation-6040368.svg" +import mech_rails from "../assets/icons/noun-cycle-4446.svg" +import mech_closest from "../assets/icons/noun-cycle-1793611.svg" +import debug_on from "../assets/icons/noun-1831710.svg" +import debug_off from "../assets/icons/noun-1831712.svg" //TODO: qr code to disable id display @@ -81,6 +87,14 @@ const theme_icon = (theme: Settings.theme): string => { } }; +const dragging_icon = (dragging: boolean): string => + dragging ? drag_on : drag_off; + +const drag_mechanic_icon = (m: Settings.dragMechanic): string => + m === "Rails" ? mech_rails : mech_closest; + +const drag_debug_icon = (on: boolean): string => (on ? debug_on : debug_off); + let action_icon = (action: Settings.Action, settings: Settings.t): string => { switch (action) { case "ToggleSound": @@ -95,6 +109,44 @@ let action_icon = (action: Settings.Action, settings: Settings.t): string => { return symbols_icon(settings.symbols); case "ToggleDark": return theme_icon(settings.theme); + case "ToggleDragging": + return dragging_icon(settings.dragging); + case "ToggleDragMechanic": + return drag_mechanic_icon(settings.dragMechanic); + case "ToggleDragDebug": + return drag_debug_icon(settings.dragDebug); + } +}; + +/* Hover tooltips: what the toggle does + its current state. */ +const action_title = (action: Settings.Action, settings: Settings.t): string => { + switch (action) { + case "ToggleSound": + return `Sound: ${settings.sound ? "on" : "off"}`; + case "ToggleMotion": + return `Motion: ${settings.motion}`; + case "TogglePreview": + return `Adjacent-possible preview: ${settings.preview ? "on" : "off"}`; + case "ToggleProjection": + return `Projection: ${settings.projection}`; + case "ToggleSymbols": + return `Symbols: ${settings.symbols}`; + case "ToggleDark": + return `Theme: ${settings.theme}`; + case "ToggleDragging": + return `Drag mode (drag nodes to rewrite): ${ + settings.dragging ? "on" : "off" + }`; + case "ToggleDragMechanic": + return `Drag mechanic: ${ + settings.dragMechanic === "Rails" + ? "Rails (knob rides rails; change rules at the hub)" + : "Closest (nearest track wins each frame)" + }`; + case "ToggleDragDebug": + return `Drag debug overlay (rails, anchors, t readout): ${ + settings.dragDebug ? "on" : "off" + }`; } }; @@ -106,21 +158,38 @@ let icon = ( ); +/* Corner clusters, three icons each (an L per corner, no 2x2 squares): + * top-left sound·preview·reset, top-right theme·projection·symbols, + * bottom-left the drag trio, bottom-right empty. Order here must match + * the nth-child positioning in index.css. */ export const SettingsView: Component<{ model: Model; inject: (_: Action.t) => void; }> = (props) => (
{icon(props.inject, props.model.settings, "ToggleSound")} - {icon(props.inject, props.model.settings, "ToggleMotion")} {icon(props.inject, props.model.settings, "TogglePreview")} + { + e.preventDefault(); + e.stopPropagation(); + if (window.confirm("Reset everything to default?")) + props.inject({ t: "hardReset" }); + }} + /> {icon(props.inject, props.model.settings, "ToggleDark")} {icon(props.inject, props.model.settings, "ToggleProjection")} {icon(props.inject, props.model.settings, "ToggleSymbols")} - + {icon(props.inject, props.model.settings, "ToggleDragging")} + {icon(props.inject, props.model.settings, "ToggleDragMechanic")} + {icon(props.inject, props.model.settings, "ToggleDragDebug")}
); diff --git a/src/view/StageView.tsx b/src/view/StageView.tsx index c9ca4c4..8c45e1d 100644 --- a/src/view/StageView.tsx +++ b/src/view/StageView.tsx @@ -1,9 +1,9 @@ -import { Component } from "solid-js"; -import { For, Show, Switch, Match } from "solid-js"; +import { Component, createMemo } from "solid-js"; import { Model } from "../Model"; import * as Action from "../Action"; import { ExpView } from "./ExpView"; import * as Hover from "../Hover"; +import * as Drag from "../drag/Drag"; import { depth } from "../syntax/Node"; const stage_scale = (d: number) => (d == 0 ? 1 : 4 / (d + 1)); @@ -11,21 +11,29 @@ const stage_scale = (d: number) => (d == 0 ? 1 : 4 / (d + 1)); export const StageView: Component<{ model: Model; inject: (_: Action.t) => void; -}> = (props) => ( -
- -); + ); +}; diff --git a/src/view/ToolsView.tsx b/src/view/ToolsView.tsx index 9eef6a5..f3cf0ee 100644 --- a/src/view/ToolsView.tsx +++ b/src/view/ToolsView.tsx @@ -1,17 +1,14 @@ -import { Component } from "solid-js"; -import { For, Index, Switch, Match } from "solid-js"; +import { Component, createMemo } from "solid-js"; +import { For, Switch, Match } from "solid-js"; import toolbarbkg from "../assets/ps-toolbar.png"; import * as Pat from "../syntax/Pat"; import { Model } from "../Model"; -import * as Hover from "../Hover"; import * as Action from "../Action"; import { Transform, flip, at_path } from "../Transform"; import * as ToolBox from "../ToolBox"; import * as Names from "../Names"; import * as Settings from "../Settings"; -import * as Sound from "../Sound"; import { map_ids } from "../syntax/Node"; -import * as Util from "../Util"; import * as Stage from "../Stage"; export const Toolbar: Component<{ model: Model; inject: Action.Inject }> = ( @@ -28,62 +25,71 @@ const PatView: Component<{ p: Pat.t; is_head: boolean; symbols: Settings.symbols; -}> = (props) => { - switch (props.p.t) { - case "Atom": { - return ( -
- {Names.get(props.symbols, props.p.sym.name)} -
- ); - } - case "Comp": - return ( -
- - {(kid, i) => - PatView({ p: kid, is_head: i() === 0, symbols: props.symbols }) - } - -
- ); - } -}; +}> = (props) => ( + + +
+ {Names.get( + props.symbols, + props.p.t === "Atom" ? props.p.sym.name : "" + )} +
+
+ +
+ + {(kid, i) => ( + + )} + +
+
+
+); const matches_at = (stage: Stage.t, pat: Pat.t): Pat.MatchResult => stage.selection == "unselected" ? "NoMatch" : Pat.matches_at_path(stage.exp, pat, stage.selection); -const filter_transforms = (stage: Stage.t, ts: Transform[]): Transform[] => +/* Kept for future use (drag candidate gating): transforms applicable in + * both directions at the current selection. */ +export const filter_transforms = ( + stage: Stage.t, + ts: Transform[] +): Transform[] => ts.filter( (t) => matches_at(stage, t.source) !== "NoMatch" && matches_at(stage, t.result) !== "NoMatch" ); -const source_matches_cls = (props: { model: Model; t: Transform }) => - matches_at(props.model.stage, props.t.source) === "NoMatch" - ? "NoMatch" - : "match"; - -const result_matches_cls = (props: { model: Model; t: Transform }) => - matches_at(props.model.stage, props.t.result) === "NoMatch" - ? "NoMatch" - : "match"; - const TransformView: Component<{ idx: number; - t: Transform; model: Model; inject: (_: Action.t) => void; }> = (props) => { + /* Memo (reference equality): the transforms array is replaced wholesale + * on every flip, but untouched rows keep their Transform references — + * without this every row rebuilds its pat DOM (and re-rasters its shadow + * stack) on every tool use, which measured ~160ms per press. */ + const t = createMemo((): Transform => props.model.tools.transforms[props.idx]); + const source_cls = () => + matches_at(props.model.stage, t().source) === "NoMatch" + ? "NoMatch" + : "match"; + const result_cls = () => + matches_at(props.model.stage, t().result) === "NoMatch" + ? "NoMatch" + : "match"; const transformNode = (e: Event) => { + /* drag mode: let the press bubble to the row (loadout toggle) */ + if (props.model.settings.dragging) return; e.preventDefault(); e.stopPropagation(); if (props.model.stage.selection != "unselected") { @@ -91,12 +97,14 @@ const TransformView: Component<{ t: "transformNodeAndFlipTransform", target: "Source", idx: props.idx, - transform: props.t, - f: at_path(props.t, props.model.stage.selection), + transform: t(), + f: at_path(t(), props.model.stage.selection), }); } }; const transformNodeReverse = (e: Event) => { + /* drag mode: let the press bubble to the row (loadout toggle) */ + if (props.model.settings.dragging) return; e.preventDefault(); e.stopPropagation(); if (props.model.stage.selection != "unselected") { @@ -104,30 +112,25 @@ const TransformView: Component<{ t: "transformNodeAndFlipTransform", target: "Result", idx: props.idx, - transform: props.t, - f: at_path(flip(props.t), props.model.stage.selection), + transform: t(), + f: at_path(flip(t()), props.model.stage.selection), }); } }; - const setHover = (fn: any, target: Hover.t) => (e: Event) => { - if (fn(props) === "match") + const setHover = (cls: () => string, target: () => Hover_t) => (_e: Event) => { + if (cls() === "match") props.inject({ t: "setHover", - target, + target: target(), }); }; - const flipTransform = (e: Event) => { + const row_pointerdown = (e: Event) => { e.preventDefault(); e.stopPropagation(); - props.inject({ - t: "flipTransform", - idx: props.idx, - }); - }; - const do_nothing = (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - props.inject({ t: "Noop" }); + /* drag mode: the whole row is the loadout toggle */ + if (props.model.settings.dragging) + props.inject({ t: "toggleDragTool", idx: props.idx }); + else props.inject({ t: "Noop" }); }; const selected_res = (tools: ToolBox.t, c: number) => tools.selector[0] === c && tools.selector[1] === 1 ? "selected" : ""; @@ -137,33 +140,42 @@ const TransformView: Component<{
{/*
{props.t.name}
*/}
({ t: "TransformSource", - pat: props.t.source, + pat: t().source, idx: props.idx, - })} - onpointerleave={setHover(source_matches_cls, { t: "NoHover" })} + }))} + onpointerleave={setHover(source_cls, () => ({ t: "NoHover" }))} onpointerdown={transformNode} > id + 100000 + 100 * props.idx, props.t.source)} + p={map_ids((id) => id + 100000 + 100 * props.idx, t().source)} is_head={false} symbols={props.model.settings.symbols} />
-
+
{/* arrows: - ⇋ ⇌ ⇆ ⇄ ⇨ ➥ ➫ ➬ + ⇋ ⇌ ⇆ ⇄ ⇨ ➥ ➫ ➬ → ⇋ ⥊ ⥋ ⇋ ⇌ ⇆ ⇄ ⇐ ⇒ ⟸ ⟹ ⟺ ⟷ ⬄ ↔ ⬌ ⟵ ⟶ ← → ⬅ ⇦ @@ -189,19 +201,17 @@ const TransformView: Component<{
({ t: "TransformResult", - pat: props.t.result, + pat: t().result, idx: props.idx, - })} - onpointerleave={setHover(result_matches_cls, { t: "NoHover" })} + }))} + onpointerleave={setHover(result_cls, () => ({ t: "NoHover" }))} onpointerdown={transformNodeReverse} > id + 200000 + 100 * props.idx, props.t.result)} + p={map_ids((id) => id + 200000 + 100 * props.idx, t().result)} is_head={false} symbols={props.model.settings.symbols} /> @@ -210,73 +220,34 @@ const TransformView: Component<{ ); }; -const select_transforms = (stage:Stage.t,tools: ToolBox.t): [number, Transform][] => { - //const filtered_transforms = filter_transforms(stage, tools.transforms); - const filtered_transforms = tools.transforms; - /* want to take tools.size tools starting at tools.offset (index into tools) - and treat the list as a ring buffer */ - - const len = filtered_transforms.length; - const offset = tools.offset % len; - const size = tools.size; - const idxs = [...Array(size).keys()].map((i) => (i + offset) % len); - return idxs.map((i) => [i, filtered_transforms[i]]); -}; - -function throttle( - func: (...args: any[]) => void, - limit: number -): (...args: any[]) => void { - let inThrottle: boolean; - return function (this: any, ...args: any[]) { - if (!inThrottle) { - func.apply(this, args); - inThrottle = true; - setTimeout(() => (inThrottle = false), limit); - } - }; -} +type Hover_t = import("../Hover").t; export const ToolsView: Component<{ model: Model; inject: (_: Action.t) => void; }> = (props) => { + /* All rules render; plain wheel scrolls NATIVELY (CSS scroll-snap + * gives continuous scroll that settles on a rule when the gesture + * ends — the old ring-buffer window stepped whole rules per event). + * Shift+wheel resizes the box CONTINUOUSLY (size is fractional; the + * max-height tracks it smoothly); on macOS shift turns trackpad + * deltas horizontal, hence deltaX. */ return (
{ - //console.log("wheel deltay:", e.deltaY); - if (Math.abs(e.deltaY) < 1.5) return; - if (e.shiftKey) { - throttle(() => { - const offset = e.deltaY == 0 ? 0 : e.deltaY / Math.abs(e.deltaY); - //console.log("SHIFT GYOOOO", offset); - props.inject({ - t: "wheelNumTools", - offset, - }); - }, 1000)(); - } else { - throttle(() => { - const offset = e.deltaY == 0 ? 0 : e.deltaY / Math.abs(e.deltaY); - //console.log("GYOOOO", offset); - props.inject({ - t: "wheelTools", - offset: offset, - }); - }, 1000)(); - } + if (!e.shiftKey) return; // native scroll + snap + e.preventDefault(); + const d = e.deltaY || e.deltaX; + if (d === 0) return; + props.inject({ t: "wheelNumTools", offset: d / 90 }); }} > - - {([idx, t]) => - TransformView({ - idx, - t, - model: props.model, - inject: props.inject, - }) - } + + {(idx) => ( + + )}
);