feat(editor): the timeline strip waits for intent, and the swap animates
Crossing the strip on the way to the Dock opened the timeline for a frame. It now waits 220ms with the pointer still on it before opening, and leaving CANCELS a pending open rather than queueing a close — so a pass-through costs nothing at all. A drag skips the wait entirely: dragging a file onto the timeline is not an accident. The swap animates. That meant giving up display:none for the closed state, since display cannot be transitioned: closed is height 0 with overflow hidden, which looks identical at rest — no sliver of lane, no cut-off clip — and on the way open the same clipping is simply what a reveal looks like. The strip and the timeline animate against each other so the zone's height stays continuous rather than jumping. Reduced motion drops the slide and keeps the behaviour. Verified in the browser: a touch-and-leave does not open it, staying does and only after the delay, leaving closes it, and the transition is on the body. Nine unit tests cover the intent timer with injected timers, including the two that would bite — a drag cancelling a pending hover, and moving around inside an already-open timeline not re-arming the delay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b4054b1d5
commit
94f82cfab4
@@ -431,47 +431,60 @@ a.brand:hover .brand-sub { color: var(--accent-hi); }
|
||||
.peek-drag-open is held by timeline_peek.js for the length of a drag: during one the
|
||||
pointer is in drag mode and a strip that stays shut is a target you cannot hit. */
|
||||
:root[data-timeline-peek="1"] .zone.timeline {
|
||||
/* Sizes to its contents: the header, plus either the caption strip or the timeline.
|
||||
flex: 0 0 auto is load-bearing — .zone is a shrinkable column child, and a fixed height
|
||||
with min-height: 0 let the workspace squeeze it to 1px. */
|
||||
/* Sizes to its contents: the header, plus the strip or the timeline. flex: 0 0 auto is
|
||||
load-bearing — .zone is a shrinkable column child, and a fixed height with min-height: 0
|
||||
let the workspace squeeze it to 1px. */
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* CLOSED: the timeline is gone, not clipped. Showing a sliver of it read as a broken
|
||||
timeline — a lane label and a cut-off clip with dead space under them — so the closed
|
||||
state is the labelled strip below and nothing else. */
|
||||
:root[data-timeline-peek="1"] .zone.timeline .zone-body { display: none; }
|
||||
:root[data-timeline-peek="1"] .zone.timeline.peek-open .zone-body,
|
||||
:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .zone-body {
|
||||
display: flex;
|
||||
/* CLOSED is height 0, not display:none — display cannot be transitioned, and the swap has to
|
||||
animate. overflow: hidden is what keeps the resting state clean: at height 0 there is no
|
||||
sliver of lane or cut-off clip to read as a broken timeline, and on the way open the same
|
||||
clipping is simply what a reveal looks like. */
|
||||
:root[data-timeline-peek="1"] .zone.timeline .zone-body {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 0;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
transition: height .18s cubic-bezier(.22,.61,.36,1),
|
||||
flex-basis .18s cubic-bezier(.22,.61,.36,1);
|
||||
}
|
||||
:root[data-timeline-peek="1"] .zone.timeline.peek-open .zone-body,
|
||||
:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .zone-body {
|
||||
flex-basis: var(--timeline-h);
|
||||
height: var(--timeline-h);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
/* The strip itself. Only ever visible while the preference is on and the timeline is shut —
|
||||
it IS the closed state, so it goes away the moment the timeline is there. */
|
||||
/* The strip. It IS the closed state, so it collapses as the timeline arrives — the two
|
||||
animate against each other and the zone's total height stays continuous. */
|
||||
.tl-peek-strip { display: none; }
|
||||
:root[data-timeline-peek="1"] .tl-peek-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 0 0 auto;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: var(--fs-subhead);
|
||||
color: var(--faint);
|
||||
letter-spacing: .02em;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
transition: height .18s cubic-bezier(.22,.61,.36,1),
|
||||
opacity .12s ease, color .12s ease;
|
||||
}
|
||||
:root[data-timeline-peek="1"] .tl-peek-strip:hover { color: var(--text); }
|
||||
:root[data-timeline-peek="1"] .zone.timeline.peek-open .tl-peek-strip,
|
||||
:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .tl-peek-strip { display: none; }
|
||||
:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .tl-peek-strip {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
border-top-color: transparent;
|
||||
}
|
||||
/* Dragging the splitter would fight the strip for the height, so it goes with it. The
|
||||
preference IS the height decision while it is on. */
|
||||
:root[data-timeline-peek="1"] #split-bottom { display: none; }
|
||||
@@ -479,6 +492,11 @@ a.brand:hover .brand-sub { color: var(--accent-hi); }
|
||||
:root[data-timeline-peek="1"] {
|
||||
--timeline-anchor: calc(var(--timeline-peek-h, 34px) + 26px);
|
||||
}
|
||||
/* Reduced motion: it still opens and closes, just without the slide. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root[data-timeline-peek="1"] .zone.timeline .zone-body,
|
||||
:root[data-timeline-peek="1"] .tl-peek-strip { transition: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Long enough that crossing the strip on the way to the Dock is not a trigger, short
|
||||
// enough that aiming at it does not feel like waiting. A drag skips it entirely: dragging
|
||||
// something onto the timeline is not an accident.
|
||||
const OPEN_DELAY_MS = 220;
|
||||
|
||||
const STRIP_ID = "timeline-peek-strip";
|
||||
const OPEN_CLASS = "peek-open";
|
||||
const STRIP_LABEL = "Timeline — hover to show";
|
||||
@@ -132,8 +137,46 @@
|
||||
return !target.closest(".zone-head");
|
||||
}
|
||||
|
||||
// The closed body is display:none, so the lanes have no width to lay out against while it
|
||||
// is shut. Telling the app to re-measure on the way open costs one resize handler and
|
||||
// Hover INTENT, not hover. The pointer crossing the strip on its way somewhere else — the
|
||||
// Mac Dock is right under it in a windowed layout — used to flash the timeline open for a
|
||||
// frame. Opening waits for the pointer to still be there after a beat; leaving cancels a
|
||||
// pending open rather than queueing a close, so a pass-through costs nothing at all.
|
||||
//
|
||||
// Timers are injected so this is testable without waiting in real time.
|
||||
function makeHoverIntent(opts) {
|
||||
const o = opts || {};
|
||||
const delay = o.delay == null ? OPEN_DELAY_MS : o.delay;
|
||||
const setT = o.setTimer || ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearT = o.clearTimer || ((id) => clearTimeout(id));
|
||||
let pending = null;
|
||||
let open = false;
|
||||
return {
|
||||
// Pointer is over something that opens the timeline.
|
||||
point() {
|
||||
if (open || pending !== null) return false;
|
||||
pending = setT(() => { pending = null; open = true; o.onOpen && o.onOpen(); }, delay);
|
||||
return false;
|
||||
},
|
||||
// Pointer is over the header, or has left the zone.
|
||||
away() {
|
||||
if (pending !== null) { clearT(pending); pending = null; }
|
||||
if (!open) return false;
|
||||
open = false;
|
||||
o.onClose && o.onClose();
|
||||
return false;
|
||||
},
|
||||
// A drag: deliberate, so no waiting.
|
||||
now() {
|
||||
if (pending !== null) { clearT(pending); pending = null; }
|
||||
open = true;
|
||||
return true;
|
||||
},
|
||||
get isOpen() { return open; },
|
||||
get isPending() { return pending !== null; },
|
||||
};
|
||||
}
|
||||
|
||||
// The closed body has no height to lay out against while it is shut. Telling the app to re-measure on the way open costs one resize handler and
|
||||
// avoids clips drawn at a stale width for the first frame after it appears.
|
||||
let reflowQueued = false;
|
||||
function markReflow(doc) {
|
||||
@@ -158,21 +201,25 @@
|
||||
if (typeof window !== "undefined" && window.addEventListener) {
|
||||
window.addEventListener("resize", onResize);
|
||||
}
|
||||
const paint = (open) => zone.classList.toggle(DRAG_CLASS, !!open);
|
||||
|
||||
const onEnter = (e) => { if (isDrag(e)) paint(track.enter()); };
|
||||
const onLeave = (e) => { if (isDrag(e)) paint(track.leave()); };
|
||||
const onEnd = () => paint(track.end());
|
||||
|
||||
// Hover is driven from script rather than :hover because the two halves of "open" are
|
||||
// different elements — the strip you point at and the timeline that replaces it — and a
|
||||
// CSS rule on either one alone flickers as the other takes the pointer.
|
||||
const intent = makeHoverIntent({
|
||||
onOpen: () => { zone.classList.add(OPEN_CLASS); markReflow(d); },
|
||||
onClose: () => zone.classList.remove(OPEN_CLASS),
|
||||
});
|
||||
const openIf = (e) => {
|
||||
const want = opensOnPointer(e.target, d);
|
||||
zone.classList.toggle(OPEN_CLASS, want);
|
||||
if (want) markReflow(d);
|
||||
if (opensOnPointer(e.target, d)) intent.point();
|
||||
else intent.away();
|
||||
};
|
||||
const closeAll = () => zone.classList.remove(OPEN_CLASS);
|
||||
const closeAll = () => intent.away();
|
||||
|
||||
const paint = (open) => zone.classList.toggle(DRAG_CLASS, !!open);
|
||||
const onEnter = (e) => { if (isDrag(e)) paint(track.enter()); };
|
||||
const onLeave = (e) => { if (isDrag(e)) paint(track.leave()); };
|
||||
// A drag ending also clears any hover state it overlapped with, so an open armed just
|
||||
// before the drag cannot fire after the drop and leave the timeline standing open.
|
||||
const onEnd = () => { paint(track.end()); intent.away(); };
|
||||
zone.addEventListener("mouseover", openIf);
|
||||
zone.addEventListener("mouseleave", closeAll);
|
||||
|
||||
@@ -200,5 +247,6 @@
|
||||
}
|
||||
|
||||
return { get, set, apply, install, measure, ensureStrip, opensOnPointer, isDrag,
|
||||
makeDragTracker, LS_KEY, ATTR, DRAG_CLASS, OPEN_CLASS, STRIP_ID, STRIP_LABEL };
|
||||
makeDragTracker, makeHoverIntent, LS_KEY, ATTR, DRAG_CLASS, OPEN_CLASS,
|
||||
STRIP_ID, STRIP_LABEL, OPEN_DELAY_MS };
|
||||
}));
|
||||
|
||||
@@ -83,3 +83,85 @@ test("the closed strip says what it is", () => {
|
||||
assert.match(P.STRIP_LABEL, /timeline/i);
|
||||
assert.match(P.STRIP_LABEL, /hover/i);
|
||||
});
|
||||
|
||||
// --- hover intent: crossing the strip on the way to the Dock is not a request -----
|
||||
|
||||
function fakeTimers() {
|
||||
let queue = [], id = 0;
|
||||
return {
|
||||
setTimer: (fn) => { queue.push({ id: ++id, fn }); return id; },
|
||||
clearTimer: (t) => { queue = queue.filter((q) => q.id !== t); },
|
||||
run: () => { const q = queue; queue = []; q.forEach((x) => x.fn()); },
|
||||
get pending() { return queue.length; },
|
||||
};
|
||||
}
|
||||
|
||||
test("passing over the strip and leaving never opens it", () => {
|
||||
const T = fakeTimers();
|
||||
let opened = 0;
|
||||
const hi = P.makeHoverIntent({ ...T, onOpen: () => opened++ });
|
||||
hi.point(); // pointer crosses the strip
|
||||
hi.away(); // ...and carries on to the Dock
|
||||
T.run(); // whatever was queued fires
|
||||
assert.equal(opened, 0);
|
||||
assert.equal(hi.isOpen, false);
|
||||
});
|
||||
|
||||
test("staying on it opens it once the delay elapses", () => {
|
||||
const T = fakeTimers();
|
||||
let opened = 0;
|
||||
const hi = P.makeHoverIntent({ ...T, onOpen: () => opened++ });
|
||||
hi.point();
|
||||
assert.equal(hi.isPending, true);
|
||||
T.run();
|
||||
assert.equal(opened, 1);
|
||||
assert.equal(hi.isOpen, true);
|
||||
});
|
||||
|
||||
test("moving around inside it does not re-arm the delay", () => {
|
||||
const T = fakeTimers();
|
||||
let opened = 0;
|
||||
const hi = P.makeHoverIntent({ ...T, onOpen: () => opened++ });
|
||||
hi.point(); T.run();
|
||||
hi.point(); hi.point(); // strip -> timeline -> a clip
|
||||
T.run();
|
||||
assert.equal(opened, 1);
|
||||
});
|
||||
|
||||
test("leaving closes what is open", () => {
|
||||
const T = fakeTimers();
|
||||
let closed = 0;
|
||||
const hi = P.makeHoverIntent({ ...T, onClose: () => closed++ });
|
||||
hi.point(); T.run();
|
||||
hi.away();
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(hi.isOpen, false);
|
||||
});
|
||||
|
||||
test("leaving when nothing is open closes nothing", () => {
|
||||
const T = fakeTimers();
|
||||
let closed = 0;
|
||||
const hi = P.makeHoverIntent({ ...T, onClose: () => closed++ });
|
||||
hi.away();
|
||||
assert.equal(closed, 0);
|
||||
});
|
||||
|
||||
test("a drag skips the delay", () => {
|
||||
// Dragging a file onto the timeline is deliberate; waiting for it would be wrong.
|
||||
const T = fakeTimers();
|
||||
const hi = P.makeHoverIntent(T);
|
||||
assert.equal(hi.now(), true);
|
||||
assert.equal(hi.isOpen, true);
|
||||
assert.equal(T.pending, 0);
|
||||
});
|
||||
|
||||
test("a drag cancels a pending hover rather than opening twice", () => {
|
||||
const T = fakeTimers();
|
||||
let opened = 0;
|
||||
const hi = P.makeHoverIntent({ ...T, onOpen: () => opened++ });
|
||||
hi.point();
|
||||
hi.now();
|
||||
T.run();
|
||||
assert.equal(opened, 0); // the hover open never fired; the drag already opened it
|
||||
assert.equal(hi.isOpen, true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user