frontend: the board glides and the editor grows from its card (task 1914)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 1m0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android (Tauri) / Android APK (debug) (push) Successful in 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m8s
Desktop (Tauri) / Update manifest (push) Successful in 4s

Two of M7's motion targets. prefers-reduced-motion was already in place from the
1999 pass and gates both of these for free.

FILTERED REFLOW. The three card grids become TransitionGroups sharing one
transition name, so "how the board moves" is defined once in CSS rather than
three times in markup. Vue's TransitionGroup does the FLIP itself — measure
before, measure after, transition the difference away — so no animation
dependency, which the task called for.

Leavers are deliberately NOT pulled out of flow with position:absolute, the usual
TransitionGroup trick. This masonry is CSS multi-column, and an absolutely
positioned child escapes its column to the container's origin: a note would fly
diagonally across the board on its way out. Keeping leavers in flow costs a small
settle when the element is finally removed, so the leave is the shortest of the
three durations.

EDITOR CONTINUITY. useNoteEditor.open() is the one place that knows which card
was clicked, so that is where the card's on-screen centre is captured; the editor
panel then scales from that point. Deliberately not a true shared-element morph:
scaling by the real card-to-panel ratio distorts the text on the way, and a card
is often a third of the modal, so an honest ratio reads as a zoom rather than a
transition. The task sanctioned a good-enough scale/position tween; this is that.

A point rather than a rect, because nothing needs the card's size and a point
survives the card being filtered away while the editor is open. Consumed on read,
so a compose — which has no card — cannot inherit the origin of whatever was
edited before it and grow from an arbitrary corner.

The animation lives inside NoteEditor rather than in the five views that render
it: the leave has to finish BEFORE the host unmounts, so the component owns its
own visibility and tells the host when it is done. visible starts true with
`appear`, because the panel lives inside that v-if and would not exist to measure
otherwise. The origin is measured with offsetLeft/offsetTop rather than
getBoundingClientRect — enter-from has already applied scale(0.94) by then, so
the bounding rect is of the shrunken panel and the origin would land off by a few
pixels. Offsets are layout geometry and ignore transforms.

Durations are 140-220ms. The brief is continuity, so a card should read as having
moved, not as having performed.

NOT verified: motion is a visual property and there is no frontend test lane, no
device, and no app run here. vue-tsc proves it compiles. Whether it FEELS right
is an operator live pass, which is what M7's own verification section asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:55:21 -04:00
co-authored by Claude Opus 5
parent e8d6a4f423
commit 18a58fb5da
5 changed files with 186 additions and 17 deletions
+66
View File
@@ -61,6 +61,72 @@ body {
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups.
*
* Vue's TransitionGroup does the FLIP itself: it measures each card before and
* after the list changes and transitions the difference away. All we supply is the
* curve. Deliberately fast and small — the brief is continuity, so a card should
* read as having MOVED rather than as having performed.
*
* Leavers are NOT taken out of flow with `position: absolute`, which is the usual
* TransitionGroup trick: this masonry is CSS multi-column, and an absolutely
* positioned child escapes its column to the container's origin — it would fly
* across the board on its way out. Keeping them in flow costs a small settle when
* the element is finally removed, so the leave is the shortest of the three.
*
* Reduced motion needs no special case here: the global guard above already
* collapses every duration, so this degrades to an instant cut. */
.board-move {
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
}
.board-enter-active {
transition:
opacity 180ms ease-out,
transform 180ms cubic-bezier(0.2, 0, 0, 1);
}
.board-leave-active {
transition: opacity 120ms ease-in;
}
.board-enter-from {
opacity: 0;
/* Barely a scale — enough to read as arriving, not as zooming. */
transform: scale(0.98);
}
.board-leave-to {
opacity: 0;
}
/* Editor open/close (M7). The backdrop only fades; the panel scales from the point
* the card occupied, which is what carries "this card became the editor" without
* the distortion a true card→panel scale would put through the text.
*
* Enter is slower than leave on purpose: arriving wants to be noticed as a
* connection, leaving just wants to be out of the way. LEAVE_MS in NoteEditor.vue
* must stay in step with the leave duration here — it waits that long before
* telling its host to unmount it. */
.editor-enter-active {
transition: opacity 200ms ease-out;
}
.editor-leave-active {
transition: opacity 140ms ease-in;
}
.editor-enter-from,
.editor-leave-to {
opacity: 0;
}
.editor-enter-active .editor-panel {
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
}
.editor-leave-active .editor-panel {
transition: transform 140ms ease-in;
}
.editor-enter-from .editor-panel,
.editor-leave-to .editor-panel {
transform: scale(0.94);
}
@layer components {
.icon-btn {
@apply rounded-md p-1.5 text-neutral-500 transition hover:bg-black/5 focus:outline-none