Timeline: move the current time to a layer's own data from its row
Motivation
The timeline moves by clock time, not by data. The scrubber lands wherever the pointer does, and the step buttons advance by a fixed calendar unit. For a layer holding data on a scattered handful of days, that makes landing on one of those days a pixel-hunt across a domain spanning years, and stepping there costs one press per intervening day.
A global "snap to data" setting can't fix it, because it has to pick whose data it means — and one timeline routinely carries sparse layers, continuously covered layers, and layers that ignore time entirely, all at once. Every global answer is wrong for some mission.
How it should work
Each layer row in the timeline sidebar carries its own set of controls — first, previous, next, last — that move the timeline's current time to that layer's data. The layer under the cursor is the scope, so there's no mode to set and nothing to configure.
- A sparse layer walks the days it holds data on. Previous and next step between them; first and last jump to the earliest and latest.
- A continuous layer steps across its coverage. For now that's by the timeline's current granularity — year, month, day or hour — and later by the layer's own declared periodicity. First and last go to the ends of its coverage.
- A layer that ignores time shows no controls, since it has nothing to navigate to.
Pressing back or forward on a layer whose data lies far from where you are now takes you to that data in one press, rather than stepping toward it. If a layer's data lies outside the timeline's window, using its controls widens the window to reach it.
The controls appear when a row is hovered or when they take keyboard focus, and are hidden otherwise, so a timeline with many layers stays legible and layer names keep their full width.
The scrubber itself is unchanged — it stays free, with no snapping.
Done when
Out of scope
- Scrubber snapping or magnetism of any kind — considered and deliberately dropped.
- Playback that walks a layer's data days rather than the global granularity.
- The layer periodicity configuration field itself, which lands separately. Until it exists, continuous layers step by the timeline's granularity.
Draft implementation plan — written as of 73b3d8c on 2026-09-08. Rough guide; re-verify against latest code.
Current behavior
The Timeline plugin lives in src/essence/Tools/Timeline/. TimelineView.tsx owns the scrubber (free drag, click-to-seek, arrow keys), and TimelineAdapter.tsx owns stepping and playback — both via stepTime(current, timeMode, ±1) in lib/utils/timeUtils.ts.
resolveLayerTimeRanges in that same file already reads a layer's time block into drawable spans: a continuous layer becomes one bar across its configured extent, and a layer listing time.dataDates becomes one whole-day box per listed day. That parsing — trimmed, strict ISO 8601, UTC-bounded — is what the navigation model should reuse.
The sidebar row is built in TimelineView.tsx from .layer-item, styled in Timeline.css, and is currently a colour dot plus a name.
Where the change lands & rough plan
- A new
lib/utils/layerNavigation.ts: resolveLayerNavigation(time) turning a layer's config into a small model (sparse stops, or a periodic extent with an optional cadence) and returning null for a layer with nothing to navigate; navigateLayer(nav, from, action, mode) answering where a button leads, or null to disable it. Keeping it out of timeUtils.ts keeps that file's job to formatting and stepping.
- A
LayerNavControls component under lib/geo/, rendered inside .layer-item, using the transport glyphs already in PlaybackControls.tsx. Reveal via .layer-item:hover and :focus-within, with opacity rather than display so the buttons stay in the tab order.
layerBarHeight in TimelineView.tsx grows from 15 to 20 to fit a button. It's the pitch of the sidebar row and the SVG row — they must stay equal or the columns desync — so LayerTimeline.tsx should derive its bar thickness from a constant instead of the hardcoded y + 4 / height - 6, keeping the chart from getting heavier.
TimelineAdapter.tsx resolves a navigation model per visible layer alongside resolveLayerTimeRanges, and commits a target through a handler separate from handleCurrentTimeChange — that one clamps to the window, and reaching a layer may mean widening it.
⚠️ Gotcha: the current time is not a point a layer is sampled at — TimeControl.updateLayersTime in src/essence/Basics/TimeControl_/TimeControl.js gives every time-enabled layer layer.time.end = TimeControl.currentTime, making it the trailing edge of the layer's query window. So navigation must land on the end of a sparse layer's day, not its start; landing at T00:00:00Z closes the window before any of that day's data falls inside it, and the button walks you onto a data day that shows nothing.
⚠️ Gotcha: a mission configured with relative start/end has its window recomputed from the current time by TimeControl.setTime, so an absolute widening won't stick there. The current time still lands on target and the rolling window follows it — worth knowing before debugging it as a bug.
References
- Design spec:
docs/superpowers/specs/2026-09-08-per-layer-timeline-navigation-design.md
- Task-by-task plan:
docs/superpowers/plans/2026-09-08-per-layer-timeline-navigation.md
- Existing sparse-layer parsing and its tests:
lib/utils/timeUtils.ts, __tests__/layerTimeRanges.spec.ts
Timeline: move the current time to a layer's own data from its row
Motivation
The timeline moves by clock time, not by data. The scrubber lands wherever the pointer does, and the step buttons advance by a fixed calendar unit. For a layer holding data on a scattered handful of days, that makes landing on one of those days a pixel-hunt across a domain spanning years, and stepping there costs one press per intervening day.
A global "snap to data" setting can't fix it, because it has to pick whose data it means — and one timeline routinely carries sparse layers, continuously covered layers, and layers that ignore time entirely, all at once. Every global answer is wrong for some mission.
How it should work
Each layer row in the timeline sidebar carries its own set of controls — first, previous, next, last — that move the timeline's current time to that layer's data. The layer under the cursor is the scope, so there's no mode to set and nothing to configure.
Pressing back or forward on a layer whose data lies far from where you are now takes you to that data in one press, rather than stepping toward it. If a layer's data lies outside the timeline's window, using its controls widens the window to reach it.
The controls appear when a row is hovered or when they take keyboard focus, and are hidden otherwise, so a timeline with many layers stays legible and layer names keep their full width.
The scrubber itself is unchanged — it stays free, with no snapping.
Done when
Out of scope
Draft implementation plan — written as of 73b3d8c on 2026-09-08. Rough guide; re-verify against latest code.
Current behavior
The Timeline plugin lives in
src/essence/Tools/Timeline/.TimelineView.tsxowns the scrubber (free drag, click-to-seek, arrow keys), andTimelineAdapter.tsxowns stepping and playback — both viastepTime(current, timeMode, ±1)inlib/utils/timeUtils.ts.resolveLayerTimeRangesin that same file already reads a layer'stimeblock into drawable spans: a continuous layer becomes one bar across its configured extent, and a layer listingtime.dataDatesbecomes one whole-day box per listed day. That parsing — trimmed, strict ISO 8601, UTC-bounded — is what the navigation model should reuse.The sidebar row is built in
TimelineView.tsxfrom.layer-item, styled inTimeline.css, and is currently a colour dot plus a name.Where the change lands & rough plan
lib/utils/layerNavigation.ts:resolveLayerNavigation(time)turning a layer's config into a small model (sparse stops, or a periodic extent with an optional cadence) and returningnullfor a layer with nothing to navigate;navigateLayer(nav, from, action, mode)answering where a button leads, ornullto disable it. Keeping it out oftimeUtils.tskeeps that file's job to formatting and stepping.LayerNavControlscomponent underlib/geo/, rendered inside.layer-item, using the transport glyphs already inPlaybackControls.tsx. Reveal via.layer-item:hoverand:focus-within, with opacity rather thandisplayso the buttons stay in the tab order.layerBarHeightinTimelineView.tsxgrows from 15 to 20 to fit a button. It's the pitch of the sidebar row and the SVG row — they must stay equal or the columns desync — soLayerTimeline.tsxshould derive its bar thickness from a constant instead of the hardcodedy + 4/height - 6, keeping the chart from getting heavier.TimelineAdapter.tsxresolves a navigation model per visible layer alongsideresolveLayerTimeRanges, and commits a target through a handler separate fromhandleCurrentTimeChange— that one clamps to the window, and reaching a layer may mean widening it.References
docs/superpowers/specs/2026-09-08-per-layer-timeline-navigation-design.mddocs/superpowers/plans/2026-09-08-per-layer-timeline-navigation.mdlib/utils/timeUtils.ts,__tests__/layerTimeRanges.spec.ts