fix(timetable): canvas layout — sticky-left + sticky-top freeze panes, single canvas scroll (B3)
Restructures the canvas so the spreadsheet-feel works correctly with
the seeder's 14 stages: horizontal scroll moves the rows AND the
TimeAxis together; vertical scroll moves the rows but keeps TimeAxis
pinned; both panes intersect at a fixed corner cell. Diagonal trackpad
scroll behaves naturally because there's only one scroll container.
DOM restructure (E2 — sticky resolves to its nearest scroll ancestor;
fixed by giving sticky elements the right scroll-container parent
instead of patching with absolute positioning):
.tt-page__canvas position: relative; overflow: auto
└ .tt-page__layout display: grid; grid-template-columns: 200px auto;
inline-size: max-content
├ .tt-page__corner sticky top:0 left:0 z=3
├ .tt-page__axis sticky top:0 z=2 (full 1872px wide, no clip)
└ for each stage:
├ .tt-page__header-cell sticky left:0 z=2
│ └ <StageHeaderCell :row-height-px="row.rowHeightPx">
└ .tt-page__row-cell normal z=1 (height = same value)
└ <StageRow>
Z-index ladder (E1) is documented in the page CSS:
corner=3, axis row=2, header rail=2, row content=1, blocks=auto.
Popover + AddPerformanceDialog stay above via Teleport-to-body.
Drops the broken pre-stabilization layout:
- `grid-template: "corner axis" 28px "stages rows" 1fr / 200px 1fr`
that put ALL stage headers in ONE grid cell (cause of "lanes too tall"
via headers stretching to 100% of the 570px cell)
- nested `overflow: auto` on `.tt-page__rows` (cause of horizontal-scroll
desync — only the rows pane scrolled, axis stayed put)
- `overflow: hidden` on `.tt-page__axis` (E4 — clipped axis ticks beyond
the 1fr cell width)
- `<GridBg :total-height="0" />` which was a no-op anyway; gridlines now
render directly on each `.tt-page__row-cell` background
`inline-size: max-content` on the layout grid forces it wider than the
canvas viewport, so `overflow: auto` on the canvas actually fires a
horizontal scrollbar. Without this, the `auto` second column shrinks to
viewport and nothing overflows.
The page now passes `:row-height-px` to StageHeaderCell (B2 seam, now
load-bearing). Both header and row cell get the same explicit blockSize
inline so the freeze panes align pixel-for-pixel under whatever
laneCount each stage resolves to.
Visual scroll/alignment proof is deferred to TEST-VISUAL-001 — jsdom
cannot verify position:sticky behavior, scrollbar visibility, or pixel
alignment of the freeze panes. This is a known limitation, not a test
gap. B4 covers the structural assertions jsdom CAN verify.
All 389 existing tests still pass; production build smoke clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@ import { computed, ref, watch } from 'vue'
|
||||
import EventTabsNav from '@/components/events/EventTabsNav.vue'
|
||||
import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue'
|
||||
import EmptyDayState from '@/components/timetable/EmptyDayState.vue'
|
||||
import GridBg from '@/components/timetable/GridBg.vue'
|
||||
import LineupMatrix from '@/components/timetable/LineupMatrix.vue'
|
||||
import PerformancePopover from '@/components/timetable/PerformancePopover.vue'
|
||||
import StageEditor from '@/components/timetable/StageEditor.vue'
|
||||
@@ -21,6 +20,7 @@ import { generateIdempotencyKey } from '@/lib/idempotencyKey'
|
||||
import { findB2BSides } from '@/lib/timetable/b2b'
|
||||
import { findConflicts } from '@/lib/timetable/conflict'
|
||||
import { resolveLanes } from '@/lib/timetable/lane'
|
||||
import { computeStageRowHeight } from '@/lib/timetable/row-height'
|
||||
import { SNAP_MIN, snap } from '@/lib/timetable/snap'
|
||||
import { isoToMinutes, minutesToIso, pxToMinutes } from '@/lib/timetable/time-grid'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
@@ -128,9 +128,19 @@ interface StageRowModel {
|
||||
stage: Stage
|
||||
performances: Performance[]
|
||||
laneCount: number
|
||||
/**
|
||||
* Pixel height of both the StageRow content cell and its sticky-left
|
||||
* StageHeaderCell (B3 — must match exactly for pixel-aligned freeze panes).
|
||||
*/
|
||||
rowHeightPx: number
|
||||
conflictCount: number
|
||||
}
|
||||
|
||||
// Match StageRow's internal lane geometry so the page-level helper call
|
||||
// produces the same pixel value the row applies to itself.
|
||||
const LANE_HEIGHT_PX = 44
|
||||
const LANE_PAD_PX = 4
|
||||
|
||||
const stageRows = computed<StageRowModel[]>(() => {
|
||||
const performances = tt.performances.value ?? []
|
||||
|
||||
@@ -150,8 +160,9 @@ const stageRows = computed<StageRowModel[]>(() => {
|
||||
const { laneCount } = resolveLanes(subjects)
|
||||
const conflictIds = findConflicts(onStage)
|
||||
const visiblePerfs = onStage.filter(p => store.isStatusVisible(p.engagement?.booking_status?.value))
|
||||
const rowHeightPx = computeStageRowHeight(laneCount, LANE_HEIGHT_PX, LANE_PAD_PX)
|
||||
|
||||
return { stage, performances: visiblePerfs, laneCount, conflictCount: conflictIds.size }
|
||||
return { stage, performances: visiblePerfs, laneCount, rowHeightPx, conflictCount: conflictIds.size }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -477,57 +488,79 @@ const { announce } = useTimetableKeyboard({
|
||||
v-else
|
||||
class="tt-page__body"
|
||||
>
|
||||
<!--
|
||||
Single canvas-level scroll container (B3 / E2). The freeze-pane
|
||||
illusion is built entirely from `position: sticky` children,
|
||||
scoped to this scroll ancestor:
|
||||
|
||||
corner cell sticky top:0 left:0 z=3 (intersection)
|
||||
TimeAxis row sticky top:0 z=2 (top pane)
|
||||
StageHeader cells sticky left:0 z=2 (left pane)
|
||||
StageRow content normal z=1
|
||||
PerformanceBlocks inside row (auto)
|
||||
|
||||
Popover + Dialog stay above this via their own stacking context
|
||||
(Teleport to body for the popover; VDialog teleports too).
|
||||
-->
|
||||
<div
|
||||
ref="canvasRoot"
|
||||
class="tt-page__canvas"
|
||||
role="application"
|
||||
aria-label="Timetable canvas"
|
||||
>
|
||||
<div class="tt-page__corner">
|
||||
Stages · {{ activeStages.length }}
|
||||
</div>
|
||||
<div class="tt-page__axis">
|
||||
<TimeAxis
|
||||
:grid-start-iso="gridStartIso"
|
||||
:total-minutes="totalMinutes"
|
||||
:px-per-min="pxPerMin"
|
||||
/>
|
||||
</div>
|
||||
<div class="tt-page__stages">
|
||||
<StageHeaderCell
|
||||
<div class="tt-page__layout">
|
||||
<!-- Header row: corner + time axis (axis spans entire content width). -->
|
||||
<div class="tt-page__corner">
|
||||
Stages · {{ activeStages.length }}
|
||||
</div>
|
||||
<div class="tt-page__axis">
|
||||
<TimeAxis
|
||||
:grid-start-iso="gridStartIso"
|
||||
:total-minutes="totalMinutes"
|
||||
:px-per-min="pxPerMin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- One grid row per stage: sticky header cell + scrolling row cell. -->
|
||||
<template
|
||||
v-for="row in stageRows"
|
||||
:key="row.stage.id"
|
||||
:stage="row.stage"
|
||||
:conflict-count="row.conflictCount"
|
||||
@edit="s => openStageEditor(s)"
|
||||
@delete="s => openStageEditor(s)"
|
||||
/>
|
||||
</div>
|
||||
<div class="tt-page__rows">
|
||||
<GridBg
|
||||
:total-minutes="totalMinutes"
|
||||
:px-per-min="pxPerMin"
|
||||
:total-height="0"
|
||||
/>
|
||||
<StageRow
|
||||
v-for="row in stageRows"
|
||||
:key="row.stage.id"
|
||||
:stage="row.stage"
|
||||
:performances="row.performances"
|
||||
:grid-start-iso="gridStartIso"
|
||||
:total-minutes="totalMinutes"
|
||||
:px-per-min="pxPerMin"
|
||||
:lane-count="row.laneCount"
|
||||
:b2b-left-set="b2bSides.leftSet"
|
||||
:b2b-right-set="b2bSides.rightSet"
|
||||
:pulse-set="pulseSet"
|
||||
:selected-id="store.selectedPerformanceId"
|
||||
:drag-origin-id="store.dragPerformanceId"
|
||||
@block-select="(p, r) => onBlockSelect(p, r)"
|
||||
@block-pointerdown="(e, p) => onBlockPointerdown(e, p)"
|
||||
@block-resize-pointerdown="(e, p) => onBlockPointerdown(e, p)"
|
||||
@block-delete="p => onBlockDelete(p)"
|
||||
/>
|
||||
>
|
||||
<div
|
||||
class="tt-page__header-cell"
|
||||
:style="{ blockSize: `${row.rowHeightPx}px` }"
|
||||
>
|
||||
<StageHeaderCell
|
||||
:stage="row.stage"
|
||||
:conflict-count="row.conflictCount"
|
||||
:row-height-px="row.rowHeightPx"
|
||||
@edit="s => openStageEditor(s)"
|
||||
@delete="s => openStageEditor(s)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="tt-page__row-cell"
|
||||
:style="{ blockSize: `${row.rowHeightPx}px` }"
|
||||
>
|
||||
<StageRow
|
||||
:stage="row.stage"
|
||||
:performances="row.performances"
|
||||
:grid-start-iso="gridStartIso"
|
||||
:total-minutes="totalMinutes"
|
||||
:px-per-min="pxPerMin"
|
||||
:lane-count="row.laneCount"
|
||||
:b2b-left-set="b2bSides.leftSet"
|
||||
:b2b-right-set="b2bSides.rightSet"
|
||||
:pulse-set="pulseSet"
|
||||
:selected-id="store.selectedPerformanceId"
|
||||
:drag-origin-id="store.dragPerformanceId"
|
||||
@block-select="(p, r) => onBlockSelect(p, r)"
|
||||
@block-pointerdown="(e, p) => onBlockPointerdown(e, p)"
|
||||
@block-resize-pointerdown="(e, p) => onBlockPointerdown(e, p)"
|
||||
@block-delete="p => onBlockDelete(p)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="tt-page__sr-only"
|
||||
@@ -604,15 +637,54 @@ const { announce } = useTimetableKeyboard({
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* B3 — Single canvas-level scroll container. Sticky-positioned children
|
||||
* resolve relative to THIS scroll ancestor (E2). Drop the pre-stabilization
|
||||
* `overflow: hidden` on the axis and `overflow: auto` on the rows pane —
|
||||
* one scroll, period.
|
||||
*/
|
||||
&__canvas {
|
||||
display: grid;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
grid-template: "corner axis" 28px "stages rows" 1fr / 200px 1fr;
|
||||
min-inline-size: 0;
|
||||
overflow: auto;
|
||||
background-color: var(--tt-canvas-bg);
|
||||
}
|
||||
|
||||
/*
|
||||
* The grid is a single 2-column track ("rail" + "rows"). Auto-rows lets
|
||||
* each stage's header cell and row cell get the same height (both sized
|
||||
* via the same computeStageRowHeight() value, applied as inline blockSize
|
||||
* on the cell <div>s in the template).
|
||||
*
|
||||
* `inline-size: max-content` forces the layout wider than the canvas so
|
||||
* the canvas's overflow:auto actually triggers a horizontal scrollbar.
|
||||
* Without this, `auto` columns would shrink to fit the viewport.
|
||||
*/
|
||||
&__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px auto;
|
||||
inline-size: max-content;
|
||||
}
|
||||
|
||||
/*
|
||||
* Z-index ladder for the sticky freeze panes (E1):
|
||||
* tt-page__corner sticky top:0 left:0 → z=3 (intersection of both panes)
|
||||
* tt-page__axis sticky top:0 → z=2 (top pane / TimeAxis)
|
||||
* tt-page__header-cell sticky left:0 → z=2 (left pane / StageHeaders)
|
||||
* tt-page__row-cell normal → z=1 (scrolling content)
|
||||
* PerformanceBlocks inside row → auto
|
||||
*
|
||||
* Popover + AddPerformanceDialog stay above this via Teleport-to-body
|
||||
* (own stacking context far above the canvas).
|
||||
*/
|
||||
|
||||
&__corner {
|
||||
grid-area: corner;
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 3;
|
||||
block-size: 28px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 12px;
|
||||
font-size: 11px;
|
||||
@@ -622,19 +694,43 @@ const { announce } = useTimetableKeyboard({
|
||||
}
|
||||
|
||||
&__axis {
|
||||
grid-area: axis;
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
z-index: 2;
|
||||
block-size: 28px;
|
||||
background-color: var(--tt-canvas-bg);
|
||||
/*
|
||||
* E4 — TimeAxis renders at full canvas width (totalMinutes × pxPerMin).
|
||||
* The pre-stabilization `overflow: hidden` clipped axis ticks beyond
|
||||
* the cell's 1fr width and broke horizontal sync with the rows pane.
|
||||
*/
|
||||
}
|
||||
|
||||
&__stages {
|
||||
grid-area: stages;
|
||||
&__header-cell {
|
||||
position: sticky;
|
||||
inset-inline-start: 0;
|
||||
z-index: 2;
|
||||
background-color: #fff;
|
||||
/*
|
||||
* blockSize is set inline from row.rowHeightPx so each header pairs
|
||||
* with its corresponding row cell pixel-for-pixel.
|
||||
*/
|
||||
}
|
||||
|
||||
&__rows {
|
||||
grid-area: rows;
|
||||
&__row-cell {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
z-index: 1;
|
||||
/*
|
||||
* blockSize is set inline from row.rowHeightPx (same source as the
|
||||
* header). Background gradient draws the half-hour and hour gridlines
|
||||
* — replaces the old free-floating GridBg component which was being
|
||||
* rendered with totalHeight=0 anyway.
|
||||
*/
|
||||
/* Gridline pitch: pxPerMin (2.4) × 60 = 144px majors, × 30 = 72px minors.
|
||||
* Hardcoded because pxPerMin is constant in v1 (zoom is deferred). */
|
||||
background-image:
|
||||
repeating-linear-gradient(to right, var(--tt-canvas-grid-major) 0 1px, transparent 1px 144px),
|
||||
repeating-linear-gradient(to right, var(--tt-canvas-grid-minor) 0 1px, transparent 1px 72px);
|
||||
}
|
||||
|
||||
&__sr-only {
|
||||
|
||||
Reference in New Issue
Block a user