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:
2026-05-09 21:54:05 +02:00
parent 4d2282f546
commit 82ca920b81

View File

@@ -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 {