From 82ca920b81f6e3f9c5b0ef09b9a5a0b669019717 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 21:54:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(timetable):=20canvas=20layout=20=E2=80=94?= =?UTF-8?q?=20sticky-left=20+=20sticky-top=20freeze=20panes,=20single=20ca?= =?UTF-8?q?nvas=20scroll=20(B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 │ └ └ .tt-page__row-cell normal z=1 (height = same value) └ 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) - `` 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) --- .../src/pages/events/[id]/timetable/index.vue | 206 +++++++++++++----- 1 file changed, 151 insertions(+), 55 deletions(-) diff --git a/apps/app/src/pages/events/[id]/timetable/index.vue b/apps/app/src/pages/events/[id]/timetable/index.vue index ed0a1515..246245c5 100644 --- a/apps/app/src/pages/events/[id]/timetable/index.vue +++ b/apps/app/src/pages/events/[id]/timetable/index.vue @@ -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(() => { const performances = tt.performances.value ?? [] @@ -150,8 +160,9 @@ const stageRows = computed(() => { 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" > +
-
- Stages · {{ activeStages.length }} -
-
- -
-
- + +
+ Stages · {{ activeStages.length }} +
+
+ +
+ + +
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 {