Accessible name. Required when there is no slot text.
Source · ads/app/components/AdsToggle.vue
vue
<script setup lang="ts">
// A single toggle button — a setting that flips on and stays that way. Off, it
// stands proud like any button; on, it stays pressed into the page with its
// text brightened. The shadow inversion is the state, so the accent fill stays
// free for the one action on the screen. With only a glyph and no words, the
// `label` names it to assistive tech.
withDefaults(defineProps<{ modelValue?: boolean; icon?: string; size?: number; label?: string }>(), {
modelValue: false,
size: 16,
label: undefined,
})
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
</script>
<template>
<button
type="button"
class="ads-toggle"
:class="{ 'is-on': modelValue }"
:aria-pressed="modelValue"
:aria-label="label"
@click="emit('update:modelValue', !modelValue)"
>
<Icon v-if="icon" :name="icon" :size="size" />
<slot />
</button>
</template>
<style scoped>
.ads-toggle {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
border-radius: var(--radius-sm);
font-size: 0.88rem;
font-weight: 500;
color: var(--text-soft);
background: var(--raised);
box-shadow: var(--soft-raised-sm);
transition:
color var(--dur-control) var(--ease),
box-shadow var(--dur-control) var(--ease);
}
.ads-toggle:hover {
color: var(--accent);
}
.ads-toggle.is-on {
color: var(--accent);
background: var(--surface);
box-shadow: var(--soft-inset-sm);
}
</style>
<script setup lang="ts">
// A row of toggle buttons where several can be on at once (bold and italic).
// Not a segmented control: that is one choice of two to five, and fills the
// chosen one with the accent. Here every pressed button stays pressed into the
// page, as AdsToggle does, so three active toggles do not spend the accent
// three times. `label` names the group.
export type ToggleOption = { label: string; value: string | number }
const props = withDefaults(defineProps<{ modelValue?: (string | number)[]; options: ToggleOption[]; label: string }>(), {
modelValue: () => [],
})
const emit = defineEmits<{ (e: 'update:modelValue', v: (string | number)[]): void }>()
function toggle(v: string | number) {
const cur = props.modelValue
emit('update:modelValue', cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v])
}
</script>
<template>
<div class="ads-toggle-group" role="group" :aria-label="label">
<button
v-for="o in options"
:key="o.value"
type="button"
class="ads-toggle-group__button"
:class="{ 'is-on': modelValue.includes(o.value) }"
:aria-pressed="modelValue.includes(o.value)"
@click="toggle(o.value)"
>
{{ o.label }}
</button>
</div>
</template>
<style scoped>
.ads-toggle-group {
display: inline-flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.ads-toggle-group__button {
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-sm);
font-size: 0.83rem;
color: var(--text-soft);
background: var(--raised);
box-shadow: var(--soft-raised-sm);
white-space: nowrap;
transition:
color var(--dur-control) var(--ease),
box-shadow var(--dur-control) var(--ease);
}
.ads-toggle-group__button:hover {
color: var(--accent);
}
.ads-toggle-group__button.is-on {
color: var(--accent);
background: var(--surface);
box-shadow: var(--soft-inset-sm);
}
</style>
AdsAvatar
A soft circle with initials.
ASCD
vue
<AdsAvatar name="Artelier Studio" :size="40" />
<!-- beside a visible name, keep it out of the reading order -->
<AdsAvatar name="Artelier Studio" decorative />
AdsAvatar props
Prop
Type
Default
Description
name
string
Required. Initials come from it; read out unless decorative.
size
number
40
decorative
boolean
false
Hide from assistive tech when the name is shown next to it.
Source · ads/app/components/AdsAvatar.vue
vue
<script setup lang="ts">
// An avatar — a soft circle with initials. Greyscale, like the chrome: a face
// or a brand colour here would out-shout the data beside it. It names the
// person to assistive tech unless the name is already written next to it, in
// which case `decorative` keeps it from being read twice.
const props = withDefaults(defineProps<{ name: string; size?: number; decorative?: boolean }>(), {
size: 40,
decorative: false,
})
const initials = computed(() =>
props.name
.split(/\s+/)
.filter(Boolean)
.map((p) => p[0])
.slice(0, 2)
.join('')
.toUpperCase(),
)
</script>
<template>
<span
class="ads-avatar"
:style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.4)}px` }"
:role="decorative ? undefined : 'img'"
:aria-label="decorative ? undefined : name"
:aria-hidden="decorative ? 'true' : undefined"
>
<span aria-hidden="true">{{ initials }}</span>
</span>
</template>
<style scoped>
.ads-avatar {
display: inline-grid;
place-items: center;
flex: none;
border-radius: 50%;
background: var(--raised);
box-shadow: var(--soft-raised-sm);
font-family: var(--font-label);
font-weight: 500;
color: var(--text-soft);
letter-spacing: 0.04em;
}
</style>
AdsSeparator
A horizontal rule.
Above
Below
vue
<AdsSeparator />
Source · ads/app/components/AdsSeparator.vue
vue
<script setup lang="ts">
// A horizontal rule — the whisper that separates rows or sections. One pixel
// of `--line`, never a heavier rule: depth does the structural work here.
</script>
<template>
<hr class="ads-separator" />
</template>
<style scoped>
.ads-separator {
height: 1px;
margin: 0;
border: 0;
background: var(--line);
}
</style>
AdsCollapsible
A single collapsible.
Why the chrome is grey
So colour means something when it appears.
vue
<AdsCollapsible v-model:open="open" title="Why the chrome is grey">
So colour means something when it appears.
</AdsCollapsible>
AdsCollapsible props
Prop
Type
Default
Description
title
string
Required. The summary line.
v-model:open
boolean
false
Source · ads/app/components/AdsCollapsible.vue
vue
<script setup lang="ts">
// A single collapsible — native details/summary, so it works before JS and the
// browser does the keyboard and the aria-expanded for us. For the reasoning
// most readers skip and some want; never for something every reader needs.
// `v-model:open` follows the reader's clicks.
const open = defineModel<boolean>('open', { default: false })
defineProps<{ title: string }>()
function onToggle(e: Event) {
open.value = (e.target as HTMLDetailsElement).open
}
</script>
<template>
<details class="ads-collapsible" :open="open || undefined" @toggle="onToggle">
<summary class="ads-collapsible__summary">{{ title }}</summary>
<div class="ads-collapsible__panel"><slot /></div>
</details>
</template>
<style scoped>
.ads-collapsible {
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--soft-inset-sm);
overflow: hidden;
}
.ads-collapsible__summary {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
cursor: pointer;
font-size: 0.9rem;
color: var(--text-soft);
list-style: none;
transition: color var(--dur-control) var(--ease);
}
.ads-collapsible__summary::-webkit-details-marker {
display: none;
}
.ads-collapsible__summary:hover {
color: var(--accent);
}
/* The chevron: two strokes of currentColor, so it brightens with the text. */
.ads-collapsible__summary::before {
content: '';
width: 7px;
height: 7px;
flex: none;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(-45deg);
transition: transform var(--dur-control) var(--ease);
}
.ads-collapsible[open] > .ads-collapsible__summary::before {
transform: rotate(45deg);
}
.ads-collapsible__panel {
padding: 0 var(--sp-4) var(--sp-4) 34px;
font-size: 0.86rem;
color: var(--subdued);
}
</style>
AdsDatePicker
One ISO date, typed or picked from a calendar, with presets.
Drawn in slot order --series-1..8; a ninth and beyond fold into "Other".
points
number[]
Shorthand for a single series named by label.
labels
string[]
X-axis labels; first and last are shown, all go in the table fallback.
height
number
160
Source · ads/app/components/AdsLineChart.vue
vue
<script setup lang="ts">
// A line chart for a trend over an ordered axis. Hue is spent only on the
// lines, in slot order (--series-1 first); past eight, the remainder is summed
// into a grey "Other" rather than inventing a ninth colour. Everything that is
// text — the scale, the axis ends, the key — is in the text tokens, never in a
// series colour, and the scale is recessive: two figures and one baseline, no
// grid, no area fill. `label` names the figure and carries the unit
// ("Sessions per day, thousands"); the same data sits in a table for screen
// readers.
export type LineSeries = { name: string; points: number[] }
const props = withDefaults(
defineProps<{
label: string
series?: LineSeries[]
points?: number[]
labels?: string[]
height?: number
}>(),
{ series: () => [], points: () => [], labels: () => [], height: 160 },
)
const W = 600
const H = 160
const PAD = 6
const MAX_SLOTS = 8
const fmt = (n: number) => n.toLocaleString('en-GB', { maximumFractionDigits: 2 })
type Drawn = LineSeries & { colour: string; other: boolean }
const drawn = computed<Drawn[]>(() => {
const all = props.series.length ? props.series : props.points.length ? [{ name: props.label, points: props.points }] : []
const kept: Drawn[] = all.slice(0, MAX_SLOTS).map((s, i) => ({ ...s, colour: `var(--series-${i + 1})`, other: false }))
const rest = all.slice(MAX_SLOTS)
if (rest.length) {
const len = Math.max(...rest.map((s) => s.points.length))
const sum = Array.from({ length: len }, (_, i) => rest.reduce((t, s) => t + (s.points[i] ?? 0), 0))
kept.push({ name: 'Other', points: sum, colour: 'var(--subdued)', other: true })
}
return kept
})
const length = computed(() => Math.max(0, ...drawn.value.map((s) => s.points.length)))
const scale = computed(() => {
const values = drawn.value.flatMap((s) => s.points)
if (!values.length) return { min: 0, max: 0 }
return { min: Math.min(...values), max: Math.max(...values) }
})
function path(points: number[]) {
if (points.length < 2) return ''
const { min, max } = scale.value
const span = max - min || 1
const step = W / Math.max(length.value - 1, 1)
return points
.map((v, i) => `${i ? 'L' : 'M'}${(i * step).toFixed(1)} ${(H - PAD - ((v - min) / span) * (H - PAD * 2)).toFixed(1)}`)
.join(' ')
}
const xLabels = computed(() =>
Array.from({ length: length.value }, (_, i) => props.labels[i] ?? String(i + 1)),
)
</script>
<template>
<figure class="ads-line-chart" :aria-label="label">
<div class="ads-line-chart__plot" aria-hidden="true">
<div class="ads-line-chart__scale num">
<span>{{ fmt(scale.max) }}</span>
<span>{{ fmt(scale.min) }}</span>
</div>
<svg
class="ads-line-chart__svg"
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
:style="{ height: `${height}px` }"
focusable="false"
>
<line class="ads-line-chart__baseline" x1="0" :y1="H - 0.5" :x2="W" :y2="H - 0.5" vector-effect="non-scaling-stroke" />
<g class="ads-line-chart__lines">
<path
v-for="s in drawn"
:key="s.name"
:d="path(s.points)"
fill="none"
:stroke="s.colour"
stroke-width="2"
vector-effect="non-scaling-stroke"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>
<div v-if="length" class="ads-line-chart__axis num">
<span>{{ xLabels[0] }}</span>
<span>{{ xLabels[length - 1] }}</span>
</div>
</div>
<ul class="ads-line-chart__key" aria-hidden="true">
<li v-for="s in drawn" :key="s.name" class="ads-line-chart__entry">
<span class="ads-line-chart__swatch" :style="{ background: s.colour }" />
<span class="ads-line-chart__name">{{ s.name }}</span>
<span class="ads-line-chart__last num">{{ fmt(s.points[s.points.length - 1] ?? 0) }}</span>
</li>
</ul>
<table class="sr-only">
<caption>{{ label }}</caption>
<thead>
<tr>
<th scope="col">Point</th>
<th v-for="s in drawn" :key="s.name" scope="col">{{ s.name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(x, i) in xLabels" :key="i">
<th scope="row">{{ x }}</th>
<td v-for="s in drawn" :key="s.name">{{ s.points[i] == null ? '' : fmt(s.points[i]!) }}</td>
</tr>
</tbody>
</table>
</figure>
</template>
<style scoped>
.ads-line-chart {
display: grid;
gap: var(--sp-3);
margin: 0;
}
.ads-line-chart__plot {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: var(--sp-2);
}
.ads-line-chart__scale {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
font-size: 0.68rem;
color: var(--subdued);
}
.ads-line-chart__svg {
display: block;
width: 100%;
overflow: visible;
}
.ads-line-chart__baseline {
stroke: var(--line);
stroke-width: 1;
}
.ads-line-chart__lines {
animation: ads-line-chart-draw var(--dur-enter) var(--ease);
}
.ads-line-chart__axis {
grid-column: 2;
display: flex;
justify-content: space-between;
padding-top: var(--sp-1);
font-size: 0.68rem;
color: var(--subdued);
}
.ads-line-chart__key {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
margin: 0;
padding: 0;
list-style: none;
}
.ads-line-chart__entry {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-size: 0.8rem;
}
.ads-line-chart__swatch {
width: 12px;
height: 3px;
border-radius: var(--pill);
}
.ads-line-chart__name {
color: var(--text-soft);
}
.ads-line-chart__last {
color: var(--text);
}
/* The lines draw left to right, once, on arrival. */
@keyframes ads-line-chart-draw {
from {
clip-path: inset(0 100% 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
</style>
AdsAlertDialog
A confirmation that says its verb. Destructive by default, so the action stays off the accent.
vue
<AdsAlertDialog
v-model:open="open"
title="Delete project?"
description="Its crawls, history and comparisons go with it."
action-label="Delete project"
@action="remove"
/>
AdsAlertDialog props
Prop
Type
Default
Description
v-model:open
boolean
false
title
string
Required. A question.
action-label
string
Required. The verb and object — "Delete project".
description
string
cancel-label
string
Cancel
destructive
boolean
true
Keeps the action off the accent and focuses Cancel first.
Source · ads/app/components/AdsAlertDialog.vue
vue
<script setup lang="ts">
// A confirmation that interrupts. It is for one irreversible step, so it says
// the verb on its button ("Delete project", never "OK") and, when that step is
// destructive, the button stays in the default style — the one accent on the
// screen never points at a loss — and focus starts on Cancel. Escape cancels,
// Tab stays inside, a click on the scrim does nothing (this is a decision, not
// a notice), and focus goes back to whatever opened it.
const props = withDefaults(
defineProps<{
title: string
actionLabel: string
description?: string
cancelLabel?: string
destructive?: boolean
}>(),
{ description: '', cancelLabel: 'Cancel', destructive: true },
)
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ (e: 'action'): void; (e: 'cancel'): void }>()
const uid = useId()
const panel = ref<HTMLElement>()
const cancelBtn = ref<HTMLButtonElement>()
const actionBtn = ref<HTMLButtonElement>()
let returnTo: HTMLElement | null = null
let prevOverflow = ''
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
function cancel() {
open.value = false
emit('cancel')
}
function act() {
open.value = false
emit('action')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
cancel()
return
}
if (e.key !== 'Tab' || !panel.value) return
const items = Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE))
if (!items.length) return
const first = items[0]!
const last = items[items.length - 1]!
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(
open,
async (isOpen) => {
if (!false) return
if (isOpen) {
returnTo = document.activeElement as HTMLElement | null
prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
await nextTick()
;(props.destructive ? cancelBtn.value : actionBtn.value)?.focus()
} else {
document.body.style.overflow = prevOverflow
returnTo?.focus?.()
returnTo = null
}
},
{ immediate: true },
)
onBeforeUnmount(() => {
if (false && open.value) document.body.style.overflow = prevOverflow
})
</script>
<template>
<Teleport to="body">
<div v-if="open" class="ads-alert-dialog" @keydown="onKeydown">
<div class="ads-alert-dialog__scrim" aria-hidden="true" />
<div
ref="panel"
class="ads-alert-dialog__panel ads-modal"
role="alertdialog"
aria-modal="true"
:aria-labelledby="`${uid}-title`"
:aria-describedby="description ? `${uid}-desc` : undefined"
>
<h2 :id="`${uid}-title`" class="ads-alert-dialog__title">{{ title }}</h2>
<p v-if="description" :id="`${uid}-desc`" class="ads-alert-dialog__desc">{{ description }}</p>
<slot />
<div class="ads-alert-dialog__actions">
<button ref="cancelBtn" type="button" class="btn btn-quiet" @click="cancel">{{ cancelLabel }}</button>
<button ref="actionBtn" type="button" class="btn" :class="{ 'btn-accent': !destructive }" @click="act">
{{ actionLabel }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.ads-alert-dialog {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: var(--sp-5);
}
.ads-alert-dialog__scrim {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 0.45);
backdrop-filter: blur(2px);
}
.ads-alert-dialog__panel {
position: relative;
display: grid;
gap: var(--sp-3);
animation: ads-alert-dialog-in var(--dur-move) var(--ease);
}
.ads-alert-dialog__title {
margin: 0;
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 600;
color: var(--text);
}
.ads-alert-dialog__desc {
margin: 0;
font-size: 0.9rem;
line-height: 1.55;
color: var(--text-soft);
}
.ads-alert-dialog__actions {
display: flex;
justify-content: flex-end;
gap: var(--sp-3);
margin-top: var(--sp-3);
}
@keyframes ads-alert-dialog-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
</style>
AdsTypography
Text in one of the system's type roles.
Less, but done well.
A quieter heading
The chrome is grey so the data can be coloured.
A caption, for the fine print.
vue
<AdsTypography role="display">Less, but done well.</AdsTypography>
<AdsTypography role="eyebrow">A quieter heading</AdsTypography>
Source · ads/app/components/AdsTypography.vue
vue
<script setup lang="ts">
// Text in one of the system's type roles, so a page never invents a size. Each
// role has one face: display for headlines, UI for reading, the label face for
// eyebrows, the data face for figures. Roles stay in text tokens — the chrome
// is grey so the data can be coloured. `as` picks the element, so a display
// line can still be the page's h1.
withDefaults(
defineProps<{ as?: string; role?: 'display' | 'title' | 'heading' | 'body' | 'lede' | 'eyebrow' | 'caption' | 'mono' }>(),
{ as: 'p', role: 'body' },
)
</script>
<template>
<component :is="as" class="ads-typography" :class="`is-${role}`"><slot /></component>
</template>
<style scoped>
.ads-typography {
margin: 0;
}
.ads-typography.is-display {
font-family: var(--font-display);
font-weight: 600;
font-size: clamp(2.4rem, 5vw, 3.6rem);
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--text);
}
.ads-typography.is-title {
font-family: var(--font-display);
font-weight: 600;
font-size: clamp(1.7rem, 3vw, 2.3rem);
line-height: 1.1;
letter-spacing: -0.015em;
color: var(--text);
}
.ads-typography.is-heading {
font-family: var(--font-display);
font-weight: 600;
font-size: 1.25rem;
line-height: 1.2;
color: var(--text);
}
.ads-typography.is-body {
font-family: var(--font);
font-size: 0.95rem;
line-height: 1.65;
color: var(--text-soft);
}
.ads-typography.is-lede {
font-family: var(--font);
font-size: 1.05rem;
line-height: 1.6;
color: var(--text);
}
/* Eyebrows are labels, so they take the label face, as `.ads-label` does. */
.ads-typography.is-eyebrow {
font-family: var(--font-label);
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--subdued);
}
.ads-typography.is-caption {
font-family: var(--font);
font-size: 0.78rem;
line-height: 1.5;
color: var(--subdued);
}
.ads-typography.is-mono {
font-family: var(--mono);
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
color: var(--text);
}
</style>
<script setup lang="ts">
// A box that holds its proportions while its width changes, so an image or a
// chart reserves its space before it loads and nothing below it jumps. It is a
// well: content sits pressed into the card, never on the canvas.
withDefaults(defineProps<{ ratio?: string }>(), { ratio: '16 / 9' })
</script>
<template>
<div class="ads-aspect-ratio" :style="{ aspectRatio: ratio }"><slot /></div>
</template>
<style scoped>
.ads-aspect-ratio {
position: relative;
width: 100%;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--soft-inset-sm);
}
</style>
<script setup lang="ts">
// A form label, above its field — in the label face, like every label in the
// system. It is the global `.ads-label`, so it cannot drift from the field
// labels the rules page shows. A field always has one: placeholder text is
// gone exactly when it is needed.
defineProps<{ for?: string }>()
</script>
<template>
<label class="ads-label" :for="$props.for"><slot /></label>
</template>
AdsKbd
A keyboard shortcut, one key per cap.
⌘KEsc
vue
<AdsKbd :keys="['⌘', 'K']" />
AdsKbd props
Prop
Type
Default
Description
keys
string[]
Required. One key per entry.
Source · ads/app/components/AdsKbd.vue
vue
<script setup lang="ts">
// A keyboard shortcut: one key, or a chord. Keys sit in the data face, pressed
// into the page like every other piece of reference text. Nested <kbd>s are
// how HTML spells "press these together".
defineProps<{ keys: string[] }>()
</script>
<template>
<kbd class="ads-kbd">
<kbd v-for="(k, i) in keys" :key="i" class="kbd">{{ k }}</kbd>
</kbd>
</template>
<style scoped>
.ads-kbd {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
font-family: var(--mono);
}
</style>
AdsCodeBlock
A code block with a copy button.
ts
const chrome = grey
const data = coloured
vue
<AdsCodeBlock :code="'const n = 42'" language="ts" />
Source · ads/app/components/AdsCodeBlock.vue
vue
<script setup lang="ts">
// A code block with a copy button. Code wraps rather than scrolling sideways:
// with no scrollbars anywhere, a cut-off line would give no sign there is more.
// The button says what it copies, and the confirmation is announced, not just
// shown.
const props = withDefaults(defineProps<{ code: string; language?: string }>(), { language: '' })
const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
async function copy() {
try {
await navigator.clipboard.writeText(props.code)
} catch {
// Clipboard unavailable; the code is still readable and selectable.
return
}
copied.value = true
clearTimeout(timer)
timer = setTimeout(() => (copied.value = false), 1600)
}
onBeforeUnmount(() => clearTimeout(timer))
</script>
<template>
<div class="ads-code-block">
<div class="ads-code-block__bar">
<span v-if="language" class="ads-label">{{ language }}</span>
<button type="button" class="ads-code-block__copy" @click="copy">
<span aria-live="polite">{{ copied ? 'Copied' : 'Copy code' }}</span>
</button>
</div>
<pre class="ads-code-block__pre"><code>{{ code }}</code></pre>
</div>
</template>
<style scoped>
.ads-code-block {
display: grid;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4) var(--sp-4);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--soft-inset-sm);
}
.ads-code-block__bar {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.ads-code-block__copy {
margin-left: auto;
padding: var(--sp-1) var(--sp-3);
border-radius: var(--pill);
background: var(--raised);
box-shadow: var(--soft-raised-sm);
font-family: var(--mono);
font-size: 0.68rem;
color: var(--text-soft);
transition:
color var(--dur-control) var(--ease),
box-shadow var(--dur-control) var(--ease);
}
.ads-code-block__copy:hover {
color: var(--accent);
}
.ads-code-block__copy:active {
box-shadow: var(--soft-inset-sm);
}
.ads-code-block__pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 0.8rem;
line-height: 1.6;
color: var(--text-soft);
}
</style>
AdsMarker
A status marker: a tone or grade, always with a label and its own glyph.
<AdsItem icon="chart">Audience overview</AdsItem>
<AdsItem icon="alert" to="/issues">4 issues to fix</AdsItem>
AdsItem props
Prop
Type
Default
Description
icon
string
to
string
Makes the row a link; only links react to hover.
Source · ads/app/components/AdsItem.vue
vue
<script setup lang="ts">
// A list item: an optional glyph, then the body. It only answers the pointer
// when it goes somewhere — give it `to` and it becomes a real link that
// brightens on hover; without one it is plain text, because a row that lights
// up and does nothing is a promise the interface breaks. Rows in a list are
// separated by the parent, with a hairline, not by a box around each.
defineProps<{ icon?: string; iconSize?: number; to?: string }>()
</script>
<template>
<NuxtLink v-if="to" :to="to" class="ads-item is-link">
<Icon v-if="icon" :name="icon" :size="iconSize ?? 18" class="ads-item__icon" />
<span class="ads-item__body"><slot /></span>
</NuxtLink>
<div v-else class="ads-item">
<Icon v-if="icon" :name="icon" :size="iconSize ?? 18" class="ads-item__icon" />
<span class="ads-item__body"><slot /></span>
</div>
</template>
<style scoped>
.ads-item {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-sm);
color: var(--text-soft);
transition:
color var(--dur-control) var(--ease),
box-shadow var(--dur-control) var(--ease);
}
.ads-item.is-link:hover {
color: var(--accent);
}
.ads-item.is-link:hover .ads-item__icon {
color: var(--accent);
}
.ads-item.is-link:active {
box-shadow: var(--soft-inset-sm);
}
.ads-item__icon {
flex: none;
color: var(--subdued);
transition: color var(--dur-control) var(--ease);
}
.ads-item__body {
min-width: 0;
}
</style>
AdsBubble
A chat bubble. Theirs is pressed in, yours is raised.
Hello from the studio.
On it — back in an hour.
vue
<AdsBubble>Hello from the studio.</AdsBubble>
<AdsBubble mine>On it — back in an hour.</AdsBubble>
Source · ads/app/components/AdsBubble.vue
vue
<script setup lang="ts">
// A chat bubble. Theirs is a well; `mine` stands proud of the page instead —
// depth and alignment tell the two voices apart. Not the accent: a thread
// would spend it on every other line, and the accent is for the one thing to
// click.
withDefaults(defineProps<{ mine?: boolean }>(), { mine: false })
</script>
<template>
<div class="ads-bubble" :class="{ 'is-mine': mine }"><slot /></div>
</template>
<style scoped>
.ads-bubble {
width: fit-content;
max-width: 72ch;
padding: var(--sp-2) var(--sp-4);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--soft-inset-sm);
font-size: 0.9rem;
color: var(--text-soft);
}
.ads-bubble.is-mine {
background: var(--raised);
box-shadow: var(--soft-raised-sm);
color: var(--text);
}
</style>
<script setup lang="ts">
// A box that scrolls. Scrollbars are hidden site-wide, so the area carries its
// own cue: whichever edge still has content behind it fades out, and the fade
// goes away once you reach that end. It is a labelled, focusable region, so
// the keyboard can scroll it as well.
const props = withDefaults(defineProps<{ label: string; height?: string; axis?: 'y' | 'x' | 'both' }>(), {
height: '',
axis: 'y',
})
const el = ref<HTMLElement>()
const more = reactive({ top: false, bottom: false, left: false, right: false })
let ro: ResizeObserver | undefined
function measure() {
const s = el.value
if (!s) return
const y = props.axis !== 'x'
const x = props.axis !== 'y'
more.top = y && s.scrollTop > 1
more.bottom = y && s.scrollTop + s.clientHeight < s.scrollHeight - 1
more.left = x && s.scrollLeft > 1
more.right = x && s.scrollLeft + s.clientWidth < s.scrollWidth - 1
}
onMounted(() => {
measure()
ro = new ResizeObserver(measure)
if (el.value) {
ro.observe(el.value)
for (const c of Array.from(el.value.children)) ro.observe(c)
}
})
onBeforeUnmount(() => ro?.disconnect())
</script>
<template>
<div
ref="el"
class="ads-scroll-area"
:class="[
`is-${axis}`,
{ 'is-more-top': more.top, 'is-more-bottom': more.bottom, 'is-more-left': more.left, 'is-more-right': more.right },
]"
:style="height ? { height } : undefined"
role="region"
:aria-label="label"
tabindex="0"
@scroll.passive="measure"
>
<slot />
</div>
</template>
<style scoped>
.ads-scroll-area {
--ads-scroll-area-fade-top: 0px;
--ads-scroll-area-fade-bottom: 0px;
--ads-scroll-area-fade-left: 0px;
--ads-scroll-area-fade-right: 0px;
overflow: auto;
border-radius: var(--radius-sm);
mask-image:
linear-gradient(
to bottom,
transparent 0,
#000 var(--ads-scroll-area-fade-top),
#000 calc(100% - var(--ads-scroll-area-fade-bottom)),
transparent 100%
),
linear-gradient(
to right,
transparent 0,
#000 var(--ads-scroll-area-fade-left),
#000 calc(100% - var(--ads-scroll-area-fade-right)),
transparent 100%
);
mask-composite: intersect;
}
.ads-scroll-area.is-y {
overflow-x: hidden;
}
.ads-scroll-area.is-x {
overflow-y: hidden;
}
.ads-scroll-area.is-more-top {
--ads-scroll-area-fade-top: var(--sp-6);
}
.ads-scroll-area.is-more-bottom {
--ads-scroll-area-fade-bottom: var(--sp-6);
}
.ads-scroll-area.is-more-left {
--ads-scroll-area-fade-left: var(--sp-6);
}
.ads-scroll-area.is-more-right {
--ads-scroll-area-fade-right: var(--sp-6);
}
</style>
AdsSpinner
A bare spinner with an accessible label.
vue
<AdsSpinner :size="22" label="Loading crawl" />
AdsSpinner props
Prop
Type
Default
Description
size
number
18
label
string
Loading
Source · ads/app/components/AdsSpinner.vue
vue
<script setup lang="ts">
// A bare spinner, for an inline wait of unknown shape — next to the thing that
// is loading, never over the page. Where the shape is known, use a skeleton.
// The global rule stops it turning for readers who asked for less motion.
withDefaults(defineProps<{ size?: number; label?: string }>(), { size: 18, label: 'Loading' })
</script>
<template>
<span class="ads-spinner" :style="{ width: `${size}px`, height: `${size}px` }" role="status" :aria-label="label" />
</template>
<script setup lang="ts">
// The column a thread lives in. It is a live log: new messages are announced
// politely, and the view follows them only if the reader was already at the
// bottom — scrolling up to re-read is never yanked away. Focusable, so the
// keyboard can scroll it with no visible scrollbar.
const props = withDefaults(defineProps<{ label?: string; stick?: boolean }>(), {
label: 'Messages',
stick: true,
})
const el = ref<HTMLElement>()
let pinned = true
let observer: MutationObserver | undefined
function nearBottom() {
const s = el.value
return !!s && s.scrollHeight - s.scrollTop - s.clientHeight < 48
}
function toBottom() {
el.value?.scrollTo({ top: el.value.scrollHeight })
}
onMounted(() => {
if (!props.stick || !el.value) return
toBottom()
observer = new MutationObserver(() => {
if (pinned) toBottom()
})
observer.observe(el.value, { childList: true, subtree: true, characterData: true })
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<template>
<div
ref="el"
class="ads-message-scroller"
role="log"
aria-live="polite"
:aria-label="label"
tabindex="0"
@scroll.passive="pinned = nearBottom()"
>
<slot />
</div>
</template>
<style scoped>
.ads-message-scroller {
display: grid;
align-content: start;
gap: var(--sp-3);
overflow-y: auto;
padding: var(--sp-1) 2px;
border-radius: var(--radius-sm);
}
</style>