Implemented PD-009 Timeline Navigation Discoverability.

This commit is contained in:
Nick Beckley 2026-06-19 20:53:07 +01:00
parent 0410afb124
commit c577235949
5 changed files with 221 additions and 52 deletions

View File

@ -345,7 +345,7 @@
<option value="@locationScene.SceneID">Location: @locationScene.PrimaryLocationName</option>
}
</select>
<span class="timeline-shortcut-hint">Shortcuts: arrows scroll, +/- zoom, Esc clears focus</span>
<span class="timeline-shortcut-hint">Shortcuts: Shift + scroll moves sideways, arrows scroll, +/- zoom, Esc clears focus</span>
</div>
</details>
@ -421,11 +421,6 @@
</section>
}
<section class="timeline-scroll-hint" data-timeline-scroll-hint hidden>
<span>Tip: Hold Shift while scrolling to move left and right through the timeline.</span>
<button type="button" class="btn btn-outline-secondary btn-sm" data-timeline-scroll-hint-dismiss>Dismiss</button>
</section>
<details class="timeline-drag-palette" data-drag-palette>
<summary>Drag Sources</summary>
<div class="drag-chip-group">
@ -602,6 +597,19 @@ else
{
<div class="workspace-layout timeline-filtered-root @(hasSelectedScene ? "inspector-drawer-open" : "inspector-drawer-closed")" data-timeline-root>
<main class="timeline-workspace">
<section class="timeline-navigation-card" data-timeline-navigation-card hidden>
<div class="timeline-navigation-card-icon" aria-hidden="true">&harr;</div>
<div class="timeline-navigation-card-copy">
<h2>Your timeline continues off-screen</h2>
<p>Use Jump to move straight to another chapter.</p>
<p>To browse nearby scenes sideways, hold Shift while using your mouse wheel.</p>
<p>On a trackpad, hold Shift and use a two-finger scroll gesture.</p>
</div>
<div class="timeline-navigation-card-actions">
<button type="button" class="btn btn-primary btn-sm" data-timeline-navigation-demo aria-label="Show timeline horizontal scrolling demonstration">Show Me</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-timeline-navigation-dismiss aria-label="Got it, do not show timeline navigation guidance again">Got It</button>
</div>
</section>
<section class="timeline-shell workspace-timeline" data-timeline-section="scene-cards" style="--scene-count:@timelineColumns.Count">
@if (Model.Settings.ShowSceneCards)
{

View File

@ -2437,22 +2437,98 @@ body.timeline-inspector-resizing {
background: rgba(47, 111, 99, 0.08);
}
.timeline-scroll-hint {
display: flex;
.timeline-navigation-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid rgba(47, 111, 99, 0.22);
border-radius: 10px;
margin-bottom: 16px;
padding: 10px 12px;
background: rgba(47, 111, 99, 0.08);
border: 1px solid rgba(47, 111, 99, 0.42);
border-left: 5px solid var(--plotline-accent);
border-radius: 8px;
margin: 0 0 12px;
padding: 14px 16px;
color: var(--plotline-ink);
background: linear-gradient(135deg, rgba(238, 245, 242, 0.98), rgba(255, 255, 255, 0.94));
box-shadow: 0 10px 26px rgba(30, 37, 43, 0.08);
}
.timeline-scroll-hint[hidden] {
.timeline-navigation-card[hidden] {
display: none;
}
.timeline-navigation-card-icon {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 50%;
color: #fff;
background: var(--plotline-accent);
font-size: 1.25rem;
font-weight: 900;
}
.timeline-navigation-card-copy h2 {
margin: 0 0 6px;
color: var(--plotline-accent-dark);
font-size: 1rem;
font-weight: 900;
}
.timeline-navigation-card-copy p {
margin: 2px 0;
color: var(--plotline-ink);
font-size: 0.9rem;
}
.timeline-navigation-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.timeline-horizontal-overflow {
transition: box-shadow 0.18s ease;
}
.timeline-horizontal-overflow.has-scroll-left {
box-shadow: inset 18px 0 18px -20px rgba(30, 37, 43, 0.55);
}
.timeline-horizontal-overflow.has-scroll-right {
box-shadow: inset -18px 0 18px -20px rgba(30, 37, 43, 0.55);
}
.timeline-horizontal-overflow.has-scroll-left.has-scroll-right {
box-shadow: inset 18px 0 18px -20px rgba(30, 37, 43, 0.55), inset -18px 0 18px -20px rgba(30, 37, 43, 0.55);
}
[data-theme="dark"] .timeline-navigation-card {
border-color: rgba(111, 169, 155, 0.52);
border-left-color: #6fa99b;
color: #f4eadc;
background: linear-gradient(135deg, rgba(34, 83, 74, 0.72), rgba(32, 31, 34, 0.96));
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
}
[data-theme="dark"] .timeline-navigation-card-copy h2,
[data-theme="dark"] .timeline-navigation-card-copy p {
color: #f4eadc;
}
[data-theme="dark"] .timeline-horizontal-overflow.has-scroll-left {
box-shadow: inset 18px 0 18px -20px rgba(244, 234, 220, 0.55);
}
[data-theme="dark"] .timeline-horizontal-overflow.has-scroll-right {
box-shadow: inset -18px 0 18px -20px rgba(244, 234, 220, 0.55);
}
[data-theme="dark"] .timeline-horizontal-overflow.has-scroll-left.has-scroll-right {
box-shadow: inset 18px 0 18px -20px rgba(244, 234, 220, 0.55), inset -18px 0 18px -20px rgba(244, 234, 220, 0.55);
}
.timeline-scene-card.filter-dim,
.timeline-scene-card.focus-dim,
.lane-dim {
@ -4041,6 +4117,18 @@ body.dragging-location [data-drag-type="location"] {
flex-direction: column;
}
.timeline-navigation-card {
grid-template-columns: 1fr;
}
.timeline-navigation-card-icon {
display: none;
}
.timeline-navigation-card-actions {
justify-content: flex-start;
}
.story-bible-search,
.story-bible-section-heading {
align-items: stretch;

File diff suppressed because one or more lines are too long

View File

@ -1205,8 +1205,9 @@
const densityKey = "plotline.timeline.density";
const zoomKey = "plotline.timeline.zoom";
const scrollKey = "plotline.timeline.pending-scroll";
const horizontalHintKey = "plotline.timeline.horizontalScrollHint.seen";
const navigationDiscoveryKey = "plotdirector.timelineNavigationDiscovered";
const drawerWidthKey = "plotline.timeline.inspectorWidth";
const horizontalOverflowTolerance = 8;
const scrollContainerSelector = [
".timeline-shell",
".plot-lane-board",
@ -1234,72 +1235,144 @@
const getScrollContainers = () => [...root.querySelectorAll(scrollContainerSelector)];
const setupHorizontalScrollHint = () => {
const hint = document.querySelector("[data-timeline-scroll-hint]");
if (!hint) {
const setupTimelineNavigationGuidance = () => {
const card = root.querySelector("[data-timeline-navigation-card]");
if (!card) {
return;
}
const dismissButton = hint.querySelector("[data-timeline-scroll-hint-dismiss]");
const readSeen = () => {
const dismissButton = card.querySelector("[data-timeline-navigation-dismiss]");
const demoButton = card.querySelector("[data-timeline-navigation-demo]");
const readDiscovered = () => {
try {
return localStorage.getItem(horizontalHintKey) === "true";
return localStorage.getItem(navigationDiscoveryKey) === "true";
} catch {
return false;
}
};
const markSeen = () => {
hint.hidden = true;
const primaryScrollContainer = () => root.querySelector(".timeline-shell");
const hasHorizontalOverflow = (container) => Boolean(container && container.scrollWidth > container.clientWidth + horizontalOverflowTolerance);
const maxScrollLeft = (container) => Math.max(0, container.scrollWidth - container.clientWidth);
const allScrollContainers = () => [workspace, ...getScrollContainers()].filter((container, index, containers) => (
container && containers.indexOf(container) === index
));
let dismissedForPage = false;
let isDemoing = false;
let isArmed = false;
const knownLeft = new WeakMap();
const rememberPosition = (container) => knownLeft.set(container, container.scrollLeft);
const updateEdgeIndicators = (container) => {
const hasOverflow = hasHorizontalOverflow(container);
const maxLeft = maxScrollLeft(container);
container.classList.toggle("timeline-horizontal-overflow", hasOverflow);
container.classList.toggle("has-scroll-left", hasOverflow && container.scrollLeft > horizontalOverflowTolerance);
container.classList.toggle("has-scroll-right", hasOverflow && container.scrollLeft < maxLeft - horizontalOverflowTolerance);
};
const updateGuidanceState = () => {
const shouldShow = hasHorizontalOverflow(primaryScrollContainer()) && !readDiscovered() && !dismissedForPage;
card.hidden = !shouldShow;
allScrollContainers().forEach(updateEdgeIndicators);
};
const markDiscovered = () => {
dismissedForPage = true;
card.hidden = true;
try {
localStorage.setItem(horizontalHintKey, "true");
localStorage.setItem(navigationDiscoveryKey, "true");
} catch {
// The hint can still disappear for this page view if storage is unavailable.
// The card can still disappear for this page view if storage is unavailable.
}
};
if (readSeen()) {
hint.hidden = true;
return;
}
hint.hidden = false;
dismissButton?.addEventListener("click", markSeen);
let isArmed = false;
window.setTimeout(() => {
isArmed = true;
containers.forEach(rememberPosition);
}, 800);
const knownLeft = new WeakMap();
const rememberPosition = (container) => knownLeft.set(container, container.scrollLeft);
const detectHorizontalScroll = (container) => {
if (!isArmed) {
updateEdgeIndicators(container);
if (!isArmed || isDemoing) {
rememberPosition(container);
return;
}
const previous = knownLeft.get(container) ?? container.scrollLeft;
if (Math.abs(container.scrollLeft - previous) > 4) {
markSeen();
if (Math.abs(container.scrollLeft - previous) > 4 && hasHorizontalOverflow(container)) {
markDiscovered();
return;
}
rememberPosition(container);
};
const containers = [workspace, ...getScrollContainers()];
const containers = allScrollContainers();
containers.forEach((container) => {
rememberPosition(container);
container.addEventListener("wheel", (event) => {
if (event.shiftKey && Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
if (event.defaultPrevented) {
return;
}
if (event.shiftKey && Math.abs(event.deltaY) > Math.abs(event.deltaX) && hasHorizontalOverflow(container)) {
const previous = container.scrollLeft;
event.preventDefault();
container.scrollLeft += event.deltaY;
markSeen();
window.requestAnimationFrame(() => {
updateEdgeIndicators(container);
if (!isDemoing && Math.abs(container.scrollLeft - previous) > 4) {
markDiscovered();
}
rememberPosition(container);
});
}
}, { passive: false });
container.addEventListener("scroll", () => detectHorizontalScroll(container), { passive: true });
});
dismissButton?.addEventListener("click", markDiscovered);
demoButton?.addEventListener("click", () => {
const container = primaryScrollContainer();
if (!hasHorizontalOverflow(container)) {
updateGuidanceState();
return;
}
const originalLeft = container.scrollLeft;
const availableRight = maxScrollLeft(container) - originalLeft;
let distance = Math.min(220, availableRight);
let direction = 1;
if (distance < 24 && originalLeft > 0) {
distance = Math.min(220, originalLeft);
direction = -1;
}
if (distance < 4) {
return;
}
isDemoing = true;
container.scrollTo({ left: originalLeft + (distance * direction), behavior: "smooth" });
window.setTimeout(() => {
container.scrollTo({ left: originalLeft, behavior: "smooth" });
}, 420);
window.setTimeout(() => {
isDemoing = false;
allScrollContainers().forEach((scrollContainer) => {
rememberPosition(scrollContainer);
updateEdgeIndicators(scrollContainer);
});
updateGuidanceState();
}, 950);
});
window.setTimeout(() => {
isArmed = true;
allScrollContainers().forEach(rememberPosition);
}, 800);
window.requestAnimationFrame(updateGuidanceState);
window.addEventListener("resize", updateGuidanceState);
window.addEventListener("plotline:timeline-layout-changed", updateGuidanceState);
densitySelect?.addEventListener("change", () => window.requestAnimationFrame(updateGuidanceState));
zoomSelect?.addEventListener("change", () => window.requestAnimationFrame(updateGuidanceState));
};
const drawerWidthBounds = () => {
@ -1324,7 +1397,7 @@
window.addEventListener("resize", () => applyDrawerWidth(localStorage.getItem(drawerWidthKey)));
}
setupHorizontalScrollHint();
setupTimelineNavigationGuidance();
inspectorResizer?.addEventListener("pointerdown", (event) => {
event.preventDefault();

File diff suppressed because one or more lines are too long