Skip to content

feat: e2e QA suite + self-recorded gallery + README with per-effect timesheets - #1

Open
CMolG wants to merge 32 commits into
mainfrom
feat/superpowers-backlog
Open

CMolG wants to merge 32 commits into
mainfrom
feat/superpowers-backlog

Conversation

@CMolG

@CMolG CMolG commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Plan 5 del backlog superpowers — completo

Este PR ejecuta el backlog de docs/superpowers/ sobre una única rama de desarrollo; el Plan 5 (QA + galería autograbada + README) está completo y los Planes 1-4 aterrizarán en esta misma rama.

Qué entrega

  • Workspace e2e/ (@playwright/test): 40 specs — runtime real en Chromium, huella-cero en modo usuario, invariantes observables de los 11 efectos, pipeline completo record→mix→gif contra ffmpeg real, ejemplos como contrato de schema, typos deterministas en ambos modos, y el gate de cobertura triple efecto↔GIF↔README.
  • Galería autograbada: pnpm gallery:record — Telekinesis grabándose a sí misma. 12 GIFs committeados (11 efectos + hero condensado de la landing), presupuesto duro ≤900KB/GIF y ≤10MB total que falla en rojo (validate-before-publish, overrides por clip medidos).
  • README: hero GIF + sección Effects con GIF + explicación + timesheet literal por efecto (byte-idénticos a examples/gallery/, garantizado por test) + sección Quality.
  • CI: job e2e bloqueante en push/PR (browser + ffmpeg, sin skips silenciosos) y regrabación de galería bajo demanda en el job motion (artifacts gallery-gifs). Run verde de los 3 jobs: 29261031483.
  • Engine (mejoras que la calidad de los GIFs exigió): carry sincronizado del drag externo (el chip viaja con el cursor — antes teleportaba), mistakes: true con pulsaciones reales (typo → backspace → corrección), hook afterTimeline, curvas de easing compartidas Node/navegador.

Muestra

hero


drag and drop

type down

Puerta de calidad

pnpm typecheck (10 workspaces) · pnpm test (117 unit) · pnpm build · pnpm e2e (40/40, 21.4s) · galería 3.9MB/10MB · CI verde end-to-end.

Cada task pasó implementación + review de spec + review de calidad con subagentes independientes; las notas de ejecución y desviaciones están en docs/superpowers/README.md.

🤖 Generated with Claude Code

CMolG and others added 30 commits July 10, 2026 18:42
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Also fixes playground/vite.config.ts: landing.html was never wired into
vite build's rollup inputs, so `pnpm --filter playground build` silently
dropped it from dist/ (only index.html survived). Task 6's hero-GIF
recording serves dist/ via `vite preview`, so this would have 404'd.
Now all three entries (index, landing, gallery) are explicit build inputs.

Co-Authored-By: Claude <noreply@anthropic.com>
record.ts's type-down previously drove pressSequentially straight through
eff.text, ignoring eff.mistakes entirely — an external-mode recording of a
"mistakes: true" sheet always came out with perfectly clean typing, even
though the gallery's type-down clip promises a stray keystroke that gets
backspaced and corrected live.

Extract the typo-placement decision (which characters get a preceding wrong
keystroke, and what that wrong keystroke is) into planTyping(), a pure,
DOM/Node-free function in @telekinesis/schema — a shared dependency of both
@telekinesis/core (browser) and @telekinesis/engine (Node), and already home
to comparable pure timesheet logic (layout.ts's estimateEffectDuration).
core/effects.ts's self-mode typeInto now calls the same function instead of
its own inline probability check, so a live-preview typo and a recorded typo
are provably driven by identical odds/placement — not just two hand-written
implementations that could quietly drift apart. Self-mode's observable
behavior is unchanged (verified via the existing e2e suite): with
mistakes:false, planTyping returns one plain step per character, same as
before.

record.ts's type-down case now walks planTyping's steps with real Playwright
input: for a typo'd step it presses the wrong character, pauses, presses a
real Backspace, pauses, then presses the correct character — mirroring
typeInto's write/pause/backspace/pause rhythm and its marking semantics
(both the wrong keystroke and the correct one get a sound mark; the
backspace itself stays silent, same as self mode).

Validated by recording examples/gallery/type-down.timesheet.json end-to-end
and inspecting extracted frames: a wrong character visibly appears
("motionm"), gets backspaced back to "motion", and typing continues to the
exact final text "motion is the message". Mark count came out to 22 for one
injected typo (21 base + 1), consistent with the mirrored semantics.

Co-Authored-By: Claude <noreply@anthropic.com>
…pair

The gallery's drag-and-drop clip only ever showed the ghost cursor
traveling — gal-drag-src's chip never moved, because the gallery page was
static chrome with no drag handlers, so Playwright's real dragTo() (real
mousedown/mousemove/mouseup during a recording) had nothing to respond to.
Per the architectural decision for this fix, the engine deliberately does
NOT drive the element itself in external mode: on a real app, the app's own
handlers react to the real mouse input, exactly like a real user's drag
would. The gallery page needed to actually be that "real app" for this one
pair.

DragDemo implements a plain pointer-driven drag — mousedown on the chip,
then window-level mousemove/mouseup while dragging — deliberately not HTML5
`draggable`. Playwright's Locator.dragTo() drives a drag purely by
dispatching real mousedown/mousemove/mouseup; native HTML5 drag needs
dragstart/dragover/drop, which dragTo never fires, so a draggable-based
implementation would never respond to a recording. The chip follows the
pointer via a CSS transform (no layout shift, no re-parenting) and, on
release, checks whether its center landed inside the dropzone; if so it
settles centered over it and the dropzone gets a "filled" style (reusing the
existing --good success accent), otherwise it snaps back to its origin.

Validated by recording examples/gallery/drag-and-drop.timesheet.json
end-to-end (real headless Playwright run against the built gallery page) and
inspecting extracted frames at the moment record.ts's real dragTo() fires:
the chip is confirmed stationary at the source through the ghost cursor's
whole pre-visualization glide, then jumps to the destination in the same
~100ms window as the real mousedown/mousemove/mouseup, and stays settled
there for the rest of the clip (checked across a dozen later frames,
including through the subsequent highlight effect).

Co-Authored-By: Claude <noreply@anthropic.com>
… mistakes coverage

record.ts's type-down case called planTyping(text, mistakes) without
startNonEmpty, so external-mode recordings could never plant a first-char
typo on a pre-filled field even though self-mode's typeInto (which derives
startNonEmpty from its own buffer) could — a parity break the surrounding
comment claimed didn't exist. Fix: read the field's live value (inputValue,
falling back to textContent for contenteditable) before planning, mirroring
effects.ts exactly. Also extracts pressAndWait to de-duplicate the
press/mark/wait idiom in the typing loop, and rewords planTyping's
startNonEmpty doc comment now that neither call site hardcodes a value.

Adds e2e/tests/typing-mistakes.spec.ts: deterministic mistakes:true coverage
for both consumers, forcing exactly one typo via a pinned Math.random. Two
undocumented traps discovered while building it, both worked around rather
than assumed away: (1) Playwright's own internals (randomIntInRange) and, in
this repo's sourcemapped test run, even the act of reading Error().stack
itself (via a randomized-quicksort prepareStackTrace hook) consume Math.random
before user code runs, so a fixed-position queue must be installed *after*
that unrelated consumption — a throwaway priming call in self mode, record()'s
onStep hook in external mode — never blindly wrapped around the whole call.

Co-Authored-By: Claude <noreply@anthropic.com>
DragDemo's mouse-based drag had two bugs: window-level mousemove/mouseup
listeners registered in handleMouseDown were never removed if the component
unmounted mid-drag (a real leak — window outlives the component), and a
release outside the viewport would never fire mouseup at all, leaving the
drag permanently stuck. Replaces mouse events with Pointer Events:
pointerdown captures the pointer on the chip itself, so pointermove/pointerup
listen on the chip (not window) and keep firing wherever the pointer
actually is — including outside the viewport. pointercancel and
lostpointercapture both snap back, same as missing the dropzone. A
useEffect unmount cleanup removes any still-attached listeners as a safety
net; normal drag-end already does this itself.

Re-verified empirically end-to-end, since Playwright's Locator.dragTo()
drives input via low-level mouse dispatch, not literal pointer events: built
the playground, booted vite preview, and recorded
examples/gallery/drag-and-drop.timesheet.json with @telekinesis/engine's
record() (both directly and via hand-instrumented reproductions of
record.ts's exact call sequence). Confirmed via ffmpeg frame extraction and
direct DOM/computed-style checks that Chromium synthesizes genuine
(isTrusted) pointer events from CDP-dispatched mouse input, and the chip
settles centered in the dropzone with the correct .filled green border
after its transition. (An initial frame-extraction pass wrongly suggested
the drag was broken — the timestamps sampled were simply earlier than the
real settle point, roughly ~2.3s into the drag step once the ghost-cursor
pre-animation and dragTo()'s own overhead are accounted for; onStep timing
and a denser resample resolved it.)

Also: reuses Point/rectCenter from @telekinesis/core instead of a
hand-rolled twin (rectCenter newly exported — additive) in App.tsx, and
renames .is-dragging/.is-filled to .dragging/.filled to match the file's
existing unprefixed state-class convention (.card.featured, .cta.ghost).
.is-dropped was left as-is — out of the literal rename scope given, though
it's now the odd one out; worth a follow-up.

Co-Authored-By: Claude <noreply@anthropic.com>
The gallery drag's settle math (endDrag in DragDemo) centers the chip on
the dropzone's rect *after* the drop, but until now the label swap
(`{dropped ? "" : "Drop here"}`) unmounted the "Drop here" text at the
exact same moment, collapsing the dropzone's shrink-to-fit box (measured:
~90.66x49 -> ~24x32) and moving its center ~33px left of where the chip
actually landed. Compounding cause: `flex: 1` on `.gallery-chip`/
`.gallery-dropzone` was inert — TelekineticFrame's own wrapper div (not
those boxes) is the flex row's actual direct child, so both boxes were
always sized by content, never by the row.

Fix: keep the "Drop here" label mounted (only visually hidden via CSS once
`.filled`) so the dropzone's own box never changes size on drop, and move
`flex: 1` onto `.gallery-drag-row > [data-telekinesis-frame]`, the wrapper
that's actually a flex item. Verified by measurement, not eyeball: a
headless drag now lands the chip's center within <0.01px of the
dropzone's center (was off by 33.33px before), captured via a temporary
git-stash before/after comparison. New e2e/tests/gallery-drag.spec.ts is
the permanent guard — drives the drag the way record.ts does
(`Locator.dragTo(dest, { force: true })`) and asserts the two centers
stay within 3px with `.filled` present.

Also: handlePointerDown now guards against reentrant drags and non-primary/
non-left-button pointers, and every drag listener re-checks `ev.pointerId`
against the pointer that started the drag (pointer capture only retargets
that pointer's own events, not a second uncaptured one). Adds
`touch-action: none` to the chip so a touch drag doesn't fight the
browser's own scroll/pan gesture. Finishes the `is-dropped` -> `dropped`
rename flagged as a follow-up in 56f7384.

Co-Authored-By: Claude <noreply@anthropic.com>
… spec

record.ts's typing loop presses a real Backspace to correct a planted typo,
but that press is deliberately unmarked (same as self mode), so the
external-mode mistakes test's mark-count assertions can't see it — deleting
the Backspace press leaves that test green even though the field ends up
with an uncorrected stray character. Mutation-tested to confirm: temporarily
removed the Backspace press, re-ran the suite, and only the new value
assertion below failed (typed value became "kaeyboard" instead of
"keyboard"; both mark-count/profile assertions still passed unchanged),
then restored it.

Fix: add `RecordOptions.afterTimeline?: (page: Page) => void | Promise<void>`,
invoked with the live page once the timeline finishes (after `durationMs` is
captured, before the context closes) so a caller can inspect real in-page
state before teardown — additive/optional, no existing caller affected.
typing-mistakes.spec.ts's external test now uses it to read
`gal-input`'s real `inputValue()` and assert it equals the intended text,
closing the phantom-coverage gap.

Also: custom expect messages on both tests' `marks`/`audioMap.marks`
`toHaveLength` assertions, pointing at Math.random forcing-queue
contamination as the likely cause of a mismatch (see the file's own
comments on where that contamination comes from in each mode).

Co-Authored-By: Claude <noreply@anthropic.com>
Split @telekinesis/core's drag-and-drop visual into two independently
callable phases — dragApproach (cursor travels to the source + press
pulse) and dragGlide (cursor glides to the destination) — and expose
both on window.__telekinesis (runDragApproach/runDragGlide) alongside
the existing atomic runEffect, which still runs both back-to-back for
every other caller (self-mode preview, Studio playback, a bare
external runEffect call) with no behavior change.

record.ts's "drag-and-drop" case now awaits only the short approach
leg, then fires the destination glide without awaiting it while
driving a real, stepped Playwright mouse drag paced to the same
eff.duration, and finally awaits the glide's completion. The gallery's
DragDemo chip tracks real Pointer Events (setPointerCapture), so it
now visibly rides along with the ghost cursor across the whole trip
instead of the previous choreography, which awaited the entire visual
first and only then snapped the real drag into place afterwards —
cursor glides, a beat of dead air, the dragged element teleports.

Verified by re-recording examples/gallery/drag-and-drop.timesheet.json
and inspecting the extracted GIF frames: the chip is visibly mid-
transit across multiple consecutive frames, riding with the cursor,
with no dead pause, and ends settled in the dropzone under the
highlight spotlight. Full e2e suite (25 tests) stays green.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
drag-and-drop's synchronized carry fires the destination glide
(runDragGlide) without awaiting it, then races a real stepped mouse
drag before awaiting it later. Between the fire and that later await,
an unhandled rejection on glideDone (e.g. execution context destroyed
mid-drag) would crash the whole Node process — skipping every
`finally` and taking out the rest of a multi-GIF record-gallery batch
over one flaky drag. Attach a no-op `.catch` on a separate chain right
after firing so the rejection is marked handled; the later `await
glideDone` still rethrows it normally through its own chain.

Also comment the dragApproach/dragGlide split at its effects.ts call
site: it exists so external-mode recording can fire the glide
concurrently with the real drag, while self-mode (and every other
caller) still awaits both phases back-to-back, unchanged from before
the split.

Co-Authored-By: Claude <noreply@anthropic.com>
The committed hero.gif ships at fps 10, but record-gallery.ts's uniform
fps 12 default reproducibly regenerates it at ~905-908KB — over the
900KB per-GIF budget — breaking the "GIFs regenerate only by the
script" rule for that one job. Add a JOB_OVERRIDES map (hero -> fps 10)
resolved by resolveFps, precedence env TK_GALLERY_FPS > override >
default, applied where fps flows into toGif.

Re-recorded public/gallery/hero.gif via `TK_GALLERY_ONLY=hero pnpm
gallery:record` itself (827584 bytes, within budget). Sanity-checked
the no-override path with TK_GALLERY_ONLY=zoom-in — the regenerated
zoom-in.gif is discarded here (not committed) to avoid encode-variance
noise.

Co-Authored-By: Claude <noreply@anthropic.com>
record.ts's real, stepped Playwright drag hardcoded its own local
easeOutQuad for every drag-and-drop, while the ghost cursor's visual glide
(dragGlide -> GhostCursor.moveTo) resolves its curve through cursor.ts's
curveForEasing, which substitutes fittsEase for the schema default
"ease-in-out" and jsEasing[easing] for everything else. The two curves
only coincidentally overlapped in shape; for "linear" they diverge 25pp at
t=0.5, and even for the *default* easing (what the gallery's own
drag-and-drop.timesheet.json uses) fittsEase vs. the old easeOutQuad
diverge ~14pp at t=0.5 — the dragged element could visibly drift off the
cursor "carrying" it mid-trip. easeOutQuad was also a character-for-
character duplicate of jsEasing["ease-out"], which it wasn't even using.

Fix: both sides now resolve through the exact same curveForEasing.

Implementation choice: moved the pure curve math (jsEasing, fittsEase,
curveForEasing — no DOM references) from @telekinesis/core into
@telekinesis/schema, and re-export it unchanged from core's easing.ts/
cursor.ts. Considered having engine depend on @telekinesis/core directly
instead (importing curveForEasing from there), but core's package "exports"
map only exposes its full index.ts barrel, which re-exports the React
components (react/TelekineticFrame, react/TelekinesisStage) alongside the
DOM-heavy runtime; engine's tsconfig resolves workspace packages straight
to source (no project references/prebuilt .d.ts isolation) and restricts
"types" to ["node"] (vs. core's ["react"]), so pulling core's whole barrel
into engine's program risked breaking engine's typecheck on JSX/React
ambient types for code engine would never use. Routing through schema
instead is the smaller, honest diff: schema already has zero DOM
dependencies, engine already depends on it (zero new dependency edges),
core's public API is unchanged (every existing import of jsEasing/
fittsEase from "@telekinesis/core" or from "./easing"/"./cursor" keeps
resolving to the same values via re-export), and there's no dependency
cycle (schema -> nothing; core -> schema, pre-existing; engine -> schema,
pre-existing).

cssEasing (the CSS transition-timing-function table) deliberately stays in
core — it's browser/CSS-specific and engine's Node-side recorder never
touches it, so moving it would have widened the diff without serving this
fix.

Deleted the duplicated easeOutQuad. All 117 unit tests pass unchanged
(easing.test.ts and cursor-motion.test.ts exercise jsEasing/fittsEase via
core's re-exports, proving the chain works at runtime, not just at the
type level), and every package's typecheck is clean, including engine's
new value import of curveForEasing from @telekinesis/schema.

Co-Authored-By: Claude <noreply@anthropic.com>
…s, failure recap

Four fixes to e2e/scripts/record-gallery.ts, all from real pain running it:

1. The budget guard wrote toGif's output straight to the tracked
   public/gallery/<name>.gif and only checked its size *after*, so a run
   that busts the 900KB per-GIF budget left a dirty oversized file sitting
   in the tracked path (reproduced twice locally). Fixed: every job now
   encodes into tmpOutDir (public/gallery/.tmp/<name>.gif — same
   filesystem as the final path, so the rename below is never a
   cross-device EXDEV; nested under outDir but excluded from the
   total-budget scan since readdir(outDir) doesn't recurse), stats *that*,
   and only renames onto the tracked path on a pass. A failing job's tmp
   output is deleted, never renamed. main() also wipes+recreates
   tmpOutDir up front so a prior hard-crash's leftovers can't linger.

2. JOB_OVERRIDES generalized from `{ fps?: number }` to
   `{ fps?: number; width?: number }`, with a new `resolveWidth` mirroring
   `resolveFps` (env TK_GALLERY_WIDTH > override > DEFAULT_WIDTH;
   `resolveWidth`'s doc comment points back to `resolveFps`'s instead of
   restating the precedence rule). Added overrides for zoom-in/zoom-out:
   `{ fps: 10, width: 440 }`. Both rescale the entire viewport every frame,
   defeating GIF palette compression — they regenerate at 1017-1061KB at
   the global fps12/width480 defaults, over the 900KB budget. The
   previously-committed 816KB/766KB bytes for these two were themselves
   just within this encoder's ~±25-30% run-to-run size variance at those
   settings, not evidence that fps12/480 reliably fits — fps10+width440
   measured 602KB, a real 33% margin instead of a gamble.

3. A mid-batch failure used to skip straight to main().catch, so a run
   that died on job 4 of 12 never showed what the first 3 actually
   measured. The per-job loop now has a catch that prints the rows
   collected so far as a "jobs completed before the failure" table before
   rethrowing (finally still kills the preview server either way).

4. The two thrown error strings that were in Spanish/mixed language are
   now English, same information, e.g. "<name>.gif weighs XKB — trim the
   timesheet or lower width/fps" and "...over the 10MB budget; trim
   timesheets or lower width/fps".

No behavior change for a passing run other than the width knob; e2e
package typecheck and the full monorepo typecheck are both clean.

Co-Authored-By: Claude <noreply@anthropic.com>
hero.timesheet.json's scroll-down covered ~970px in 600ms — at hero's fps
10 (JOB_OVERRIDES), that's too few distinct GIF frames for the scroll to
read as motion; it played as a jump-cut. Slowed to 900ms, matching
scroll-down.timesheet.json's own scroll duration. Verified on the
regenerated hero.gif (gifsicle frame listing + pixel diff across
consecutive frames): the scroll window now spans 10 distinct stored
frames (vs. the old 2-3), with real progressive content change frame to
frame (5-35% of pixels changing per tick, nav bar receding and new
content entering by the window's end) — motion, not a cut.

Regenerated via the script (`TK_GALLERY_ONLY=<name>`, playground built,
`pnpm --filter @telekinesis/e2e gallery:record`), not by hand:

  GIF              old (committed)   new       budget
  hero.gif         828KB             854KB     900KB (46KB margin)
  zoom-in.gif      817KB             597KB     900KB (303KB margin)
  zoom-out.gif     766KB             604KB     900KB (296KB margin)
  drag-and-drop    203KB             211KB     900KB (689KB margin)

- hero: re-paced scroll (this commit) plus whatever this run's encode
  variance was; still comfortably under budget.
- zoom-in/zoom-out: the previous commit's new fps10/width440 overrides
  landing where they were measured to.
- drag-and-drop: engine now drives the real pointer through the same
  curveForEasing curve the ghost cursor uses (prior commit) instead of the
  old easeOutQuad — a few KB of encode difference from a different motion
  shape, still tiny next to budget.

drag-and-drop.gif frame-verified the same way: gifsicle's frame list +
pixel diffs show the chip riding rightward across at least 4 distinct
mid-transit frames (source -> ~1/3 -> ~2/3 -> arrived, each a clearly
different on-screen position, cursor still attached throughout — the
synchronized-carry choreography this task's Commit 1 curve fix targets),
then a sharp frame-to-frame jump (~90% of pixels) as the trailing
highlight spotlight confirms the drop and the chip sits settled inside the
dropzone.

public/gallery totals 4.02MB across all 12 GIFs (10MB budget). The other
8 GIFs are untouched — `git diff --stat` against each is empty.

Only examples/gallery/hero.timesheet.json and these 4 binaries are
committed; no source changes.

Co-Authored-By: Claude <noreply@anthropic.com>
…yet mirror the flySpring branch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Red on the README assertions by design; the next commit's README turns
it green (plan 5 task 7→8 TDD).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… timesheet

Every effect in the README now ships with the GIF that shows it and the
literal timesheet that filmed it, plus a hero GIF up top recorded by
Telekinesis about Telekinesis. Turns green the coverage spec committed
in the prior commit (plan 5 task 7→8 TDD).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hero.gif was the one GIF of the twelve whose README embed nothing
asserted — stripping the hero <img> would have slipped through. Also
align the budget comment with the strict toBeLessThan the assertions
actually use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…verage asserts

- Hero test now also asserts examples/gallery/hero.timesheet.json exists,
  parses, and has a non-empty timeline, mirroring the per-action block.
- New orphan-heading test: every `#### `name`` section in the README must
  map back to an EFFECT_ACTIONS entry or "hero", catching a stale section
  left behind after an effect rename.
- Anchor the per-action heading regex to a whole line (^...$ with "m"),
  so it can no longer match inside a deeper heading level (#####/######)
  or a backticked mention of the action sitting mid-sentence.
- Guard every file check with existsSync (custom message) instead of
  letting statSync throw a raw ENOENT, and make every assertion in the
  per-action and hero tests expect.soft, so a brand-new action reports
  all of its gaps (missing sheet, GIF, README refs) in one run.
- Fix the double-slash path bug: repoRoot (from a bare "../.." URL) keeps
  a trailing slash, which template-literal concatenation was silently
  doubling up against; switched to path.join throughout, matching
  examples.spec.ts's own convention.

Co-Authored-By: Claude <noreply@anthropic.com>
- click: the sound doesn't time itself to "the exact frame the ripple
  starts" — traced through effects.ts and record.ts, the ripple plays
  out fully first, and the sound lands with the real Playwright click
  right after it.
- type-down: `mistakes: true` doesn't "plant exactly one typo per run" —
  packages/schema/src/typing.ts rolls TYPO_RATE (0.06) independently per
  eligible character with no cap, so a run can land clean or catch a few.
- Replace all 11 generic "<action> effect" alt attributes with short,
  concrete first-clause descriptions of what's on screen. Hero's alt is
  left as-is.

Co-Authored-By: Claude <noreply@anthropic.com>
…rding

Co-Authored-By: Claude <noreply@anthropic.com>
The root workspace has no playwright dependency, so the unscoped
'pnpm exec playwright install' hard-fails on a clean runner
(ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL) — first exposed by the first-ever
live run of this workflow_dispatch-gated job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… contract, not byte determinism

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…easings

Plan 1 backlog, Task 1 (docs/superpowers/plans/2026-07-06-01-animation-transition-library.md).
Test-first: extended packages/core/test/easing.test.ts's EXPECTED_EASINGS/
MONOTONIC/OVERSHOOTING lists before the schema had the values, confirming
both a runtime assertion failure and (per pnpm typecheck) the expected
compile error on the two EasingPattern[]-typed arrays.

- schema/src/easing.ts: append the 3 names to the EasingPattern enum
  (additive only, per its own doc comment) and their closed-form jsEasing
  curves (easings.net forms) — Record<EasingPattern, ...> makes the
  compiler enforce completeness.
- core/src/easing.ts: matching cssEasing cubic-bezier fallbacks, same
  completeness guarantee via Record<EasingPattern, string>.

Co-Authored-By: Claude <noreply@anthropic.com>
CMolG and others added 2 commits July 13, 2026 17:31
…Spring

Debt tracked in docs/superpowers/README.md's Plan 5 execution notes and in
curveForEasing's doc comment (packages/schema/src/easing.ts): a
drag-and-drop authored with easing: "spring" ran packages/engine/src/
record.ts's real pointer through curveForEasing's fixed-duration
cubic-bezier approximation, while the ghost cursor's glide
(GhostCursor.moveTo, core/src/cursor.ts) branched to flySpring — a true,
variable-duration physics integration — before ever reaching that same
function. No shipped timesheet used the combination, but the two legs
would have visibly drifted apart on both self-mode (the dragged element's
CSS transition already used the fixed-duration approximation) and
external/recorded drags.

Resolution (option a from the plan): teach GhostCursor.moveTo an opt-in
escape hatch, MoveOptions.approximateSpring, that skips the flySpring
branch and resolves "spring" through curveForEasing instead. dragGlide
(core/src/effects.ts) sets it unconditionally on its cursor.moveTo call —
a no-op for every non-spring easing, and for spring specifically it trades
the nicer true-spring feel for guaranteed lockstep across all three legs
of a drag (self-mode element, ghost cursor, external real pointer). Every
other moveTo caller (cursor-move, click, a drag's own ease-out pick-up
approach) is unaffected.

Updated curveForEasing's doc comment to describe the closed gap instead of
the dormant desync. Extended the existing e2e drag-and-drop coverage
(e2e/tests/effects.spec.ts) with a spring-eased variant as a cheap
regression guard for the new wiring.

Co-Authored-By: Claude <noreply@anthropic.com>
…back-ease undershoot

Review follow-up on 227d519 (three findings):

- e2e/tests/effects.spec.ts: the spring-drag test's settle-position
  assertion had no teeth — GhostCursor.moveTo ends with an exact
  place(target) snap on BOTH branches, so reverting approximateSpring
  could not fail it. Rebuilt it around the elapsed ms runEffect already
  returns, over a long (~600px) explicit destX/destY carry where the
  branches actually diverge: approximation path measured ~1480ms
  (stable across 4 runs: 1475-1487) vs flySpring path ~2200ms (glide
  150ms vs travel-spring physics ~860ms). Bound at 1800ms, >=300ms
  margin each way; every phase of the passing path is a wall-clock
  duration or cap, so CI jank costs ~a frame per phase. Verified by
  mutation: flipping the flag to false in dragGlide fails the test
  (Expected < 1800, Received 2201); restoring it passes. The comment
  now states exactly what the test does and doesn't catch (real
  Playwright pointer out of scope — record.ts owns that side).

- packages/core/test/easing.test.ts: assert ease-in-out-back's
  documented undershoot (min ~ -0.100 near t~0.24) via an
  UNDERSHOOTING case — spring/ease-out-back deliberately excluded,
  they never dip below 0.

- packages/schema/src/easing.ts: tightened curveForEasing's paragraph 4
  ~40% (17 -> 11 lines): cut the self-referential opener, compressed
  the before/after history to two sentences. Paragraphs 1-3 untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant