Start a project

Patterns

Patterns

The compositions. Components are half a system — these are the arrangements that recur in every dashboard.

KpiRow

The top of nearly every dashboard. Four figures at most, ranked left to right by how often they are the reason someone opened the screen. Each carries a comparison — delta, previous or target — passed straight to AdsStat.

URLs1,284up+37
Health98target 100
Median74msprev 84ms
Issues23down−44 critical
vue
<KpiRow :stats="[
  { value: '1,284', label: 'URLs', delta: '+37', direction: 'up' },
  { value: '98', label: 'Health', target: 100 },
  { value: '74', label: 'Median', unit: 'ms', previous: 84 },
  { value: '23', label: 'Issues', delta: '−4', direction: 'down', note: '4 critical' },
]" />
KpiRow props
PropTypeDefaultDescription
stats{ value, label, unit?, delta?, direction?, previous?, target?, note? }[]—Four at most; more warns in development.
columns2 | 3 | 44
Source · ads/app/components/KpiRow.vue
vue
<script setup lang="ts">
// The KPI row — the top of nearly every dashboard. Four figures at most, ranked
// left to right by how often they are the reason someone opened the screen.
// Each tile carries its comparison (a delta, the previous period or a target)
// because a figure with nothing to compare it to is trivia.
export type StatItem = {
  value: string | number
  label: string
  unit?: string
  note?: string
  delta?: string
  direction?: 'up' | 'down' | 'flat'
  previous?: string | number
  target?: string | number
}

const props = withDefaults(defineProps<{ stats: StatItem[]; columns?: 2 | 3 | 4 }>(), { columns: 4 })

if (false && props.stats.length > 4) {
  console.warn(`[KpiRow] ${props.stats.length} figures: four at most, or none of them is the headline.`)
}
</script>

<template>
  <div class="ads-kpi-row" :class="`is-cols-${columns}`">
    <AdsStat v-for="s in stats" :key="s.label" v-bind="s" />
  </div>
</template>

<style scoped>
.ads-kpi-row {
  display: grid;
  gap: var(--gutter);
}

@media (min-width: 760px) {
  .ads-kpi-row.is-cols-2 { grid-template-columns: repeat(2, 1fr); }
  .ads-kpi-row.is-cols-3 { grid-template-columns: repeat(3, 1fr); }
  .ads-kpi-row.is-cols-4 { grid-template-columns: repeat(4, 1fr); }
}
</style>

FilterBar

One row, above the data, holding every control that changes what is shown. Saved views are tags, and tags are removable filters. Every control is named; the placeholder is an example, never the label.

30d to now
SavedExec summary Broken links
vue
<FilterBar
  v-model:query="query"
  v-model:segment="segment"
  search-label="Filter by URL"
  segments-label="Status class"
  :tags="tags"
  :segments="['All', '2xx', '4xx']"
  @remove-tag="(t) => tags.splice(tags.indexOf(t), 1)"
  @save="saveView" />
FilterBar props
PropTypeDefaultDescription
querystring (v-model)''
placeholderstringFilter by URL…
searchLabelstringFilter by URLAccessible name of the search field.
tagsstring[][]
segmentsstring[]All / 2xx / 4xx
segmentstring (v-model)All
segmentsLabelstringStatus
from / tostring30d / nowRelative presets, or ISO dates: 2026-10-27.
@remove-tag / @saveevent—
Source · ads/app/components/FilterBar.vue
vue
<script setup lang="ts">
// One row, above the data, holding every control that changes what is shown.
// Saved views turn a repeated set of filters into a named thing a team can
// share. Serialise the whole bar into the URL, so a view can be bookmarked.
//
// Every control is named: the search field by `searchLabel` (its placeholder
// is an example, never the label), the segmented control by `segmentsLabel`.
// Each saved view is a tag, and a tag is removable — that is what a tag means.
withDefaults(
  defineProps<{
    placeholder?: string
    searchLabel?: string
    tags?: string[]
    segments?: string[]
    segment?: string
    segmentsLabel?: string
    from?: string
    to?: string
  }>(),
  {
    placeholder: 'Filter by URL…',
    searchLabel: 'Filter by URL',
    tags: () => [],
    segments: () => ['All', '2xx', '4xx'],
    segment: 'All',
    segmentsLabel: 'Status',
    from: '30d',
    to: 'now',
  },
)

const query = defineModel<string>('query', { default: '' })

const emit = defineEmits<{ (e: 'update:segment', v: string): void; (e: 'remove-tag', v: string): void; (e: 'save'): void }>()
</script>

<template>
  <div class="ads-filter-bar stack gap-3">
    <div class="ads-filter-bar__controls">
      <div class="ads-search">
        <Icon name="compass" :size="16" />
        <input v-model="query" class="ads-field" type="search" :placeholder="placeholder" :aria-label="searchLabel" />
      </div>
      <div class="ads-daterange">
        <span class="num">{{ from }}</span><span class="sep" aria-hidden="true">→</span><span class="sr-only"> to </span><span class="num">{{ to }}</span>
      </div>
      <div class="ads-segmented" role="group" :aria-label="segmentsLabel">
        <button
          v-for="s in segments"
          :key="s"
          type="button"
          :class="{ 'is-on': segment === s }"
          :aria-pressed="segment === s"
          @click="emit('update:segment', s)"
        >{{ s }}</button>
      </div>
    </div>

    <div class="ads-filter-bar__saved">
      <span class="ads-label">Saved</span>
      <AdsTag v-for="t in tags" :key="t" :label="t" @remove="emit('remove-tag', t)" />
      <button class="btn btn-quiet" type="button" @click="emit('save')">
        <Icon name="plus" :size="15" />Save this view
      </button>
    </div>
  </div>
</template>

<style scoped>
.ads-filter-bar__controls {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  align-items: center;
}

.ads-filter-bar__controls .ads-search {
  flex: 1;
  min-width: 200px;
}

.ads-filter-bar__saved {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2);
}
</style>

TableScreen

Header, tabs, filter bar, grid, pagination. The tabs filter the one table below them; the arrow keys move between tabs. The header action is the screen's single accent. A selected row is forwarded as @select — open it in a drawer.

Results

4 rows
Results
/overview200182 low
/pricing4044 critical
/docs/installing3011 medium
/blog/launch200246 low
vue
<TableScreen
  v-model:tab="tab" v-model:query="query"
  title="Results" count="733 rows" action-label="Export CSV"
  :tabs="['All', 'Redirects', 'Errors']"
  :columns="columns" :rows="filtered"
  @select="openInDrawer">
  <template #footer>
    <DashEmpty v-if="!filtered.length" status="no-results" title="No rows match" />
  </template>
</TableScreen>
TableScreen props
PropTypeDefaultDescription
titlestring—
countstring
actionLabelstringExport CSVEmpty hides it — when the page spends its accent elsewhere.
tabsstring[][]
tabstring (v-model)''
querystring (v-model)''
placeholder / searchLabelstringFilter by URL…
columns / rows——Passed to AdsDataGrid.
@select(row, index) => void—
Source · ads/app/components/TableScreen.vue
vue
<script setup lang="ts">
// The arrangement that carries most of the working hours in any data product:
// header, tabs, filter bar, grid, pagination.
//
// The tabs filter the one table below them — they never swap in a different
// screen — so they are a real tablist whose panel is the grid, with the arrow
// keys moving between tabs. The header's action is the screen's single accent;
// pass an empty `actionLabel` when the page already spends it elsewhere. A
// selected row is forwarded as `select`, for the drawer beside it.
import type { GridColumn, Row } from './AdsDataGrid.vue'

export type { GridColumn }
export type GridRow = Row

const props = withDefaults(
  defineProps<{
    title: string
    count?: string
    actionLabel?: string
    tabs?: string[]
    tab?: string
    columns: GridColumn[]
    rows: GridRow[]
    placeholder?: string
    searchLabel?: string
  }>(),
  { count: '', actionLabel: 'Export CSV', tabs: () => [], tab: '', placeholder: 'Filter by URL…', searchLabel: 'Filter by URL' },
)

const query = defineModel<string>('query', { default: '' })

const emit = defineEmits<{
  (e: 'update:tab', v: string): void
  (e: 'action'): void
  (e: 'select', row: GridRow, index: number): void
}>()

const uid = `ads-table-screen-${useId().replace(/[^\w-]/g, '-')}`
const tabIds = computed(() => props.tabs.map((_, i) => `${uid}-tab-${i}`))
const tablist = ref<HTMLElement | null>(null)

// Roving focus: only the selected tab is in the tab order; the arrows move
// between tabs and select as they go, as a tablist is expected to.
function onTabKey(e: KeyboardEvent) {
  const n = props.tabs.length
  const at = Math.max(0, props.tabs.indexOf(props.tab))
  let next = -1
  if (e.key === 'ArrowRight') next = (at + 1) % n
  else if (e.key === 'ArrowLeft') next = (at - 1 + n) % n
  else if (e.key === 'Home') next = 0
  else if (e.key === 'End') next = n - 1
  if (next < 0) return
  e.preventDefault()
  emit('update:tab', props.tabs[next]!)
  tablist.value?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus()
}
</script>

<template>
  <div class="card ads-table-screen">
    <header class="ads-table-screen__head">
      <div class="ads-table-screen__heading">
        <h4 class="ads-table-screen__title ads-display">{{ title }}</h4>
        <span v-if="count" class="dim num">{{ count }}</span>
      </div>
      <button v-if="actionLabel" class="btn btn-accent" type="button" @click="emit('action')">{{ actionLabel }}</button>
    </header>

    <div v-if="tabs.length" ref="tablist" class="ads-tabs" role="tablist" :aria-label="title" @keydown="onTabKey">
      <button
        v-for="(t, i) in tabs"
        :id="tabIds[i]"
        :key="t"
        type="button"
        role="tab"
        :aria-selected="tab === t"
        :aria-controls="`${uid}-panel`"
        :tabindex="tab === t || (!tabs.includes(tab) && i === 0) ? 0 : -1"
        :class="{ 'is-on': tab === t }"
        @click="emit('update:tab', t)"
      >{{ t }}</button>
    </div>

    <div
      :id="`${uid}-panel`"
      class="ads-table-screen__panel"
      :role="tabs.length ? 'tabpanel' : undefined"
      :aria-labelledby="tabs.length ? tabIds[Math.max(0, tabs.indexOf(tab))] : undefined"
    >
      <div class="ads-table-screen__controls">
        <div class="ads-search">
          <Icon name="compass" :size="16" />
          <input v-model="query" class="ads-field" type="search" :placeholder="placeholder" :aria-label="searchLabel" />
        </div>
        <slot name="filters" />
      </div>

      <AdsDataGrid :columns="columns" :rows="rows" :caption="title" @select="(r, i) => emit('select', r, i)" />
    </div>

    <footer class="ads-table-screen__foot">
      <slot name="footer" />
    </footer>
  </div>
</template>

<style scoped>
.ads-table-screen {
  display: grid;
  gap: var(--sp-4);
  padding: 22px;
}

.ads-table-screen__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  flex-wrap: wrap;
}

.ads-table-screen__heading {
  display: grid;
  gap: 2px;
}

.ads-table-screen__title {
  font-size: 1.15rem;
  font-weight: 600;
}

.ads-table-screen__panel {
  display: grid;
  gap: var(--sp-4);
}

.ads-table-screen__controls {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  align-items: center;
}

.ads-table-screen__controls .ads-search {
  flex: 1;
  min-width: 200px;
}

.ads-table-screen__foot {
  display: flex;
  justify-content: flex-end;
}
</style>

DashEmpty

Why a panel is empty, in plain words, with the fix — and the action that applies it. “Never had data” and “no results” are different situations and say so. The state is carried by glyph and words, not colour.

No data yet: No crawls yet

Nothing to fix. The panel fills in once the data exists.

No results: No pages match these filters

Clear a filter to widen the search.

No access: Search Console is not connected

The service account has no access to this property.

Fix the access grant and this panel fills in on its own.

vue
<DashEmpty status="empty" title="No crawls yet" action-label="Start a crawl" @action="start" />
<DashEmpty status="no-results" title="No pages match these filters" action-label="Clear filters" @action="clear" />
<DashEmpty status="forbidden" title="Search Console is not connected" reason="The service account has no access to this property." />
DashEmpty props
PropTypeDefaultDescription
titlestring—
status'empty' | 'no-results' | 'error' | 'forbidden' | 'unconfigured''empty'
reasonstring
fixstringper status
actionLabelstringRenders a button that emits @action.
Source · ads/app/components/DashEmpty.vue
vue
<script setup lang="ts">
// Explains why a panel is empty, in plain words, with the fix when there is one
// — and the action that applies it, so the empty state is a way forward rather
// than a dead end.
//
// The status is a closed set because the causes are: a panel that has never
// had data ("empty") is a different situation from one whose filters exclude
// everything ("no-results"), and both differ from a failure. The tone is
// carried three ways — the glyph, the words, and the mark's colour — so it
// survives without colour. No stripe, no brand hue: the panel is chrome, and
// chrome is grey; the only colour is the warning state on the mark.
export type DashEmptyStatus = 'empty' | 'no-results' | 'error' | 'forbidden' | 'unconfigured'

const props = withDefaults(
  defineProps<{ title: string; reason?: string; status?: DashEmptyStatus; fix?: string; actionLabel?: string }>(),
  { reason: '', status: 'empty', fix: '', actionLabel: '' },
)

const emit = defineEmits<{ action: [] }>()

const tone = computed(() =>
  props.status === 'forbidden' || props.status === 'error' ? 'warn' : props.status === 'unconfigured' ? 'info' : 'quiet',
)
const glyph = computed(
  () => ({ forbidden: '!', error: '!', unconfigured: '?', 'no-results': '∅', empty: '—' } as const)[props.status],
)
const spoken = computed(
  () =>
    ({ forbidden: 'No access', error: 'Error', unconfigured: 'Not set up', 'no-results': 'No results', empty: 'No data yet' } as const)[
      props.status
    ],
)
const fixText = computed(
  () =>
    props.fix ||
    {
      forbidden: 'Fix the access grant and this panel fills in on its own.',
      unconfigured: 'Add the missing environment variable and redeploy.',
      'no-results': 'Clear a filter to widen the search.',
      empty: 'Nothing to fix. The panel fills in once the data exists.',
      error: '',
    }[props.status],
)
</script>

<template>
  <div class="ads-dash-empty" :class="`is-${tone}`">
    <span class="ads-dash-empty__mark" aria-hidden="true">{{ glyph }}</span>
    <div class="ads-dash-empty__copy">
      <p class="ads-dash-empty__title"><span class="sr-only">{{ spoken }}: </span>{{ title }}</p>
      <p v-if="reason" class="ads-dash-empty__reason">{{ reason }}</p>
      <p v-if="fixText" class="ads-dash-empty__fix">{{ fixText }}</p>
      <button v-if="actionLabel" type="button" class="btn btn-sm ads-dash-empty__action" @click="emit('action')">{{ actionLabel }}</button>
      <slot />
    </div>
  </div>
</template>

<style scoped>
.ads-dash-empty {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  padding: 18px;
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-dash-empty__mark {
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  flex: none;
  border-radius: 50%;
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--subdued);
}

.ads-dash-empty.is-warn .ads-dash-empty__mark { color: var(--warn); }
.ads-dash-empty.is-info .ads-dash-empty__mark { color: var(--text); }

.ads-dash-empty__title { font-size: 0.9rem; font-weight: 500; color: var(--text); margin: 0 0 var(--sp-1); }
.ads-dash-empty__reason { font-size: 0.82rem; color: var(--subdued); margin: 0; max-width: 62ch; }
.ads-dash-empty__fix { font-size: 0.78rem; color: var(--text-soft); margin: var(--sp-2) 0 0; }
.ads-dash-empty__action { margin-top: var(--sp-3); }
</style>

SummaryDetail

The pattern the whole system is arranged around: a ranked answer at the top, the reasoning one level down, the raw rows two levels down.

  1. Answer. One ranked list. "Fix the alt text on the blog template — 823 pages, two changes."

  2. Reasoning. An accordion or drawer: why this ranks first, what the score is made of.

  3. Evidence. The grid, filtered to the 823 rows, with the filter visible as a tag.

vue
<SummaryDetail :rungs="[
  { title: 'Answer.', body: 'One ranked list.' },
  { title: 'Reasoning.', body: 'Why this ranks first.' },
  { title: 'Evidence.', body: 'The grid, filtered, with the filter visible as a tag.' },
]" />
SummaryDetail props
PropTypeDefaultDescription
rungs{ title, body }[]Answer / Reasoning / Evidence
Source · ads/app/components/SummaryDetail.vue
vue
<script setup lang="ts">
// Summary → detail: the pattern the whole system is arranged around. A ranked
// answer at the top, the reasoning one level down, the raw rows two levels
// down — and the reader never loses their place.
export type Rung = { title: string; body: string }

withDefaults(
  defineProps<{ rungs?: Rung[] }>(),
  {
    rungs: () => [
      { title: 'Answer.', body: 'One ranked list. "Fix the alt text on the blog template — 823 pages, two changes."' },
      { title: 'Reasoning.', body: 'An accordion or drawer: why this ranks first, what the score is made of.' },
      { title: 'Evidence.', body: 'The grid, filtered to the 823 rows, with the filter visible as a tag.' },
    ],
  },
)
</script>

<template>
  <ol class="ads-summary-detail">
    <li v-for="(r, i) in rungs" :key="i" class="ads-summary-detail__rung">
      <span class="num ads-summary-detail__step" aria-hidden="true">{{ i + 1 }}</span>
      <p class="ads-summary-detail__text"><strong>{{ r.title }}</strong> {{ r.body }}</p>
    </li>
  </ol>
</template>

<style scoped>
.ads-summary-detail {
  display: grid;
  gap: var(--sp-3);
  list-style: none;
  margin: 0;
  padding: 0;
}

.ads-summary-detail__rung {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  padding: var(--sp-4) 18px;
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-summary-detail__step {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  flex: none;
  border-radius: 50%;
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  font-size: 0.72rem;
  color: var(--text);
}

.ads-summary-detail__text {
  font-size: 0.9rem;
  color: var(--text-soft);
  margin: 0;
}

.ads-summary-detail__text strong {
  color: var(--text);
  font-weight: 600;
}
</style>

GanttChart

The engagement plan from the studio site: a length control that rescales the plan, and a marker you drag (or move with the arrow keys) to ask what is happening in a given week.

Typical engagement

2 months, start to handover

PhaseWeeks
12345678
Discovery & read
Information design
Flutter
Flutter
Rust
iOS
Testing & access
Handover
wk 4

Week 4Design systemInterface buildEngine & data

vue
<GanttChart />
Source · ads/app/components/GanttChart.vue
vue
<script setup lang="ts">
// The engagement, as a Gantt chart.
//
// Two things make it worth building rather than drawing: the length control
// rescales the whole plan, because a four-week job and a twelve-week job are
// the same shape at different sizes; and the marker can be dragged across the
// timeline to ask "what is happening in week six?" — which is the only
// question anyone actually brings to a Gantt chart.
//
// Phase extents are stored as fractions of the engagement rather than dates,
// so one set of numbers serves every duration and the bars are positioned in
// percentages that need no measuring.

type Phase = {
  name: string
  tech?: string
  from: number
  to: number
  detail: string
}

const phases: Phase[] = [
  {
    name: 'Discovery & read',
    from: 0,
    to: 0.12,
    detail: 'We use the product the way your users do and write down every question the screen implies but does not answer.',
  },
  {
    name: 'Information design',
    from: 0.08,
    to: 0.3,
    detail: 'The decisions the interface exists to support, ranked. Mostly argument rather than pixels, and where most of the value is.',
  },
  {
    name: 'Design system',
    tech: 'Flutter',
    from: 0.22,
    to: 0.5,
    detail: 'Tokens, surfaces and type as a themed library — light, dark and density modes from one source.',
  },
  {
    name: 'Interface build',
    tech: 'Flutter',
    from: 0.38,
    to: 0.84,
    detail: 'Screens assembled from the system and reviewed in a branch. Design and front-end code happen together, never as a handover.',
  },
  {
    name: 'Engine & data',
    tech: 'Rust',
    from: 0.3,
    to: 0.72,
    detail: 'The work behind the chart: queries, aggregation and whatever has to stay fast when the dataset stops being a demo.',
  },
  {
    name: 'Platform build',
    tech: 'iOS',
    from: 0.58,
    to: 0.9,
    detail: 'Native shell, platform conventions and the parts of the interface that should not be a web view.',
  },
  {
    name: 'Testing & access',
    from: 0.62,
    to: 0.96,
    detail: 'Keyboard paths, contrast, reduced motion and screen readers — checked against the build rather than promised in a deck.',
  },
  {
    name: 'Handover',
    from: 0.88,
    to: 1,
    detail: 'Tokens, components and the reasoning behind them, written down so the next person does not have to guess.',
  },
]

// from-index → to-index. Drawn as elbows, the way a real Gantt shows that one
// piece of work cannot begin until another has produced something.
const links: [number, number][] = [
  [0, 1],
  [1, 2],
  [2, 3],
  [1, 4],
  [4, 5],
  [3, 6],
  [6, 7],
]

const durations = [
  { weeks: 4, label: '1 month' },
  { weeks: 8, label: '2 months' },
  { weeks: 12, label: '3 months' },
]

const weeks = ref(8)
// Where the scrub marker sits, as a fraction of the engagement.
const at = ref(0.42)
const selected = ref<number | null>(null)
const track = ref<HTMLElement>()

const ROW = 46 // row height in px; the link elbows do their y arithmetic on it

const weekCols = computed(() => Array.from({ length: weeks.value }, (_, i) => i + 1))
const currentWeek = computed(() => Math.max(1, Math.ceil(at.value * weeks.value)))

const active = computed(() =>
  phases.filter((p) => at.value >= p.from && at.value <= p.to).map((p) => p.name),
)

function weekRange(p: Phase) {
  const first = Math.max(1, Math.ceil(p.from * weeks.value) || 1)
  const last = Math.max(first, Math.ceil(p.to * weeks.value))
  return first === last ? `wk ${first}` : `wk ${first}–${last}`
}

const pct = (n: number) => `${(n * 100).toFixed(3)}%`

// ─── The marker ──────────────────────────────────────────────────────────

function fractionFrom(clientX: number) {
  const rect = track.value!.getBoundingClientRect()
  return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
}

function onPointerDown(e: PointerEvent) {
  ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
  at.value = fractionFrom(e.clientX)
}

function onPointerMove(e: PointerEvent) {
  // buttons is 0 unless a button is held, which is what keeps this from
  // following the pointer around after the drag has finished.
  if (e.buttons === 0) return
  at.value = fractionFrom(e.clientX)
}

function onKey(e: KeyboardEvent) {
  const step = 1 / (weeks.value * 2)
  if (e.key === 'ArrowRight') at.value = Math.min(1, at.value + step)
  else if (e.key === 'ArrowLeft') at.value = Math.max(0, at.value - step)
  else if (e.key === 'Home') at.value = 0
  else if (e.key === 'End') at.value = 1
  else return
  e.preventDefault()
}

// ─── Links ───────────────────────────────────────────────────────────────

// Each elbow is two absolutely-positioned rules rather than an SVG overlay,
// because the x coordinates are percentages of a flexible track and the y
// coordinates are multiples of a fixed row height. Mixing those units inside
// one SVG viewBox means measuring the track on every resize; in CSS it costs
// nothing.
const elbows = computed(() =>
  links.map(([a, b]) => {
    const from = phases[a]!
    const to = phases[b]!
    const top = Math.min(a, b) * ROW + ROW / 2
    const height = Math.abs(b - a) * ROW
    return {
      key: `${a}-${b}`,
      x: pct(from.to),
      top,
      height,
      armTop: b * ROW + ROW / 2,
      armWidth: pct(Math.max(0, to.from - from.to)),
    }
  }),
)
</script>

<template>
  <div class="ads-gantt-chart card">
    <header class="ads-gantt-chart__head">
      <div>
        <span class="eyebrow">Typical engagement</span>
        <h3 class="h3">{{ durations.find((d) => d.weeks === weeks)?.label }}, start to handover</h3>
      </div>
      <div class="ads-segmented" role="group" aria-label="Engagement length">
        <button
          v-for="d in durations"
          :key="d.weeks"
          type="button"
          :class="{ 'is-on': weeks === d.weeks }"
          :aria-pressed="weeks === d.weeks"
          @click="weeks = d.weeks"
        >
          {{ d.label }}
        </button>
      </div>
    </header>

    <div class="ads-gantt-chart__scroller">
      <div class="ads-gantt-chart__chart">
        <!-- Left column: the phase list -->
        <div class="ads-gantt-chart__names">
          <div class="ads-gantt-chart__col-head">
            <span class="eyebrow">Phase</span>
            <span class="eyebrow">Weeks</span>
          </div>
          <button
            v-for="(p, i) in phases"
            :key="p.name"
            type="button"
            class="ads-gantt-chart__name-row"
            :class="{ 'is-active': active.includes(p.name), 'is-picked': selected === i }"
            :aria-expanded="selected === i"
            @click="selected = selected === i ? null : i"
          >
            <span class="ads-gantt-chart__name">
              {{ p.name }}
              <span v-if="p.tech" class="ads-gantt-chart__tech num">{{ p.tech }}</span>
            </span>
            <span class="ads-gantt-chart__weeks num">{{ weekRange(p) }}</span>
          </button>
        </div>

        <!-- Right column: the timeline -->
        <div class="ads-gantt-chart__lanes">
          <TransitionGroup tag="div" name="ads-gantt-chart__col" class="ads-gantt-chart__col-head ads-gantt-chart__weeks-head">
            <span
              v-for="w in weekCols"
              :key="w"
              class="ads-gantt-chart__week num"
              :style="{ width: pct(1 / weeks) }"
            >{{ w }}</span>
          </TransitionGroup>

          <div
            ref="track"
            class="ads-gantt-chart__track"
            :style="{ '--cols': weeks }"
            role="slider"
            tabindex="0"
            :aria-valuemin="1"
            :aria-valuemax="weeks"
            :aria-valuenow="currentWeek"
            :aria-valuetext="`Week ${currentWeek} of ${weeks}`"
            aria-label="Scrub the timeline"
            @pointerdown="onPointerDown"
            @pointermove="onPointerMove"
            @keydown="onKey"
          >
            <!-- Dependency elbows, drawn under the bars -->
            <span
              v-for="e in elbows"
              :key="e.key"
              class="ads-gantt-chart__elbow-v"
              :style="{ left: e.x, top: `${e.top}px`, height: `${e.height}px` }"
            />
            <span
              v-for="e in elbows"
              :key="`${e.key}-arm`"
              class="ads-gantt-chart__elbow-h"
              :style="{ left: e.x, top: `${e.armTop}px`, width: e.armWidth }"
            />

            <div
              v-for="(p, i) in phases"
              :key="p.name"
              class="ads-gantt-chart__lane"
              :style="{ height: `${ROW}px` }"
            >
              <span
                class="ads-gantt-chart__bar"
                :class="{ 'is-active': active.includes(p.name), 'is-picked': selected === i }"
                :style="{ left: pct(p.from), width: pct(p.to - p.from) }"
                @click.stop="selected = selected === i ? null : i"
              >
                <span class="ads-gantt-chart__bar-label">{{ p.tech || p.name }}</span>
              </span>
            </div>

            <!-- The scrub marker -->
            <span class="ads-gantt-chart__marker" :style="{ left: pct(at) }">
              <span class="ads-gantt-chart__flag num">wk {{ currentWeek }}</span>
            </span>
          </div>
        </div>
      </div>
    </div>

    <footer class="ads-gantt-chart__foot">
      <p class="body ads-gantt-chart__readout">
        <span class="eyebrow">Week {{ currentWeek }}</span>
        <TransitionGroup tag="span" name="ads-gantt-chart__phase" class="ads-gantt-chart__active-list">
          <span v-for="name in active" :key="name" class="ads-gantt-chart__active-item">{{ name }}</span>
          <span v-if="!active.length" key="none" class="dim">Nothing scheduled — drag the marker.</span>
        </TransitionGroup>
      </p>
      <Transition name="ads-gantt-chart__detail">
        <p v-if="selected !== null" class="body ads-gantt-chart__detail">
          <strong>{{ phases[selected]!.name }}.</strong>
          {{ phases[selected]!.detail }}
        </p>
      </Transition>
    </footer>
  </div>
</template>

<style scoped>
.ads-gantt-chart {
  display: grid;
  gap: 22px;
  padding: clamp(20px, 3vw, 28px);
}

.ads-gantt-chart__head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
}

.ads-gantt-chart__head .eyebrow {
  margin-bottom: 7px;
}

/* ─── Frame ────────────────────────────────────────────────────────────── */

/* A Gantt cannot be made narrow enough for a phone without becoming useless,
   so below its minimum it scrolls sideways and the phase names stay put. */
.ads-gantt-chart__scroller {
  overflow-x: auto;
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset);
  padding: 4px;
}

.ads-gantt-chart__chart {
  display: grid;
  grid-template-columns: minmax(232px, 252px) minmax(430px, 1fr);
  min-width: 690px;
}

.ads-gantt-chart__col-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 38px;
  padding: 0 12px;
  border-bottom: 1px solid var(--line);
}

/* ─── Phase list ───────────────────────────────────────────────────────── */

.ads-gantt-chart__names {
  position: sticky;
  left: 0;
  z-index: 3;
  background: var(--surface);
  border-right: 1px solid var(--line);
}

.ads-gantt-chart__name-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  height: 46px;
  padding: 0 12px;
  text-align: left;
  font-size: 0.87rem;
  color: var(--text-soft);
  border-bottom: 1px solid var(--line);
  transition:
    color var(--dur-control) var(--ease),
    background var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-gantt-chart__name-row:last-child {
  border-bottom: 0;
}

.ads-gantt-chart__name-row:hover {
  color: var(--text);
  background: var(--hover);
}

/* The phases running at the marker's position. */
.ads-gantt-chart__name-row.is-active {
  color: var(--text);
  box-shadow: inset 2px 0 0 var(--accent);
}

.ads-gantt-chart__name-row.is-picked {
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
}

.ads-gantt-chart__name {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ads-gantt-chart__tech {
  font-size: 0.64rem;
  letter-spacing: 0.06em;
  padding: 2px 7px;
  border-radius: var(--pill);
  background: var(--raised);
  box-shadow: var(--soft-inset-sm);
  color: var(--subdued);
  flex: none;
}

.ads-gantt-chart__weeks {
  font-size: 0.7rem;
  color: var(--subdued);
  flex: none;
}

/* ─── Timeline ─────────────────────────────────────────────────────────── */

.ads-gantt-chart__weeks-head {
  padding: 0;
}

.ads-gantt-chart__week {
  display: grid;
  place-items: center;
  font-size: 0.68rem;
  color: var(--subdued);
  flex: none;
}

.ads-gantt-chart__track {
  position: relative;
  /* Week gridlines, drawn rather than marked up — one element instead of
     twelve, and they resize with the column count. */
  background-image: linear-gradient(to right, var(--line) 1px, transparent 1px);
  background-size: calc(100% / var(--cols)) 100%;
  cursor: ew-resize;
  touch-action: pan-y;
}

.ads-gantt-chart__track:focus-visible {
  outline-offset: -2px;
}

.ads-gantt-chart__lane {
  position: relative;
  border-bottom: 1px solid var(--line);
}

.ads-gantt-chart__lane:last-of-type {
  border-bottom: 0;
}

.ads-gantt-chart__bar {
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  height: 26px;
  display: flex;
  align-items: center;
  padding: 0 11px;
  border-radius: var(--pill);
  background: var(--elevated);
  box-shadow: var(--soft-raised-sm);
  color: var(--text-soft);
  cursor: pointer;
  overflow: hidden;
  transition:
    background var(--dur-control) var(--ease),
    color var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-gantt-chart__bar:hover {
  color: var(--text);
}

/* Running at the marker: the bar takes the accent. With no hue to spend,
   being the brightest thing in the row is the whole signal. */
.ads-gantt-chart__bar.is-active {
  background: var(--accent);
  color: var(--accent-ink);
}

.ads-gantt-chart__bar.is-picked {
  box-shadow: var(--soft-inset-sm);
}

.ads-gantt-chart__bar-label {
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.04em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ─── Elbows ───────────────────────────────────────────────────────────── */

.ads-gantt-chart__elbow-v,
.ads-gantt-chart__elbow-h {
  position: absolute;
  background: var(--subdued);
  opacity: 0.75;
  pointer-events: none;
}

.ads-gantt-chart__elbow-v {
  width: 1px;
}

/* A dot where the connector meets the dependent row. A bare hairline reads
   as a stray gridline; the dot is what makes it read as an arrival. */
.ads-gantt-chart__elbow-v::after {
  content: '';
  position: absolute;
  bottom: -2px;
  left: -2px;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--subdued);
}

.ads-gantt-chart__elbow-h {
  height: 1px;
}

/* ─── Marker ───────────────────────────────────────────────────────────── */

.ads-gantt-chart__marker {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--accent);
  pointer-events: none;
}

/* A hairline is easy to lose against the gridlines, so the marker carries a
   soft glow of its own. */
.ads-gantt-chart__marker::before {
  content: '';
  position: absolute;
  inset: 0 -6px;
  background: var(--accent);
  opacity: 0.12;
  filter: blur(5px);
}

.ads-gantt-chart__flag {
  position: absolute;
  top: -31px;
  left: 50%;
  translate: -50% 0;
  font-size: 0.64rem;
  padding: 3px 9px;
  border-radius: var(--pill);
  background: var(--accent);
  color: var(--accent-ink);
  white-space: nowrap;
}

/* ─── Readout ──────────────────────────────────────────────────────────── */

.ads-gantt-chart__foot {
  display: grid;
  gap: 12px;
}

/* Week columns, entering and leaving as the length changes. Absolute
   positioning on the leaving element keeps the remaining columns from
   jumping while it fades. */
.ads-gantt-chart__col-enter-active,
.ads-gantt-chart__col-leave-active {
  transition:
    opacity var(--dur-move) var(--ease),
    transform var(--dur-move) var(--ease);
}

.ads-gantt-chart__col-enter-from,
.ads-gantt-chart__col-leave-to {
  opacity: 0;
  transform: translateY(-6px);
}

.ads-gantt-chart__col-leave-active {
  position: absolute;
}

/* Phase names arriving and leaving as the marker is dragged. */
.ads-gantt-chart__active-list {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 8px;
  position: relative;
}

.ads-gantt-chart__active-item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.ads-gantt-chart__active-item:not(:last-child)::after {
  content: '·';
  color: var(--subdued);
}

.ads-gantt-chart__phase-enter-active,
.ads-gantt-chart__phase-leave-active {
  transition:
    opacity var(--dur-control) var(--ease),
    transform var(--dur-control) var(--ease);
}

.ads-gantt-chart__phase-enter-from,
.ads-gantt-chart__phase-leave-to {
  opacity: 0;
  transform: translateY(5px);
}

.ads-gantt-chart__phase-leave-active {
  position: absolute;
}

.ads-gantt-chart__phase-move {
  transition: transform var(--dur-control) var(--ease);
}

.ads-gantt-chart__readout {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 10px;
  margin: 0;
  color: var(--text-soft);
  font-size: 0.88rem;
}

.ads-gantt-chart__detail {
  margin: 0;
  padding-top: 12px;
  border-top: 1px solid var(--line);
  font-size: 0.88rem;
  max-width: 68ch;
}

.ads-gantt-chart__detail strong {
  color: var(--text);
  font-weight: 540;
}

.ads-gantt-chart__detail-enter-active,
.ads-gantt-chart__detail-leave-active {
  transition:
    opacity var(--dur-control) var(--ease),
    transform var(--dur-control) var(--ease);
}

.ads-gantt-chart__detail-enter-from,
.ads-gantt-chart__detail-leave-to {
  opacity: 0;
  transform: translateY(-4px);
}
</style>