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 {