Start a project

Sections

Page sections

Navigation, heroes, footers and the content blocks between them.

HeroSection

A big display headline over an optional visual. Actions and visual are slots: one accent link for the action the page exists for, a default one beside it.

Studio

The dashboard your users live in

Grey chrome, coloured data.

vue
<HeroSection eyebrow="Studio" title="The dashboard your users live in" lead="…" align="center">
  <template #actions>
    <NuxtLink to="/contact" class="btn btn-accent">Start a project</NuxtLink>
    <NuxtLink to="/work" class="btn">See the work</NuxtLink>
  </template>
  <template #visual><AdsBars :data="bars" label="Crawls by weekday" /></template>
</HeroSection>
HeroSection props
PropTypeDefaultDescription
eyebrowstringSmall label above the title.
titlestring—The headline.
leadstringSupporting sentence.
aligncenter | leftcenterText alignment.
Source · ads/app/components/HeroSection.vue
vue
<script setup lang="ts">
// The hero: a big display headline over a soft visual. The visual is a slot,
// so the landing page can drop in a chart or a dashboard mock.
//
// Actions are a slot too, but they follow the studio's hero: one accent link
// on the action the page exists for, a default one beside it. If the nav or
// anything else above the fold already spends the accent, this does not.
withDefaults(
  defineProps<{ eyebrow?: string; title: string; lead?: string; align?: 'center' | 'left' }>(),
  { eyebrow: '', lead: '', align: 'center' },
)
</script>

<template>
  <section class="ads-hero-section">
    <div class="ads-hero-section__inner max" :class="{ 'is-center': align === 'center' }">
      <div class="ads-hero-section__copy">
        <p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
        <h1 class="ads-hero-section__title">{{ title }}</h1>
        <p v-if="lead" class="lede ads-hero-section__lead">{{ lead }}</p>
        <div v-if="$slots.actions" class="ads-hero-section__actions">
          <slot name="actions" />
        </div>
      </div>
      <div v-if="$slots.visual" class="ads-hero-section__visual">
        <slot name="visual" />
      </div>
    </div>
  </section>
</template>

<style scoped>
.ads-hero-section {
  padding: clamp(56px, 9vw, 120px) 0 var(--sp-7);
}

.ads-hero-section__inner {
  display: grid;
  gap: var(--sp-7);
  padding: 0 clamp(20px, 4vw, 48px);
}

.ads-hero-section__inner.is-center {
  justify-items: center;
  text-align: center;
}

.ads-hero-section__copy {
  display: grid;
  gap: var(--sp-4);
  justify-items: start;
  max-width: 720px;
}

.ads-hero-section__inner.is-center .ads-hero-section__copy {
  justify-items: center;
}

.ads-hero-section__title {
  font-family: var(--font-display);
  font-size: clamp(2.6rem, 7vw, 4.6rem);
  line-height: 1.02;
  letter-spacing: -0.035em;
  font-weight: 600;
}

.ads-hero-section__lead {
  max-width: 640px;
}

.ads-hero-section__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  margin-top: var(--sp-2);
}

.ads-hero-section__inner.is-center .ads-hero-section__actions {
  justify-content: center;
}

.ads-hero-section__visual {
  width: 100%;
  max-width: 980px;
}
</style>

ContentSection

A titled block — eyebrow, headline, lede — with any body in the default slot.

Why

A system, not a style guide

Depth is the only decoration.

vue
<ContentSection eyebrow="Why" title="A system, not a style guide" lead="…">
  <FeatureGrid :features="features" />
</ContentSection>
ContentSection props
PropTypeDefaultDescription
eyebrowstring
titlestring—
leadstring
aligncenter | leftleft
Source · ads/app/components/ContentSection.vue
vue
<script setup lang="ts">
// A content section: eyebrow, headline and lede, then any body. The workhorse
// of a marketing page — a titled block of prose or a titled grid. The head is
// the studio's section head: label, display headline, one lede, and the body
// below in cards rather than strips.
withDefaults(
  defineProps<{ eyebrow?: string; title: string; lead?: string; align?: 'center' | 'left' }>(),
  { eyebrow: '', lead: '', align: 'left' },
)
</script>

<template>
  <section class="ads-content-section">
    <div class="ads-content-section__inner max">
      <header class="ads-content-section__head" :class="{ 'is-center': align === 'center' }">
        <p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
        <h2 class="ads-content-section__title h2">{{ title }}</h2>
        <p v-if="lead" class="lede">{{ lead }}</p>
      </header>
      <div class="ads-content-section__body"><slot /></div>
    </div>
  </section>
</template>

<style scoped>
.ads-content-section {
  padding: var(--sp-8) 0;
}

.ads-content-section__inner {
  display: grid;
  gap: var(--sp-7);
  padding: 0 clamp(20px, 4vw, 48px);
}

.ads-content-section__head {
  display: grid;
  gap: var(--sp-4);
  max-width: 680px;
}

.ads-content-section__head.is-center {
  justify-items: center;
  justify-self: center;
  text-align: center;
}

.ads-content-section__title {
  font-family: var(--font-display);
}
</style>

ImageSection

Copy on one side, a soft-framed visual on the other. `flip` swaps them.

Soft UI

Depth is the only decoration

A raised surface is available; a pressed-in surface holds something.

vue
<ImageSection eyebrow="Soft UI" title="Depth is the only decoration" body="…" flip>
  <AdsGauge :value="64" :threshold="75" label="Scroll" unit="%" />
</ImageSection>
ImageSection props
PropTypeDefaultDescription
eyebrowstring
titlestring—
bodystring
flipbooleanfalseSwap copy and visual.
Source · ads/app/components/ImageSection.vue
vue
<script setup lang="ts">
// A split section: copy on one side, an image (or any visual) on the other.
// `flip` swaps them. The visual is a slot and sits in a raised frame — depth,
// not a border, is what separates it from the page.
withDefaults(defineProps<{ eyebrow?: string; title: string; body?: string; flip?: boolean }>(), { eyebrow: '', body: '', flip: false })
</script>

<template>
  <section class="ads-image-section">
    <div class="ads-image-section__inner max" :class="{ 'is-flipped': flip }">
      <div class="ads-image-section__copy">
        <p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
        <h2 class="ads-image-section__title h2">{{ title }}</h2>
        <p v-if="body" class="ads-image-section__body">{{ body }}</p>
        <slot name="actions" />
      </div>
      <figure class="ads-image-section__frame">
        <slot />
      </figure>
    </div>
  </section>
</template>

<style scoped>
.ads-image-section {
  padding: var(--sp-8) 0;
}

.ads-image-section__inner {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(32px, 5vw, 72px);
  align-items: center;
  padding: 0 clamp(20px, 4vw, 48px);
}

.ads-image-section__inner.is-flipped .ads-image-section__copy {
  order: 2;
}

.ads-image-section__copy {
  display: grid;
  gap: var(--sp-4);
  justify-items: start;
}

.ads-image-section__title {
  font-family: var(--font-display);
}

.ads-image-section__body {
  color: var(--subdued);
  font-size: 1rem;
  max-width: 52ch;
}

.ads-image-section__frame {
  margin: 0;
  padding: clamp(18px, 3vw, 30px);
  border-radius: var(--radius-lg);
  background: var(--raised);
  box-shadow: var(--soft-raised);
  overflow: hidden;
}

@media (max-width: 820px) {
  .ads-image-section__inner {
    grid-template-columns: 1fr;
  }
  .ads-image-section__inner.is-flipped .ads-image-section__copy {
    order: 0;
  }
}
</style>

FeatureGrid

A responsive grid of feature cards — glyph, title, line of body.

Grey chrome

Interface colour and data colour compete; give the data the channel.

Depth only

No borders. Two shadows on a matching surface are the whole decoration.

Monospaced numbers

Counts and codes line up column to column.

vue
<FeatureGrid :features="[{ icon: 'gauge', title: 'Grey chrome', body: '…' }]" :columns="3" />
FeatureGrid props
PropTypeDefaultDescription
features{ icon, title, body }[]—
columns2 | 3 | 43
Source · ads/app/components/FeatureGrid.vue
vue
<script setup lang="ts">
// A grid of feature cards. Each is a raised card with a glyph in a well, a
// title and a line of body — the studio's service cards. Hover presses the
// card in rather than lifting it.
export type Feature = { icon: string; title: string; body: string }

withDefaults(defineProps<{ features: Feature[]; columns?: 2 | 3 | 4 }>(), { columns: 3 })
</script>

<template>
  <div class="ads-feature-grid" :style="{ '--ads-feature-grid-cols': columns }">
    <article v-for="f in features" :key="f.title" class="card card-press ads-feature-grid__item">
      <span class="ads-feature-grid__glyph"><Icon :name="f.icon" :size="20" /></span>
      <h3 class="ads-feature-grid__title h3">{{ f.title }}</h3>
      <p class="ads-feature-grid__body">{{ f.body }}</p>
    </article>
  </div>
</template>

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

@media (min-width: 760px) {
  .ads-feature-grid {
    grid-template-columns: repeat(var(--ads-feature-grid-cols, 3), 1fr);
  }
}

.ads-feature-grid__item {
  display: grid;
  gap: var(--sp-3);
  align-content: start;
  justify-items: start;
}

.ads-feature-grid__glyph {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin-bottom: var(--sp-1);
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  color: var(--text-soft);
}

.ads-feature-grid__title {
  color: var(--text);
}

.ads-feature-grid__body {
  font-size: 0.9rem;
  color: var(--subdued);
}
</style>

LogoMarquee

A quiet row of wordmarks, kept monochrome so they never compete with data. It wraps rather than scrolls.

  • Northwind
  • Vantage
  • Halo
  • Meridian
vue
<LogoMarquee :names="['Northwind', 'Vantage', 'Halo']" />
LogoMarquee props
PropTypeDefaultDescription
namesstring[]6 defaultsWordmarks to show.
Source · ads/app/components/LogoMarquee.vue
vue
<script setup lang="ts">
// A row of wordmarks, for "used by" or partner logos. Kept monochrome so they
// do not compete with the data; the accent is not spent here.
//
// It wraps rather than scrolls. A logo row is read once, and a strip that
// moves forever costs every reader attention to show the same six names —
// so there is nothing here for reduced motion to stop.
withDefaults(defineProps<{ names?: string[] }>(), {
  names: () => ['Northwind', 'Vantage', 'Halo', 'Meridian', 'Atlas', 'Orbital'],
})
</script>

<template>
  <ul class="ads-logo-marquee" role="list">
    <li v-for="(n, i) in names" :key="i" class="ads-logo-marquee__name">{{ n }}</li>
  </ul>
</template>

<style scoped>
.ads-logo-marquee {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3) var(--sp-7);
  justify-content: center;
  align-items: center;
  margin: 0;
  padding: 0;
  list-style: none;
}

.ads-logo-marquee__name {
  font-family: var(--font-label);
  font-size: 0.82rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--subdued);
  white-space: nowrap;
}
</style>

Marquee

A scrolling band of pills. Pauses on hover, on focus and from its own toggle; under reduced motion it never moves and the pills wrap.

  • Accessibility
  • SEO
  • Performance
  • Content
  • Structure
vue
<Marquee :items="['Accessibility', 'SEO', 'Performance', 'Content']" />
Marquee props
PropTypeDefaultDescription
itemsstring[]6 defaultsPill labels.
Source · ads/app/components/Marquee.vue
vue
<script setup lang="ts">
// A scrolling band of pills — a quiet way to show breadth. The second lane is
// a copy for the seamless loop, hidden from assistive tech.
//
// Anything that moves for more than five seconds needs a way to stop it, so
// the band pauses on hover, on focus, and from its own toggle. Under reduced
// motion it never starts: the pills wrap into a still row instead.
withDefaults(
  defineProps<{ items?: string[] }>(),
  { items: () => ['Accessibility', 'SEO', 'Performance', 'Content', 'Structure', 'Security'] },
)

const paused = ref(false)
</script>

<template>
  <div class="ads-marquee" :class="{ 'is-paused': paused }">
    <div class="ads-marquee__viewport">
      <div class="ads-marquee__track">
        <ul v-for="copy in 2" :key="copy" class="ads-marquee__lane" role="list" :aria-hidden="copy === 2 ? 'true' : undefined">
          <li v-for="(item, i) in items" :key="i" class="ads-marquee__item">{{ item }}</li>
        </ul>
      </div>
    </div>
    <button type="button" class="btn btn-quiet btn-sm ads-marquee__toggle" :aria-pressed="paused" @click="paused = !paused">
      Pause motion
    </button>
  </div>
</template>

<style scoped>
.ads-marquee {
  display: grid;
  justify-items: center;
  gap: var(--sp-2);
}

/* The fade at either end is a mask, not a painted gradient: it shapes the
   band and adds no colour. */
.ads-marquee__viewport {
  width: 100%;
  overflow: hidden;
  padding: var(--sp-4) 0;
  mask: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
  -webkit-mask: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}

/* Linear on purpose: --ease on a loop would surge and stall every lap. */
.ads-marquee__track {
  display: flex;
  width: max-content;
  animation: ads-marquee-slide 80s linear infinite;
}

.ads-marquee__viewport:hover .ads-marquee__track,
.ads-marquee:focus-within .ads-marquee__track,
.ads-marquee.is-paused .ads-marquee__track {
  animation-play-state: paused;
}

.ads-marquee__lane {
  display: flex;
  gap: var(--sp-4);
  margin: 0;
  padding: 0 var(--sp-4) 0 0;
  list-style: none;
}

/* Raised, not pressable: the pills are a list, not controls, so they do not
   answer hover. */
.ads-marquee__item {
  display: inline-flex;
  align-items: center;
  padding: var(--sp-3) var(--sp-4);
  border-radius: var(--pill);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  font-size: 0.88rem;
  color: var(--text-soft);
  white-space: nowrap;
}

@keyframes ads-marquee-slide {
  to {
    transform: translateX(-50%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .ads-marquee__viewport {
    mask: none;
    -webkit-mask: none;
  }
  .ads-marquee__track {
    width: auto;
    justify-content: center;
    animation: none;
  }
  .ads-marquee__lane {
    flex-wrap: wrap;
    justify-content: center;
    padding: 0;
  }
  .ads-marquee__lane[aria-hidden='true'],
  .ads-marquee__toggle {
    display: none;
  }
}
</style>

PricingSection

Pricing cards, figures in mono. The recommended plan is shown by depth and value — further off the page, the brightest figure, the one accent button — never by a border or a hue. Only the first featured plan gets it.

Starter

$0

For a first screen.

  • Tokens & utilities
  • Sections
vue
<PricingSection :plans="[
  { name: 'Studio', price: '$49', period: '/mo', description: '…', features: ['Dashboard'], cta: 'Start a project', featured: true },
]" featured-label="Recommended" @select="onSelect" />
PricingSection props
PropTypeDefaultDescription
plansPlan[]—{ name, price, period?, description, features[], cta, featured? }
featuredLabelstringRecommendedText of the flag on the featured plan.
Source · ads/app/components/PricingSection.vue
vue
<script setup lang="ts">
// Pricing cards. The recommended plan is told apart by depth and value, never
// by a border or a hue: it stands further off the page, its figure is the
// brightest, and its button carries the one accent. Only the first plan
// marked `featured` gets that treatment — two recommended plans recommend
// nothing, and two accent buttons side by side cancel out.
export type Plan = {
  name: string
  price: string
  period?: string
  description: string
  features: string[]
  cta: string
  featured?: boolean
}

const props = withDefaults(defineProps<{ plans: Plan[]; featuredLabel?: string }>(), { featuredLabel: 'Recommended' })
const emit = defineEmits<{ (e: 'select', plan: Plan): void }>()

const featured = computed(() => props.plans.findIndex((p) => p.featured))
</script>

<template>
  <div class="ads-pricing-section">
    <article
      v-for="(p, i) in plans"
      :key="p.name"
      class="card ads-pricing-section__plan"
      :class="{ 'is-featured': i === featured }"
    >
      <span v-if="i === featured" class="ads-pricing-section__flag">{{ featuredLabel }}</span>
      <h3 class="ads-pricing-section__name">{{ p.name }}</h3>
      <p class="ads-pricing-section__price">
        <span class="ads-pricing-section__amount num">{{ p.price }}</span>
        <span v-if="p.period" class="ads-pricing-section__period">{{ p.period }}</span>
      </p>
      <p class="ads-pricing-section__desc">{{ p.description }}</p>
      <ul class="ads-pricing-section__features" role="list">
        <li v-for="f in p.features" :key="f" class="ads-pricing-section__feature">
          <Icon name="check" :size="15" class="ads-pricing-section__tick" />{{ f }}
        </li>
      </ul>
      <button type="button" class="btn" :class="{ 'btn-accent': i === featured }" @click="emit('select', p)">
        {{ p.cta }}
      </button>
    </article>
  </div>
</template>

<style scoped>
.ads-pricing-section {
  display: grid;
  gap: var(--gutter);
  grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
  align-items: start;
}

.ads-pricing-section__plan {
  position: relative;
  display: grid;
  gap: var(--sp-3);
  justify-items: start;
}

.ads-pricing-section__plan.is-featured {
  box-shadow: var(--soft-raised-lg);
}

.ads-pricing-section__flag {
  position: absolute;
  top: var(--sp-4);
  right: var(--sp-4);
  padding: var(--sp-1) var(--sp-3);
  border-radius: var(--pill);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  font-family: var(--font-label);
  font-size: 0.6rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-soft);
}

.ads-pricing-section__name {
  font-size: 1.05rem;
}

.ads-pricing-section__price {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
}

/* Value carries the recommendation: every other figure sits a step down. */
.ads-pricing-section__amount {
  font-size: 2rem;
  font-weight: 500;
  letter-spacing: -0.03em;
  color: var(--text-soft);
}

.ads-pricing-section__plan.is-featured .ads-pricing-section__amount {
  color: var(--text);
}

.ads-pricing-section__period {
  color: var(--subdued);
  font-size: 0.85rem;
}

.ads-pricing-section__desc {
  color: var(--subdued);
  font-size: 0.9rem;
}

.ads-pricing-section__features {
  display: grid;
  gap: var(--sp-2);
  margin: var(--sp-2) 0;
  padding: 0;
  list-style: none;
  font-size: 0.88rem;
  color: var(--text-soft);
}

.ads-pricing-section__feature {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  gap: var(--sp-2);
}

.ads-pricing-section__tick {
  margin-top: 2px;
  color: var(--text);
}
</style>

FaqSection

Frequently asked questions on an accordion.

Why is the chrome grey?
So colour means something when it finally shows up.
Is this Tailwind?
No — ADS is its own token-driven utility layer.
vue
<FaqSection :faqs="[{ title: 'Why grey?', body: '…' }]" />
FaqSection props
PropTypeDefaultDescription
faqs{ title, body }[]—
Source · ads/app/components/FaqSection.vue
vue
<script setup lang="ts">
// Frequently asked questions, on an accordion. Progressive disclosure: show
// the question, let the reader open the answer.
export type Faq = { title: string; body: string }

defineProps<{ faqs: Faq[] }>()
</script>

<template>
  <div class="ads-faq-section max">
    <AdsAccordion :items="faqs" />
  </div>
</template>

<style scoped>
.ads-faq-section {
  max-width: 760px;
}
</style>

CtaBand

The closing call to action, shaped like the studio site's: one raised panel, one accent button, a default secondary beside it.

New project

Build the dashboard your users live in.

Send the screen you are least happy with. You get a written read on it within two working days.

vue
<CtaBand
  eyebrow="New project" title="Build the dashboard your users live in" body="…"
  cta-label="Start a project" cta-to="/contact"
  secondary-label="See the work" secondary-to="/dashboard-example" />
CtaBand props
PropTypeDefaultDescription
eyebrowstring
titlestring—Required.
bodystringWhat happens next, in a sentence or two.
ctaLabelstringStart a project
ctaTostring/contact
secondaryLabelstringDefault-weight second action. Needs secondaryTo.
secondaryTostring
Source · ads/app/components/CtaBand.vue
vue
<script setup lang="ts">
// The close, shaped like the studio site's: one raised panel — label,
// headline, a lede that says what happens next — and one filled action in
// it. Everything above should have spent its accent carefully so that this
// button is the brightest thing on the page by the time anyone reaches it.
// The secondary action stays default weight.
withDefaults(
  defineProps<{
    eyebrow?: string
    title: string
    body?: string
    ctaLabel?: string
    ctaTo?: string
    secondaryLabel?: string
    secondaryTo?: string
  }>(),
  {
    eyebrow: '',
    body: '',
    ctaLabel: 'Start a project',
    ctaTo: '/contact',
    secondaryLabel: '',
    secondaryTo: '',
  },
)
</script>

<template>
  <section class="ads-cta-band">
    <div class="ads-cta-band__inner max">
      <div class="ads-cta-band__panel card">
        <span v-if="eyebrow" class="eyebrow">{{ eyebrow }}</span>
        <h2 class="ads-cta-band__title h2">{{ title }}</h2>
        <p v-if="body" class="lede ads-cta-band__body">{{ body }}</p>
        <div class="ads-cta-band__actions">
          <NuxtLink :to="ctaTo" class="btn btn-accent">
            {{ ctaLabel }}
            <Icon name="arrow" :size="18" />
          </NuxtLink>
          <NuxtLink v-if="secondaryLabel && secondaryTo" :to="secondaryTo" class="btn">{{ secondaryLabel }}</NuxtLink>
        </div>
      </div>
    </div>
  </section>
</template>

<style scoped>
.ads-cta-band {
  padding: var(--sp-7) 0 var(--sp-8);
}

.ads-cta-band__inner {
  padding: 0 clamp(20px, 4vw, 48px);
}

.ads-cta-band__panel {
  display: grid;
  gap: var(--sp-4);
  justify-items: start;
  padding: clamp(28px, 5vw, 52px);
}

.ads-cta-band__title {
  font-family: var(--font-display);
}

.ads-cta-band__body {
  max-width: 56ch;
}

.ads-cta-band__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  margin-top: var(--sp-2);
}
</style>

ContactForm

A contact form: visible labels on every field, errors under the field that say what to do, focus moved to the first one. A toast confirms a send; it never carries an error.

Optional.

Optional. Include the country code, e.g. +44 20 7946 0000.

vue
<ContactForm privacy-to="/privacy" @submit="send" />
ContactForm props
PropTypeDefaultDescription
privacyTostring/privacyWhere the consent checkbox links.
@submit(data: ContactMessage) => void—Fires with the fields once they validate.
Source · ads/app/components/ContactForm.vue
vue
<script setup lang="ts">
// The contact form. The ADS version of a classic "contact sales" form,
// composed from AdsField / AdsChoice / AdsButton.
//
// Every field has a visible label above it — a placeholder disappears exactly
// when it is needed. The browser's own bubbles are switched off so each error
// can say what to do rather than that something is wrong, sit under the field
// it belongs to, and take focus. A failed send is not a toast: the reader
// must act on it, so it would be a banner.
export type ContactMessage = {
  firstName: string
  lastName: string
  company: string
  email: string
  phone: string
  message: string
}

withDefaults(defineProps<{ privacyTo?: string }>(), { privacyTo: '/privacy' })
const emit = defineEmits<{ (e: 'submit', data: ContactMessage): void }>()

const toast = useToast()
const uid = useId()
const root = ref<HTMLFormElement>()

const blank = () => ({ firstName: '', lastName: '', company: '', email: '', phone: '', message: '', agree: false })
const form = reactive(blank())
const tried = ref(false)
const submitting = ref(false)

const errors = computed(() => {
  if (!tried.value) return {} as Record<string, string>
  const e: Record<string, string> = {}
  if (!form.firstName.trim()) e.firstName = 'Enter your first name.'
  if (!form.lastName.trim()) e.lastName = 'Enter your last name.'
  if (!form.email.trim()) e.email = 'Enter your email address, so we can reply.'
  else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) e.email = 'Enter an email address like name@company.com.'
  if (!form.message.trim()) e.message = 'Tell us what you need — a sentence or two is enough.'
  if (!form.agree) e.agree = 'Tick the box to agree to the privacy policy, so we can keep your message.'
  return e
})

const order = ['firstName', 'lastName', 'email', 'message', 'agree'] as const
const fieldId = (key: string) => `${uid}-${key}`

async function submit() {
  tried.value = true
  await nextTick()
  const first = order.find((k) => errors.value[k])
  if (first) {
    const target = first === 'agree' ? root.value?.querySelector<HTMLElement>('.ads-contact-form__consent input') : document.getElementById(fieldId(first))
    target?.focus()
    return
  }
  submitting.value = true
  const { agree: _agree, ...data } = form
  emit('submit', { ...data })
  // Simulated; wire the `submit` event to $fetch('/api/contact') in a real app.
  await new Promise((r) => setTimeout(r, 900))
  submitting.value = false
  toast.push('Message sent — we will reply within a day.', 'check')
  Object.assign(form, blank())
  tried.value = false
}
</script>

<template>
  <form ref="root" class="ads-contact-form" novalidate @submit.prevent="submit">
    <div class="ads-contact-form__row">
      <AdsField :id="fieldId('firstName')" v-model="form.firstName" label="First name" name="first-name" autocomplete="given-name" :error="errors.firstName" required />
      <AdsField :id="fieldId('lastName')" v-model="form.lastName" label="Last name" name="last-name" autocomplete="family-name" :error="errors.lastName" required />
    </div>

    <AdsField v-model="form.company" label="Company" name="company" autocomplete="organization" hint="Optional." />

    <AdsField :id="fieldId('email')" v-model="form.email" label="Email" type="email" name="email" autocomplete="email" :error="errors.email" required />

    <AdsField
      v-model="form.phone"
      label="Phone number"
      type="tel"
      name="phone"
      autocomplete="tel"
      hint="Optional. Include the country code, e.g. +44 20 7946 0000."
    />

    <AdsField :id="fieldId('message')" v-model="form.message" label="Message" type="textarea" name="message" :rows="4" :error="errors.message" required />

    <div class="ads-contact-form__consent">
      <AdsChoice v-model="form.agree" kind="checkbox" label="I agree to the privacy policy">
        I agree to the <NuxtLink :to="privacyTo" class="ads-link">privacy policy</NuxtLink>.
      </AdsChoice>
      <p v-if="errors.agree" class="ads-contact-form__error" role="alert">
        <Icon name="alert" :size="14" />{{ errors.agree }}
      </p>
    </div>

    <AdsButton type="submit" variant="accent" :loading="submitting" icon="arrow" class="ads-contact-form__submit">
      Send message
    </AdsButton>
  </form>
</template>

<style scoped>
.ads-contact-form {
  display: grid;
  gap: var(--sp-5);
  width: 100%;
  max-width: 560px;
  margin-inline: auto;
}

.ads-contact-form__row {
  display: grid;
  gap: var(--sp-5);
}

@media (min-width: 640px) {
  .ads-contact-form__row {
    grid-template-columns: 1fr 1fr;
  }
}

.ads-contact-form__consent {
  display: grid;
  gap: var(--sp-2);
}

/* Same voice as a field's error: the words carry it, the glyph backs them up,
   the colour is the third signal, not the first. */
.ads-contact-form__error {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: 0.76rem;
  color: var(--bad);
}

.ads-contact-form__submit {
  width: 100%;
  margin-top: var(--sp-1);
}
</style>