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.
<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.
<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.
Empty hides it — when the page spends its accent elsewhere.
tabs
string[]
[]
tab
string (v-model)
''
query
string (v-model)
''
placeholder / searchLabel
string
Filter 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." />
<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
Prop
Type
Default
Description
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
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>