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
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:
@@ -10,6 +10,8 @@ import LabelPicker from "./LabelPicker.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||
import { takeMorphOrigin } from "../composables/useEditorMorph";
|
||||
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
|
||||
|
||||
@@ -174,10 +176,49 @@ async function commitAndContinue(): Promise<void> {
|
||||
await nextTick();
|
||||
bodyInput.value?.focus();
|
||||
}
|
||||
// ---- open/close animation (M7) ----
|
||||
//
|
||||
// Owned here rather than by each of the five views that render this component: the
|
||||
// leave has to play BEFORE the host unmounts us, so the component has to control
|
||||
// its own visibility and tell the host afterwards.
|
||||
// Starts TRUE, with `appear` driving the entry animation. Starting false and
|
||||
// flipping it on mount would be the obvious shape, but the panel lives inside this
|
||||
// v-if — it wouldn't exist yet to measure.
|
||||
const visible = ref(true);
|
||||
const panel = ref<HTMLElement | null>(null);
|
||||
/** Matches .editor-leave-active in style.css. */
|
||||
const LEAVE_MS = 140;
|
||||
|
||||
onMounted(() => {
|
||||
const from = takeMorphOrigin();
|
||||
// Grow from the card that was clicked. Set as a transform-origin rather than
|
||||
// animating between two rects — see useEditorMorph for why. No origin (compose,
|
||||
// or a card that scrolled out of view) simply grows from its own centre.
|
||||
if (!from || !panel.value) return;
|
||||
// offsetLeft/offsetTop, NOT getBoundingClientRect: the enter-from class has
|
||||
// already applied scale(0.94) by now, so the bounding rect is of the SHRUNKEN
|
||||
// panel and the origin would land a few pixels off. Offsets are layout geometry
|
||||
// and ignore transforms. The offset parent is the inset-0 backdrop, so these are
|
||||
// effectively viewport coordinates — which is what the captured point is in.
|
||||
panel.value.style.transformOrigin =
|
||||
`${from.x - panel.value.offsetLeft}px ${from.y - panel.value.offsetTop}px`;
|
||||
});
|
||||
|
||||
/** Play the leave, then let the host unmount us. */
|
||||
async function finish(): Promise<void> {
|
||||
if (prefersReducedMotion()) {
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
visible.value = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, LEAVE_MS));
|
||||
emit("close");
|
||||
}
|
||||
|
||||
// Persist (create in compose, save in edit) and close the editor.
|
||||
async function close(): Promise<void> {
|
||||
await flush();
|
||||
emit("close");
|
||||
await finish();
|
||||
}
|
||||
// Esc / click-away DISMISS. A brand-new, not-yet-persisted note is DISCARDED — so an
|
||||
// accidental keystroke or type-to-compose never litters the board. To keep a new note,
|
||||
@@ -185,7 +226,7 @@ async function close(): Promise<void> {
|
||||
// compose already persisted by a rich action, closes normally (saving its text).
|
||||
async function dismiss(): Promise<void> {
|
||||
if (isCreate.value) {
|
||||
emit("close");
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
await close();
|
||||
@@ -489,7 +530,7 @@ async function onPaste(e: ClipboardEvent) {
|
||||
// ---- edit-mode lifecycle actions (pin/archive/trash/restore/delete) ----
|
||||
async function act(fn: () => Promise<void>) {
|
||||
await fn();
|
||||
emit("close");
|
||||
await finish();
|
||||
}
|
||||
|
||||
// ---- version history (modal edit only) ----
|
||||
@@ -535,14 +576,21 @@ function revPreview(rev: NoteRevision): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
|
||||
@mousedown.self="onBackdropMousedown"
|
||||
>
|
||||
<!-- One editor card for BOTH compose (empty note) and edit — a single surface. -->
|
||||
<!-- `appear` because we mount already-open: the host renders us with v-if, so the
|
||||
enter has to fire on the first frame rather than on a later state change. -->
|
||||
<Transition name="editor" appear>
|
||||
<div
|
||||
class="w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
v-if="visible"
|
||||
ref="root"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]"
|
||||
@mousedown.self="onBackdropMousedown"
|
||||
>
|
||||
<!-- One editor card for BOTH compose (empty note) and edit — a single surface.
|
||||
`editor-panel` is the animation's handle: the backdrop only fades, while
|
||||
this scales from the card that opened it (see useEditorMorph). -->
|
||||
<div
|
||||
ref="panel"
|
||||
class="editor-panel w-full max-w-lg rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@keydown.esc.stop="onEsc"
|
||||
@@ -899,5 +947,6 @@ function revPreview(rev: NoteRevision): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user