Start a project

Data display

Data display

The reason the screen exists. Numbers are monospaced so a column scans downward.

AdsStat

A figure and its comparison. A number with nothing to compare it to is trivia, so every tile carries one: a delta, the previous period, or a target. A tile with none warns in development.

URLs crawled1,284up+37
Median latency74msprev 84ms
Coverage68%target 80%
vue
<AdsStat value="1,284" label="URLs crawled" delta="+37" direction="up" />
<AdsStat value="74" unit="ms" label="Median latency" :previous="84" />
<AdsStat value="68" unit="%" label="Coverage" :target="80" />
AdsStat props
PropTypeDefaultDescription
valuestring | number—
labelstring—
unitstring
deltastring—Change against the last period, shown with AdsDelta.
direction'up' | 'down' | 'flat''flat'Direction of the delta.
previousstring | number—The prior period’s figure, in the same unit.
targetstring | number—The figure being aimed for.
notestringContext only — not a comparison.
Source · ads/app/components/AdsStat.vue
vue
<script setup lang="ts">
// A KPI tile. The number is monospaced and large, the label is small and
// quiet — and the figure always travels with a comparison, because a number
// on its own is a label with extra steps. Give it a delta, the previous
// period, a target, or at the very least a note saying what it is against.
const props = withDefaults(
  defineProps<{
    value: string | number
    label: string
    unit?: string
    /** The change, already formatted ("12.4%"). Read with `direction`. */
    delta?: string
    direction?: 'up' | 'down' | 'flat'
    /** The same figure last period, shown as "prev …". */
    previous?: string | number
    /** What the figure is aiming at, shown as "target …". */
    target?: string | number
    /** A free-form comparison: "+37 this run", "of 100". */
    note?: string
  }>(),
  { unit: '', delta: '', direction: 'flat', previous: undefined, target: undefined, note: '' },
)

const hasComparison = computed(
  () => !!props.delta || props.previous !== undefined || props.target !== undefined || !!props.note,
)

if (false && !hasComparison.value) {
  console.warn(`[AdsStat] "${props.label}" has no comparison — pass delta, previous, target or note.`)
}
</script>

<template>
  <div class="ads-stat card-press">
    <span class="eyebrow">{{ label }}</span>
    <span class="num ads-stat__value">{{ value }}<span v-if="unit" class="ads-stat__unit">{{ unit }}</span></span>
    <span v-if="hasComparison" class="ads-stat__compare">
      <AdsDelta v-if="delta" :direction="direction" :value="delta" />
      <span v-if="previous !== undefined" class="num ads-stat__note">prev {{ previous }}{{ unit }}</span>
      <span v-if="target !== undefined" class="num ads-stat__note">target {{ target }}{{ unit }}</span>
      <span v-if="note" class="num ads-stat__note">{{ note }}</span>
    </span>
  </div>
</template>

<style scoped>
.ads-stat {
  display: grid;
  gap: var(--sp-2);
  align-content: start;
  padding: var(--sp-4) var(--sp-5);
  border-radius: var(--radius-sm);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  transition: box-shadow var(--dur-control) var(--ease);
}

.ads-stat:hover {
  box-shadow: var(--soft-inset-sm);
}

/* A viewport reading like 1440×900 is a lot of monospaced characters for a
   half-width tile, so the number gives way before the box does. */
.ads-stat__value {
  font-size: clamp(1.1rem, 4.4cqw, 1.62rem);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 1.05;
}

.ads-stat__unit {
  font-size: 0.8rem;
  color: var(--subdued);
  margin-left: 2px;
}

.ads-stat__compare {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--sp-1) var(--sp-3);
}

.ads-stat__note {
  font-size: 0.7rem;
  color: var(--subdued);
}
</style>

CountUp

A figure that counts up once, the first time it scrolls into view — an arrival, never a ticker. Later changes are set, not animated; reduced motion and screen readers get the final value.

18,240 pages crawled

vue
<CountUp :to="18240" />
CountUp props
PropTypeDefaultDescription
tonumber—
decimalsnumber0
durationnumber (ms)1600
Source · ads/app/components/CountUp.vue
vue
<script setup lang="ts">
// Counts up to `to` the first time it scrolls into view — once, as an
// arrival, never again. Server-rendered with the final value, so the number is
// right without JavaScript, and a reader who asks for less motion only ever
// sees the final value. When `to` changes later the figure is simply set:
// a value that re-animates on every update is a slot machine, not a reading.
// Assistive tech is given the final value and never the frames in between.
const props = withDefaults(defineProps<{ to: number; decimals?: number; duration?: number }>(), { decimals: 0, duration: 1600 })
const el = ref<HTMLElement>()
const value = ref(props.to)
let counting = false

const format = (n: number) =>
  n.toLocaleString('en-GB', {
    minimumFractionDigits: props.decimals,
    maximumFractionDigits: props.decimals,
  })

watch(
  () => props.to,
  (to) => {
    counting = false
    value.value = to
  },
)

onMounted(() => {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
  value.value = 0
  counting = true
  const io = new IntersectionObserver(([entry]) => {
    if (!entry?.isIntersecting) return
    io.disconnect()
    const start = performance.now()
    const step = (now: number) => {
      if (!counting) return
      const t = Math.min(1, (now - start) / props.duration)
      value.value = props.to * (1 - Math.pow(1 - t, 4))
      if (t < 1) requestAnimationFrame(step)
      else counting = false
    }
    requestAnimationFrame(step)
  })
  io.observe(el.value!)
  onBeforeUnmount(() => io.disconnect())
})
</script>

<template>
  <span ref="el" class="ads-count-up num"><span aria-hidden="true">{{ format(value) }}</span><span class="sr-only">{{ format(to) }}</span></span>
</template>

AdsDelta

Change against a previous period. The direction is required and is carried by the glyph and spoken text as well as the tone — never by colour alone.

up12.4%down3.1%unchanged0.0%
vue
<AdsDelta direction="up" value="12.4%" />
AdsDelta props
PropTypeDefaultDescription
direction'up' | 'down' | 'flat'—Required.
valuestring—
Source · ads/app/components/AdsDelta.vue
vue
<script setup lang="ts">
// A change against the previous period. The direction glyph is mandatory — a
// delta without one is a number nobody can read at speed — so `direction` is
// required rather than defaulted, and it is spoken as a word too, because a
// screen reader does not reliably read a CSS triangle.
const props = defineProps<{ direction: 'up' | 'down' | 'flat'; value: string }>()

const spoken = computed(() => ({ up: 'up', down: 'down', flat: 'unchanged' })[props.direction])
</script>

<template>
  <span class="ads-delta num" :class="`is-${direction}`"><span class="sr-only">{{ spoken }} </span>{{ value }}</span>
</template>

AdsBadge

A count pill. A bare number means nothing to a screen reader, so `label` says what is being counted.

5unread findings99pagesdraft
vue
<AdsBadge :value="5" label="unread findings" />
AdsBadge props
PropTypeDefaultDescription
valuestring | number—
labelstringSpoken after the value: “5 unread findings”.
quietbooleanfalse
Source · ads/app/components/AdsBadge.vue
vue
<script setup lang="ts">
// A count or code pill. The accent variant is the one number on screen that
// matters; the quiet variant sits inside the chrome. A bare "5" means nothing
// to a screen reader, so `label` says what is being counted ("unread").
withDefaults(defineProps<{ value: string | number; label?: string; quiet?: boolean }>(), { label: '', quiet: false })
</script>

<template>
  <span class="ads-badge num" :class="{ 'is-quiet': quiet }">{{ value }}<span v-if="label" class="sr-only"> {{ label }}</span></span>
</template>

AdsStatus

A state, always as a dot and a word. In dense data use `grade` — luminance, the worse the brighter. For a single state outside a table, `tone` allows the three reserved state colours. There is no colour prop.

Low Medium High Critical Passing Degraded Failing
vue
<AdsStatus label="Critical" :grade="4" />
<AdsStatus label="Passing" tone="ok" />
AdsStatus props
PropTypeDefaultDescription
labelstring—Required — the state in words.
grade1 | 2 | 3 | 41
tone'ok' | 'warn' | 'bad'—Wins over grade.
Source · ads/app/components/AdsStatus.vue
vue
<script setup lang="ts">
// Severity in a monochrome system is luminance: the worse it is, the brighter
// it is. The glyph (dot) carries it too, and the label is required, so the
// rank survives a greyscale print and a colour-vision deficiency alike.
//
// `grade` is for dense data — a severity column, a heat grid. `tone` is for a
// single state outside it (a health check, a job), where the three semantic
// colours are allowed; it wins over `grade` when both are given. There is no
// colour prop on purpose: state has three colours and they are spoken for.
withDefaults(
  defineProps<{ label: string; grade?: 1 | 2 | 3 | 4; tone?: 'ok' | 'warn' | 'bad' }>(),
  { grade: 1, tone: undefined },
)
</script>

<template>
  <span class="ads-status" :class="tone ? `is-${tone}` : `g${grade}`">
    <span class="ads-dot" aria-hidden="true" />
    {{ label }}
  </span>
</template>

<style scoped>
.ads-status.is-ok { color: var(--ok); }
.ads-status.is-warn { color: var(--warn); }
.ads-status.is-bad { color: var(--bad); }
</style>

AdsMeter

A quantity against a threshold. The threshold is required: the mark is the component, and without it the fill means nothing.

Budget used68%
Error rate91%
vue
<AdsMeter :value="68" :threshold="80" label="Budget used" unit="%" />
AdsMeter props
PropTypeDefaultDescription
valuenumber—
maxnumber100
thresholdnumber—Required — draws the mark.
labelstring—Required — visible and the accessible name.
unitstring
Source · ads/app/components/AdsMeter.vue
vue
<script setup lang="ts">
// A quantity against a threshold. Distinct from progress, which is a task
// moving toward completion. The threshold is required: it draws the mark the
// reader needs to know whether the fill is good news, and a meter without one
// is decoration. The label is required too — it is the meter's name, for the
// eye and for a screen reader alike.
const props = withDefaults(
  defineProps<{ value: number; max?: number; threshold: number; label: string; unit?: string }>(),
  { max: 100, unit: '' },
)

const at = (n: number) => `${Math.max(0, Math.min(100, (n / props.max) * 100))}%`
const valueText = computed(
  () => `${props.value}${props.unit} of ${props.max}${props.unit}, threshold ${props.threshold}${props.unit}`,
)
</script>

<template>
  <div class="ads-meter__wrap">
    <span class="ads-meter__head">
      <span class="ads-meter__label">{{ label }}</span>
      <span class="num ads-meter__value">{{ value }}{{ unit }}</span>
    </span>
    <div
      class="ads-meter"
      role="meter"
      :aria-label="label"
      :aria-valuenow="value"
      :aria-valuemin="0"
      :aria-valuemax="max"
      :aria-valuetext="valueText"
    >
      <i :style="{ width: at(value) }" />
      <b :style="{ left: at(threshold) }" />
    </div>
  </div>
</template>

<style scoped>
.ads-meter__wrap {
  display: grid;
  gap: var(--sp-2);
}

.ads-meter__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
}

.ads-meter__label {
  font-size: 0.76rem;
  color: var(--subdued);
}

.ads-meter__value {
  font-size: 0.76rem;
  color: var(--text-soft);
}
</style>

AdsProgress

A task moving toward a known end. Not a meter: there is no threshold, only completion. The label names the task.

Crawling46%
vue
<AdsProgress :value="46" label="Crawling" />
AdsProgress props
PropTypeDefaultDescription
valuenumber—
maxnumber100
labelstring—Required.
Source · ads/app/components/AdsProgress.vue
vue
<script setup lang="ts">
// A task moving toward completion. Distinct from a meter, which is a quantity
// against a threshold — so there is no threshold here, only an end. The label
// names the task ("Crawling"); a progress bar with no name is a bar a screen
// reader can only call "progress".
const props = withDefaults(defineProps<{ value: number; max?: number; label: string }>(), { max: 100 })

const percent = computed(() => Math.round(Math.max(0, Math.min(100, (props.value / props.max) * 100))))
</script>

<template>
  <div class="ads-progress__wrap">
    <span class="ads-progress__head">
      <span class="ads-progress__label">{{ label }}</span>
      <span class="num ads-progress__value">{{ percent }}%</span>
    </span>
    <div
      class="ads-progress"
      role="progressbar"
      :aria-label="label"
      :aria-valuenow="value"
      :aria-valuemin="0"
      :aria-valuemax="max"
    >
      <i :style="{ width: `${percent}%` }" />
    </div>
  </div>
</template>

<style scoped>
.ads-progress__wrap {
  display: grid;
  gap: var(--sp-2);
}

.ads-progress__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
}

.ads-progress__label {
  font-size: 0.76rem;
  color: var(--subdued);
}

.ads-progress__value {
  font-size: 0.76rem;
  color: var(--text-soft);
}
</style>

ChartChoice

The decision before the component. Pick the form from the job, then the colour — most bad charts are the wrong shape.

Choosing a chart form from the data's job
The data's jobFormWhat decides it
Change over timeLine, or a sparkline beside the figureMinimal grid; label the first and last value, never every point
Comparing categoriesHorizontal bars, ordered by valueNever a pie — angle is the hardest encoding to compare
One headline numberStat tile with a deltaA figure with no comparison is trivia; pair it with a target or a prior period
Part of a wholeStacked bar, or just the percentageTwo or three parts at most, or it becomes a pie lying down
DistributionHistogram or box plotAn average hides the shape; show the spread where the spread is the point
Density over two axesHeatmapLuminance steps for magnitude — the channel the eye reads fastest
Progress to a targetMeter with a threshold markThe mark is the component; without it the fill means nothing
RelationshipScatterThree series maximum — any two marks can sit side by side
vue
<ChartChoice />
ChartChoice props
PropTypeDefaultDescription
rows[job, form, decides][]8 rows
Source · ads/app/components/ChartChoice.vue
vue
<script setup lang="ts">
// The decision before the component: pick the chart form from the job, not
// from a gallery. Most bad charts are the wrong shape, not the wrong colour.
//
// The table is styled through its own classes rather than bare `table`/`td`
// selectors, so de-scoped into the Rust kit's one sheet it styles this table
// and no other.
export type ChartChoiceRow = [job: string, form: string, decides: string]

withDefaults(defineProps<{ rows?: ChartChoiceRow[] }>(), {
  rows: () => [
    ['Change over time', 'Line, or a sparkline beside the figure', 'Minimal grid; label the first and last value, never every point'],
    ['Comparing categories', 'Horizontal bars, ordered by value', 'Never a pie — angle is the hardest encoding to compare'],
    ['One headline number', 'Stat tile with a delta', 'A figure with no comparison is trivia; pair it with a target or a prior period'],
    ['Part of a whole', 'Stacked bar, or just the percentage', 'Two or three parts at most, or it becomes a pie lying down'],
    ['Distribution', 'Histogram or box plot', 'An average hides the shape; show the spread where the spread is the point'],
    ['Density over two axes', 'Heatmap', 'Luminance steps for magnitude — the channel the eye reads fastest'],
    ['Progress to a target', 'Meter with a threshold mark', 'The mark is the component; without it the fill means nothing'],
    ['Relationship', 'Scatter', 'Three series maximum — any two marks can sit side by side'],
  ],
})
</script>

<template>
  <div class="ads-chart-choice">
    <table class="ads-chart-choice__table">
      <caption class="sr-only">Choosing a chart form from the data's job</caption>
      <thead>
        <tr>
          <th scope="col" class="ads-chart-choice__th">The data's job</th>
          <th scope="col" class="ads-chart-choice__th">Form</th>
          <th scope="col" class="ads-chart-choice__th">What decides it</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in rows" :key="row[0]">
          <th scope="row" class="ads-chart-choice__td ads-chart-choice__job">{{ row[0] }}</th>
          <td class="ads-chart-choice__td">{{ row[1] }}</td>
          <td class="ads-chart-choice__td ads-chart-choice__decides">{{ row[2] }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<style scoped>
.ads-chart-choice {
  width: 100%;
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset);
  padding: var(--sp-2) var(--sp-4);
  overflow-x: auto;
}

.ads-chart-choice__table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.85rem;
  min-width: 560px;
}

.ads-chart-choice__th {
  text-align: left;
  font-family: var(--font-label);
  font-size: 0.62rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--subdued);
  font-weight: 500;
  padding: 10px var(--sp-3);
  border-bottom: 1px solid var(--line);
}

.ads-chart-choice__td {
  text-align: left;
  font-weight: inherit;
  padding: 9px var(--sp-3);
  border-bottom: 1px solid var(--line);
  vertical-align: top;
  color: var(--text-soft);
}

.ads-chart-choice__job {
  color: var(--text);
  white-space: nowrap;
}

.ads-chart-choice__decides {
  color: var(--subdued);
}
</style>

AdsSparkline

Change over time, beside the figure it explains. Point at it, or focus it and use the arrow keys, to read a value. `label` is required and names the chart.

vue
<AdsSparkline :points="[12, 19, 15, 24]" label="Sessions, last 12 weeks" unit="k" />
AdsSparkline props
PropTypeDefaultDescription
pointsnumber[]—
labelstring—Required — the accessible name.
unitstring
fillbooleantrue
pointLabel(i: number) => string#nNames a point in the readout.
Source · ads/app/components/AdsSparkline.vue
vue
<script setup lang="ts">
// A sparkline, painted by hand. The arithmetic is a pure function so it can be
// reasoned about without a chart library's opinions, and the whole component
// costs a few hundred bytes rather than a dependency.
//
// Point at it (or drag a finger along it) and it reads out: a crosshair, the
// point on the line, and the value — the inspection every real dashboard
// chart offers, which is most of what makes one feel like an instrument. The
// arrow keys do the same from the keyboard, and the readout is spoken.
//
// `label` is the chart's name and is required: the SVG's accessible name is
// built from it plus the first, last, lowest and highest values, which is what
// a reader glancing at a sparkline takes from it anyway.
const props = withDefaults(
  defineProps<{ points: number[]; label: string; fill?: boolean; unit?: string; pointLabel?: (i: number) => string }>(),
  { fill: true, unit: '', pointLabel: undefined },
)

const W = 240
const H = 64
// Sanitised: an id with a colon in it breaks `url(#…)`.
const fadeId = `ads-sparkline-${useId().replace(/[^\w-]/g, '-')}`

const geometry = computed(() => path(props.points, W, H))
const fmt = (n: number) => `${n.toLocaleString('en-GB')}${props.unit}`

function path(values: number[], w: number, h: number) {
  if (values.length < 2) return { line: '', area: '', coords: [] as (readonly [number, number])[] }
  const min = Math.min(...values)
  const max = Math.max(...values)
  const span = max - min || 1
  const step = w / (values.length - 1)
  const coords = values.map((v, i) => {
    const x = i * step
    // 4px of padding top and bottom so the stroke is never clipped.
    const y = h - 4 - ((v - min) / span) * (h - 8)
    return [x, y] as const
  })

  // A Catmull-Rom-ish smoothing: each segment gets control points a third of
  // the way toward its neighbours, which reads as a curve without overshoot.
  let line = `M${coords[0]![0]} ${coords[0]![1].toFixed(2)}`
  for (let i = 0; i < coords.length - 1; i++) {
    const [x0, y0] = coords[i]!
    const [x1, y1] = coords[i + 1]!
    const cx = (x0 + x1) / 2
    line += ` C${cx.toFixed(2)} ${y0.toFixed(2)} ${cx.toFixed(2)} ${y1.toFixed(2)} ${x1.toFixed(2)} ${y1.toFixed(2)}`
  }
  return { line, area: `${line} L${w} ${h} L0 ${h} Z`, coords }
}

const summary = computed(() => {
  const p = props.points
  if (!p.length) return `${props.label}: no data`
  return `${props.label}: ${p.length} points, from ${fmt(p[0]!)} to ${fmt(p[p.length - 1]!)}, low ${fmt(Math.min(...p))}, high ${fmt(Math.max(...p))}`
})

// ─── Inspection ──────────────────────────────────────────────────────────
const hover = ref<number | null>(null)

function inspect(e: PointerEvent) {
  const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
  const t = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
  hover.value = Math.round(t * (props.points.length - 1))
}

function onKey(e: KeyboardEvent) {
  const last = props.points.length - 1
  const i = hover.value ?? last
  if (e.key === 'ArrowLeft') hover.value = Math.max(0, i - 1)
  else if (e.key === 'ArrowRight') hover.value = Math.min(last, i + 1)
  else if (e.key === 'Home') hover.value = 0
  else if (e.key === 'End') hover.value = last
  else if (e.key === 'Escape') hover.value = null
  else return
  e.preventDefault()
}

const readout = computed(() => {
  const i = hover.value
  const c = i === null ? undefined : geometry.value.coords[i]
  if (i === null || !c) return null
  return {
    x: (c[0] / W) * 100,
    y: (c[1] / H) * 100,
    value: fmt(props.points[i]!),
    label: props.pointLabel?.(i) ?? `#${i + 1}`,
    // Keep the bubble inside the chart at either end.
    align: c[0] / W < 0.2 ? 'is-start' : c[0] / W > 0.8 ? 'is-end' : '',
  }
})
</script>

<template>
  <div
    class="ads-sparkline"
    role="group"
    tabindex="0"
    :aria-label="label"
    @pointermove="inspect"
    @pointerdown="inspect"
    @pointerleave="hover = null"
    @pointercancel="hover = null"
    @keydown="onKey"
    @blur="hover = null"
  >
    <svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none" class="ads-sparkline__svg" role="img" :aria-label="summary">
      <defs>
        <linearGradient :id="fadeId" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="currentColor" stop-opacity="0.22" />
          <stop offset="100%" stop-color="currentColor" stop-opacity="0" />
        </linearGradient>
      </defs>
      <path v-if="fill" :d="geometry.area" :fill="`url(#${fadeId})`" />
      <path
        :d="geometry.line"
        pathLength="1"
        fill="none"
        stroke="currentColor"
        stroke-width="1.8"
        vector-effect="non-scaling-stroke"
        stroke-linecap="round"
      />
    </svg>
    <template v-if="readout">
      <span class="ads-sparkline__cross" :style="{ left: `${readout.x}%` }" />
      <span class="ads-sparkline__point" :style="{ left: `${readout.x}%`, top: `${readout.y}%` }" />
      <span class="ads-sparkline__bubble num" :class="readout.align" :style="{ left: `${readout.x}%` }" aria-hidden="true">
        <b>{{ readout.value }}</b> {{ readout.label }}
      </span>
    </template>
    <span class="sr-only" aria-live="polite">{{ readout ? `${readout.value}, ${readout.label}` : '' }}</span>
  </div>
</template>

<style scoped>
.ads-sparkline {
  position: relative;
  touch-action: pan-y;
  cursor: crosshair;
}

.ads-sparkline__svg {
  display: block;
  width: 100%;
  height: 64px;
  color: var(--text);
}

.ads-sparkline__cross {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--line-strong);
  pointer-events: none;
}

/* Follows the pointer, so it moves in a frame or two rather than on the
   control duration — a dot that lags the hand reads as broken, not smooth. */
.ads-sparkline__point {
  position: absolute;
  width: 9px;
  height: 9px;
  margin: -4.5px 0 0 -4.5px;
  border-radius: 50%;
  background: var(--text);
  box-shadow: 0 0 0 3px var(--raised);
  pointer-events: none;
  transition:
    left 0.08s var(--ease),
    top 0.08s var(--ease);
}

.ads-sparkline__bubble {
  position: absolute;
  bottom: calc(100% + 6px);
  transform: translateX(-50%);
  padding: var(--sp-1) 9px;
  border-radius: 8px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 0.68rem;
  white-space: nowrap;
  pointer-events: none;
}

.ads-sparkline__bubble.is-start {
  transform: translateX(-12px);
}

.ads-sparkline__bubble.is-end {
  transform: translateX(calc(-100% + 12px));
}

.ads-sparkline__bubble b {
  font-weight: 600;
}
</style>

AdsBars

Comparing categories: horizontal bars, each with its figure as real text. Value maps to luminance as well as length.

Mon42
Tue58
Wed51
Thu74
Fri66
vue
<AdsBars :data="[{ label: 'Mon', value: 42 }]" label="Crawls by weekday" />
AdsBars props
PropTypeDefaultDescription
data{ label, value }[]—
labelstring—Required — names the figure and carries the unit.
Source · ads/app/components/AdsBars.vue
vue
<script setup lang="ts">
// A bar chart where value maps to luminance as well as height. With no hue to
// spend, brightness is the second channel — the tallest bar is also the
// brightest, so the ranking survives being glanced at.
//
// Every bar carries its category and its figure as real text, so the rows are
// their own table fallback; `label` names the whole figure ("Sessions by day,
// thousands") and is where the unit lives, not in every row.
const props = defineProps<{ data: { label: string; value: number }[]; label: string }>()
const max = computed(() => Math.max(...props.data.map((d) => d.value), 1))
const fmt = (n: number) => n.toLocaleString('en-GB')
</script>

<template>
  <div class="ads-bars" role="figure" :aria-label="label">
    <div v-for="(d, i) in data" :key="d.label" class="ads-bars__row">
      <span class="ads-bars__label">{{ d.label }}</span>
      <span class="ads-bars__track" aria-hidden="true">
        <span
          class="ads-bars__bar grow"
          :style="{
            width: `${(d.value / max) * 100}%`,
            opacity: 0.35 + (d.value / max) * 0.65,
            '--d': `${i * 90}ms`,
          }"
        />
      </span>
      <span class="num ads-bars__value">{{ fmt(d.value) }}</span>
    </div>
  </div>
</template>

<style scoped>
.ads-bars {
  display: grid;
  gap: var(--sp-3);
}

.ads-bars__row {
  display: grid;
  grid-template-columns: 1fr 2.1fr auto;
  align-items: center;
  gap: var(--sp-3);
  font-size: 0.85rem;
}

.ads-bars__label {
  color: var(--subdued);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ads-bars__track {
  height: 9px;
  border-radius: var(--pill);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  overflow: hidden;
}

.ads-bars__bar {
  display: block;
  height: 100%;
  border-radius: var(--pill);
  background: var(--accent);
  transition: width var(--dur-enter) var(--ease);
}

/* Figures right-aligned, so a column of them reads by magnitude. */
.ads-bars__value {
  font-size: 0.78rem;
  color: var(--text-soft);
  min-width: 2.4ch;
  text-align: right;
}
</style>

AdsGauge

A meter bent into a dial, for a rate that changes while you watch. Like the meter, its threshold is required.

vue
<AdsGauge :value="68" :threshold="80" label="Coverage" unit="%" />
AdsGauge props
PropTypeDefaultDescription
valuenumber—
maxnumber100
thresholdnumber—Required — the notch on the arc.
labelstring—Required.
unitstring
Source · ads/app/components/AdsGauge.vue
vue
<script setup lang="ts">
// A dial, drawn rather than animated frame by frame: it moves only when a new
// value arrives and eases between the two.
//
//         ╭ ticks ╮
//      ·············        outer ring: the scale
//    ·   ╭───────╮   ·
//   ·   │   64    │   ·     centre: the live value
//   ·   │ SCROLL  │   ·
//    ·   ╰───────╯   ·      inner arc: progress through the range
//      ·············        the notch: the threshold
//
// A dial shows a changing rate far better than a number does: the eye reads
// the position of the needle without reading the digits. It is a meter bent
// into a circle, so it obeys the meter's rule: the threshold mark is required,
// because without it the reader cannot tell whether the arc is good news.
const props = withDefaults(
  defineProps<{ value: number; max?: number; threshold: number; label: string; unit?: string }>(),
  { max: 100, unit: '' },
)

const SWEEP = 260 // degrees of arc the scale covers
const START = 140 // where it begins, measured clockwise from 3 o'clock
const R = 54
const CX = 64
const CY = 64

const eased = ref(0)
// False until the first value has been applied; see the watcher below.
let primed = false

// Ease toward the live value so the needle never snaps.
watch(
  () => props.value,
  (to) => {
    const from = eased.value
    const delta = to - from
    // The first run always sets rather than animates. On the server there is
    // no rAF to call, and on the client an opening animation from zero would
    // disagree with the needle the server already drew — which is a
    // hydration mismatch on every tick mark. Only later changes ease, and not
    // at all for a reader who has asked for less motion.
    const still = false && window.matchMedia('(prefers-reduced-motion: reduce)').matches
    if (!primed || still || Math.abs(delta) < 0.5) {
      primed = true
      eased.value = to
      return
    }
    const begun = performance.now()
    const step = (now: number) => {
      const t = Math.min(1, (now - begun) / 420)
      eased.value = from + delta * (1 - Math.pow(1 - t, 3))
      if (t < 1) requestAnimationFrame(step)
    }
    requestAnimationFrame(step)
  },
  { immediate: true },
)

const clamp01 = (n: number) => Math.max(0, Math.min(1, n))
const fraction = computed(() => clamp01(eased.value / props.max))

// Rounded to two decimals, which is finer than a device pixel at any size
// this gauge is drawn at. Unrounded, Node and the browser disagree on the
// last bit of the mantissa for the same trigonometry, and every tick becomes
// a hydration mismatch.
function polar(angleDeg: number, radius: number) {
  const rad = (angleDeg * Math.PI) / 180
  return [
    Math.round((CX + radius * Math.cos(rad)) * 100) / 100,
    Math.round((CY + radius * Math.sin(rad)) * 100) / 100,
  ] as const
}

function arc(from: number, to: number, radius: number) {
  const [x1, y1] = polar(from, radius)
  const [x2, y2] = polar(to, radius)
  const large = Math.abs(to - from) > 180 ? 1 : 0
  return `M${x1} ${y1} A${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`
}

const track = arc(START, START + SWEEP, R)
const progress = computed(() => arc(START, START + SWEEP * Math.max(fraction.value, 0.001), R))

// The scale: a tick every 10% of the sweep.
const ticks = Array.from({ length: 14 }, (_, i) => {
  const angle = START + (SWEEP / 13) * i
  const [x1, y1] = polar(angle, R + 7)
  const [x2, y2] = polar(angle, R + (i % 3 === 0 ? 12 : 10))
  return { x1, y1, x2, y2, major: i % 3 === 0 }
})

// The threshold: a notch cut through the arc in the dial's own surface, the
// way the meter's mark is cut in the canvas, and a full-strength tick outside
// it so it still reads where the arc is unfilled.
const notch = computed(() => {
  const angle = START + SWEEP * clamp01(props.threshold / props.max)
  const [cx1, cy1] = polar(angle, R - 6)
  const [cx2, cy2] = polar(angle, R + 6)
  const [tx1, ty1] = polar(angle, R + 6)
  const [tx2, ty2] = polar(angle, R + 14)
  return { cut: { x1: cx1, y1: cy1, x2: cx2, y2: cy2 }, tick: { x1: tx1, y1: ty1, x2: tx2, y2: ty2 } }
})

const needle = computed(() => polar(START + SWEEP * fraction.value, R - 13))
const shown = computed(() => Math.round(eased.value))
// Named from the value itself, not the eased one, so the accessible name does
// not churn through every frame of the animation.
const name = computed(() => `${props.label}: ${Math.round(props.value)}${props.unit}, threshold ${props.threshold}${props.unit}`)
</script>

<template>
  <div class="ads-gauge">
    <svg viewBox="0 0 128 128" class="ads-gauge__svg" role="img" :aria-label="name">
      <g class="ads-gauge__ticks">
        <line
          v-for="(t, i) in ticks"
          :key="i"
          :x1="t.x1"
          :y1="t.y1"
          :x2="t.x2"
          :y2="t.y2"
          :opacity="t.major ? 0.55 : 0.22"
        />
      </g>
      <path :d="track" class="ads-gauge__track" />
      <path :d="progress" class="ads-gauge__progress" />
      <line v-bind="notch.cut" class="ads-gauge__notch" />
      <line v-bind="notch.tick" class="ads-gauge__threshold" />
      <line :x1="CX" :y1="CY" :x2="needle[0]" :y2="needle[1]" class="ads-gauge__needle" />
      <!-- The cap is drawn over the needle so the reading is never crossed by
           it. Without this the digits and the needle fight at every value
           between 40% and 60%. -->
      <circle :cx="CX" :cy="CY" r="31" class="ads-gauge__cap" />
    </svg>
    <div class="ads-gauge__read" aria-hidden="true">
      <span class="num ads-gauge__value">{{ shown }}<span class="ads-gauge__unit">{{ unit }}</span></span>
      <span class="eyebrow">{{ label }}</span>
    </div>
  </div>
</template>

<style scoped>
.ads-gauge {
  position: relative;
  display: grid;
  place-items: center;
  width: 100%;
  max-width: 190px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--raised);
  box-shadow: var(--soft-raised);
  padding: 10px;
}

.ads-gauge__svg {
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* The scale is recessive — faint ticks in the text colour — so the arc, the
   needle and the threshold are the only things at full strength. */
.ads-gauge__ticks line {
  stroke: var(--text);
  stroke-width: 1.4;
  stroke-linecap: round;
}

.ads-gauge__track {
  fill: none;
  stroke: var(--canvas);
  stroke-width: 7;
  stroke-linecap: round;
  opacity: 0.85;
}

.ads-gauge__progress {
  fill: none;
  stroke: var(--accent);
  stroke-width: 7;
  stroke-linecap: round;
}

.ads-gauge__notch {
  stroke: var(--raised);
  stroke-width: 2.4;
}

.ads-gauge__threshold {
  stroke: var(--text);
  stroke-width: 2;
  stroke-linecap: round;
}

.ads-gauge__needle {
  stroke: var(--accent);
  stroke-width: 2;
  stroke-linecap: round;
  opacity: 0.8;
}

.ads-gauge__cap {
  fill: var(--raised);
}

.ads-gauge__read {
  position: absolute;
  display: grid;
  justify-items: center;
  gap: 3px;
  pointer-events: none;
}

.ads-gauge__value {
  font-size: 1.68rem;
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 1;
}

.ads-gauge__unit {
  font-size: 0.78rem;
  color: var(--subdued);
  margin-left: 1px;
}

.ads-gauge__read .eyebrow {
  font-size: 0.56rem;
  letter-spacing: 0.22em;
}
</style>

AdsHeatmap

Density over two axes. Values are quantised into the four luminance grades, with recessive axes and a legend; the accessible name carries the shape and range.

vue
<AdsHeatmap
  :values="[[0, 4, 12], [3, 9, 40]]"
  label="Crawl errors by weekday and hour"
  :row-labels="['Mon', 'Tue']"
  :col-labels="['06h', '07h', '08h']" />
AdsHeatmap props
PropTypeDefaultDescription
valuesnumber[][]—Rows of cells.
labelstring—Required.
rowLabelsstring[]—
colLabelsstring[]—Empty strings skip a label.
maxnumberlargest value
unitstring
Source · ads/app/components/AdsHeatmap.vue
vue
<script setup lang="ts">
// A density grid. Magnitude is carried by the four luminance grades — the
// channel the eye reads fastest, and the one dense data keeps when hue would
// be noise: forty cells in three hues is a pattern, not a reading. Brighter is
// more, in either theme, because the grade tokens invert with it.
//
// The grid takes real values and quantises them itself into five steps: an
// empty well for nothing, then grades one to four. The axes are recessive —
// small, subdued, only as many labels as are given — and a compact legend
// says which end is which. The accessible name carries the shape and range,
// since a reader cannot inspect four hundred cells one by one.
const props = withDefaults(
  defineProps<{
    values: number[][]
    label: string
    rowLabels?: string[]
    colLabels?: string[]
    max?: number
    unit?: string
  }>(),
  { rowLabels: undefined, colLabels: undefined, max: undefined, unit: '' },
)

const flat = computed(() => props.values.flat())
const top = computed(() => props.max ?? Math.max(...flat.value, 0))
const low = computed(() => (flat.value.length ? Math.min(...flat.value) : 0))
const fmt = (n: number) => `${n.toLocaleString('en-GB')}${props.unit}`

// 0 → the empty well; anything above zero lands on grade 1–4.
function step(v: number) {
  if (v <= 0 || top.value <= 0) return 0
  return Math.max(1, Math.min(4, Math.ceil((v / top.value) * 4)))
}

// Every row shares one column template so the axis labels and the cells line
// up; the row-label column is fixed rather than max-content for the same reason.
const columns = computed(() => {
  const n = props.values[0]?.length ?? 0
  return `${props.rowLabels?.length ? '3.5rem ' : ''}repeat(${n}, minmax(0, 1fr))`
})

const name = computed(() => {
  const rows = props.values.length
  const cols = props.values[0]?.length ?? 0
  if (!rows || !cols) return `${props.label}: no data`
  return `${props.label}: ${rows} by ${cols} grid, values ${fmt(low.value)} to ${fmt(top.value)}`
})
</script>

<template>
  <figure class="ads-heatmap">
    <div class="ads-heatmap__plot sweep" role="img" :aria-label="name">
      <div v-if="colLabels?.length" class="ads-heatmap__row" :style="{ gridTemplateColumns: columns }" aria-hidden="true">
        <span v-if="rowLabels?.length" class="ads-heatmap__axis" />
        <span v-for="(c, x) in colLabels" :key="x" class="ads-heatmap__axis ads-heatmap__axis-col">{{ c }}</span>
      </div>
      <div v-for="(row, y) in values" :key="y" class="ads-heatmap__row" :style="{ gridTemplateColumns: columns }">
        <span v-if="rowLabels?.length" class="ads-heatmap__axis" aria-hidden="true">{{ rowLabels[y] }}</span>
        <span
          v-for="(v, x) in row"
          :key="x"
          class="ads-heatmap__cell"
          :class="`is-step-${step(v)}`"
          :title="`${rowLabels?.[y] ?? `Row ${y + 1}`}, ${colLabels?.[x] ?? `column ${x + 1}`}: ${fmt(v)}`"
        />
      </div>
    </div>
    <figcaption class="ads-heatmap__legend" aria-hidden="true">
      <span class="num">{{ fmt(low) }}</span>
      <span v-for="s in 5" :key="s" class="ads-heatmap__cell ads-heatmap__key" :class="`is-step-${s - 1}`" />
      <span class="num">{{ fmt(top) }}</span>
    </figcaption>
  </figure>
</template>

<style scoped>
.ads-heatmap {
  display: grid;
  gap: var(--sp-3);
  margin: 0;
}

.ads-heatmap__plot {
  display: grid;
  gap: var(--sp-1);
}

.ads-heatmap__row {
  display: grid;
  align-items: center;
  gap: var(--sp-1);
}

/* Recessive axes: small mono, subdued, never competing with the cells. */
.ads-heatmap__axis {
  font-family: var(--mono);
  font-size: 0.6rem;
  color: var(--subdued);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ads-heatmap__axis-col {
  text-align: center;
}

.ads-heatmap__cell {
  aspect-ratio: 1;
  border-radius: 3px;
  background: var(--line);
  transition: background-color var(--dur-move) var(--ease);
}

.ads-heatmap__cell.is-step-1 { background: var(--grade-1); }
.ads-heatmap__cell.is-step-2 { background: var(--grade-2); }
.ads-heatmap__cell.is-step-3 { background: var(--grade-3); }
.ads-heatmap__cell.is-step-4 { background: var(--grade-4); }

.ads-heatmap__legend {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--sp-1);
  font-size: 0.62rem;
  color: var(--subdued);
}

.ads-heatmap__key {
  width: 10px;
}
</style>

AdsDl

Key-value detail — what a selected row almost always needs. A grid, not a table, so long values wrap.

Region
NA + EU
Window
2026-10-20 → 2026-10-27
Owner
Growth
vue
<AdsDl :items="[{ term: 'Status', value: '200 · indexable' }]" />
AdsDl props
PropTypeDefaultDescription
items{ term, value }[]—
Source · ads/app/components/AdsDl.vue
vue
<script setup lang="ts">
// Key-value detail, which is what a selected row almost always needs. A
// two-column grid rather than a table, so the values can wrap.
export type DlItem = { term: string; value: string | number }

defineProps<{ items: DlItem[] }>()
</script>

<template>
  <dl class="ads-dl">
    <template v-for="(item, i) in items" :key="i">
      <dt>{{ item.term }}</dt>
      <dd>{{ item.value }}</dd>
    </template>
  </dl>
</template>

AdsDataGrid

The workhorse. Numeric columns right-aligned in tabular figures with the unit in the header; text left. No striping. Sortable headers, a keyboard cursor (arrows, Home, End, Page Up/Down), and Enter or a click emits `select` — open the row in a drawer, never a modal.

Top pages
/overview18,24074 low
/pricing9,312112 medium
/contact2,133486 critical
vue
<AdsDataGrid
  :columns="[
    { key: 'name', label: 'Page', mono: true },
    { key: 'views', label: 'Views' },
    { key: 'latency', label: 'Latency', unit: 'ms' },
    { key: 'status', label: 'Severity', grade: true },
  ]"
  :rows="[{ name: '/overview', views: 18240, latency: 74, status: 1 }]"
  caption="Top pages"
  @select="(row) => open(row)" />
AdsDataGrid props
PropTypeDefaultDescription
columns{ key, label, unit?, numeric?, mono?, grade?, format? }[]—`numeric` is inferred when every value is a number. `mono` is for identifiers and stays left-aligned.
rowsRecord<string, string | number>[]—
captionstring—
@select(row, index) => void—
Source · ads/app/components/AdsDataGrid.vue
vue
<script setup lang="ts">
// The table from the app, on the web.
//
// Rows are separated by a whisper rather than a rule — never striped, because
// a stripe is a second signal competing with the cursor and the grades.
// Numeric columns are right-aligned in tabular figures so they read by
// magnitude down the page; text stays left. A column's unit lives in its
// header, once, not in every cell. The header sorts, and a keyboard cursor
// moves with the arrow keys; Enter (or a click) selects the row, which is the
// cue to open it in a drawer beside the table — never in a modal over it.
export type Row = Record<string, string | number>

export type GridColumn = {
  key: string
  label: string
  /** Shown in the header after the label: "Latency (ms)". */
  unit?: string
  /** Right-aligned, tabular. Inferred when every value in the column is a number. */
  numeric?: boolean
  /** Monospaced but left-aligned: identifiers, hashes, paths. */
  mono?: boolean
  /** Values 1–4 rendered as a luminance grade with a dot and a word. */
  grade?: boolean
  format?: (v: string | number) => string
}

const props = defineProps<{
  columns: GridColumn[]
  rows: Row[]
  caption?: string
}>()

const emit = defineEmits<{ select: [row: Row, index: number] }>()

const sortKey = ref<string | null>(null)
const sortAsc = ref(true)
const cursor = ref(-1)
const wrap = ref<HTMLElement | null>(null)

const isNumeric = (c: GridColumn) =>
  !c.grade && (c.numeric ?? (props.rows.length > 0 && props.rows.every((r) => typeof r[c.key] === 'number')))

const cells = computed(() => props.columns.map((c) => ({ ...c, isNum: isNumeric(c) })))

const display = (c: GridColumn & { isNum: boolean }, v: string | number | undefined) => {
  if (v === undefined || v === null) return '—'
  if (c.format) return c.format(v)
  return c.isNum && typeof v === 'number' ? v.toLocaleString('en-GB') : String(v)
}

const sorted = computed(() => {
  if (!sortKey.value) return props.rows
  const key = sortKey.value
  return [...props.rows].sort((a, b) => {
    const x = a[key]
    const y = b[key]
    const cmp = typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y))
    return sortAsc.value ? cmp : -cmp
  })
})

watch(
  () => props.rows,
  () => {
    if (cursor.value >= props.rows.length) cursor.value = props.rows.length - 1
  },
)

function sortBy(key: string) {
  if (sortKey.value === key) sortAsc.value = !sortAsc.value
  else {
    sortKey.value = key
    sortAsc.value = true
  }
}

function move(to: number) {
  const last = sorted.value.length - 1
  cursor.value = Math.max(0, Math.min(last, to))
  nextTick(() => {
    wrap.value?.querySelectorAll('tbody tr')[cursor.value]?.scrollIntoView({ block: 'nearest' })
  })
}

function select(i: number) {
  const row = sorted.value[i]
  if (row) emit('select', row, i)
}

function onKey(e: KeyboardEvent) {
  const page = 10
  const at = cursor.value
  if (e.key === 'ArrowDown') move(at + 1)
  else if (e.key === 'ArrowUp') move(at - 1)
  else if (e.key === 'PageDown') move(at + page)
  else if (e.key === 'PageUp') move(at - page)
  else if (e.key === 'Home') move(0)
  else if (e.key === 'End') move(sorted.value.length - 1)
  else if (e.key === 'Enter' && at >= 0) select(at)
  else return
  e.preventDefault()
}

// Four luminance steps stand in for severity. There is no red here, so the
// rule is simply: the worse it is, the brighter it is — and the word says it.
const gradeLabel = ['', 'low', 'medium', 'high', 'critical']
const asGrade = (v: string | number | undefined) => Math.max(1, Math.min(4, Number(v) || 1)) as 1 | 2 | 3 | 4

// The cursor is announced, since a moving highlight is invisible to a
// screen reader: the first column names the row.
const announce = computed(() => {
  const r = sorted.value[cursor.value]
  const first = cells.value[0]
  if (!r || !first) return ''
  return `${display(first, r[first.key])}, row ${cursor.value + 1} of ${sorted.value.length}`
})
</script>

<template>
  <div
    ref="wrap"
    class="ads-data-grid"
    tabindex="0"
    role="group"
    :aria-label="`${caption ?? 'Data table'}. Arrow keys move, Enter opens the row.`"
    @keydown="onKey"
  >
    <table class="ads-data-grid__table">
      <caption v-if="caption" class="sr-only">{{ caption }}</caption>
      <thead>
        <tr>
          <th
            v-for="c in cells"
            :key="c.key"
            scope="col"
            class="ads-data-grid__th"
            :class="{ 'is-num': c.isNum }"
            :aria-sort="sortKey === c.key ? (sortAsc ? 'ascending' : 'descending') : 'none'"
          >
            <button type="button" class="ads-data-grid__sort" @click="sortBy(c.key)">
              {{ c.label }}<span v-if="c.unit" class="ads-data-grid__unit"> ({{ c.unit }})</span>
              <span
                class="ads-data-grid__caret"
                :class="{ 'is-active': sortKey === c.key, 'is-desc': sortKey === c.key && !sortAsc }"
                aria-hidden="true"
              >↑</span>
            </button>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="(r, i) in sorted"
          :key="i"
          class="ads-data-grid__row"
          :class="{ 'is-cursor': cursor === i }"
          :aria-current="cursor === i ? 'true' : undefined"
          @mouseenter="cursor = i"
          @click="select(i)"
        >
          <td
            v-for="c in cells"
            :key="c.key"
            class="ads-data-grid__td"
            :class="{ 'is-num': c.isNum, num: c.isNum || c.mono }"
          >
            <AdsStatus v-if="c.grade" :grade="asGrade(r[c.key])" :label="gradeLabel[asGrade(r[c.key])]!" />
            <template v-else>{{ display(c, r[c.key]) }}</template>
          </td>
        </tr>
      </tbody>
    </table>
    <span class="sr-only" aria-live="polite">{{ announce }}</span>
  </div>
</template>

<style scoped>
.ads-data-grid {
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset);
  padding: 6px var(--sp-1);
  overflow-x: auto;
}

.ads-data-grid__table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.87rem;
  min-width: 540px;
}

.ads-data-grid__th {
  text-align: left;
  font-weight: 450;
  padding: 0;
}

.ads-data-grid__sort {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  padding: 10px 14px;
  font-family: var(--font-label);
  font-size: 0.62rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--subdued);
  transition: color var(--dur-control) var(--ease);
}

.ads-data-grid__th.is-num .ads-data-grid__sort {
  justify-content: flex-end;
}

.ads-data-grid__sort:hover {
  color: var(--text);
}

/* Units are read once, in the header, quieter than the label they qualify. */
.ads-data-grid__unit {
  text-transform: none;
  letter-spacing: 0.04em;
}

.ads-data-grid__caret {
  opacity: 0;
  font-size: 0.8rem;
  transition:
    opacity var(--dur-control) var(--ease),
    transform var(--dur-control) var(--ease);
}

.ads-data-grid__caret.is-active {
  opacity: 1;
  color: var(--accent);
}

.ads-data-grid__caret.is-desc {
  transform: rotate(180deg);
}

.ads-data-grid__row {
  cursor: default;
}

.ads-data-grid__td {
  padding: 0 14px;
  height: var(--row-h);
  text-align: left;
  color: var(--text-soft);
  border-top: 1px solid var(--line);
  white-space: nowrap;
}

.ads-data-grid__td.is-num {
  text-align: right;
}

.ads-data-grid__row.is-cursor .ads-data-grid__td {
  color: var(--text);
  background: var(--hover);
}

/* The cursor bar is drawn as a box-shadow rather than a border, because a
   border on the focused row shrinks that row's content box and shifts every
   column by exactly its own width. */
.ads-data-grid__row.is-cursor .ads-data-grid__td:first-child {
  box-shadow: inset 2px 0 0 var(--accent);
}
</style>