Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions .specs/footer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
---
name: footer
category: layout
structure: composition
status: approved
spec_version: 1
figma:
url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=5467-1638
node_id: 5467:1638
checksum: 7cc4efef4305e63c50c44c3a007001c28265e937d6dfaa153a588893ace37118
created: 2026-08-10
last_updated: 2026-08-11
---
# Footer — Component Spec

## Purpose

Page footer for marketing and product surfaces: the bottom-of-page counterpart to `GlobalHeader`. A canvas-colored shell with a centered content measure holding link columns and a social bar separated by a hairline top border. Matches the Webkit Footer component set (Figma node 5467:1638, `Type=Default` / `Type=Mobile`): the two presentations are one component switching by CSS at the `md` (768px) breakpoint. Content (links, labels, languages, status) always comes from the consumer; the component owns anatomy, tokens, and responsiveness.

## When to use

- The page-level footer of a site or application, paired with `GlobalHeader`.
- Grouped navigation links at the bottom of the page plus a brand/social/status bar.

## When NOT to use

- The bottom area of a panel, table, sidebar, or overlay → use that component's own footer part (`panel-footer`, `table-footer`, `sidebar-footer`, `popover-footer`).
- A single standalone link inside body content → use `link` instead of `Footer.Link`.

## Related

- `global-header` — the top-of-page chrome counterpart; same shell philosophy (regions composed by the consumer).
- `brand`, `icon-button`, `status-indicator`, `select` — the components consumers compose inside the social bar; the footer reimplements none of them.

## Best practices

- Compose the social bar from existing DS components (`Brand`, `IconButton kind="transparent"`, `StatusIndicator`, `Select`); never rebuild them inside the slots.
- Keep column titles short (one or two words) — they are group labels, not headings for prose.
- Give every social `IconButton` an `ariaLabel`; the footer cannot name them for you.
- Provide four columns for the canonical desktop presentation; the grid folds to two columns below `md` on its own.

## Usage

```vue
<script setup>
import Footer from '@aziontech/webkit/footer'
</script>

<template>
<Footer aria-label="Footer">
<Footer.Column title="Products">
<Footer.Link href="/products/edge-application">Edge Application</Footer.Link>
<Footer.Link href="/products/edge-firewall">Edge Firewall</Footer.Link>
</Footer.Column>
<Footer.Column title="Company">
<Footer.Link href="/about">About us</Footer.Link>
<Footer.Link href="/careers">Careers</Footer.Link>
</Footer.Column>
<template #social-start>
<a href="/" aria-label="Azion home">Azion</a>
</template>
<template #social-end>
<a href="https://status.azion.com/">All Systems Operational</a>
</template>
</Footer>
</template>
```

Tree-shaking alternative — the standalone root and each sub-component from its own entry (no `Object.assign` compound pulled in):

```vue
<script setup>
import Footer from '@aziontech/webkit/footer-root'
import FooterColumn from '@aziontech/webkit/footer-column'
import FooterLink from '@aziontech/webkit/footer-link'
</script>
```

## Sub-components

- `footer-column/footer-column.vue` — one navigation column: a muted title labelling a stacked group of `Footer.Link` items. Renders a `nav` labelled by its title so each group is a navigable landmark. Props: `title: string` (required) — column group label, rendered above the links and used as the accessible name of the column landmark. Slot: `default` — its `Footer.Link` items.
- `footer-link/footer-link.vue` — one text link inside a column; the root is the anchor itself, so consumer attributes (`target`, `rel`) land on the `a`. Props: `href: string` (required) — destination URL of the link. Slot: `default` — the link label.

## Props

| Prop | Type | Default | Required | JSDoc |
|---|---|---|---|---|
| `ariaLabel` | `string` | `'Footer'` | false | Accessible name for the contentinfo landmark. |

## Events

| _none_ | — | — |

## Slots

| Slot | Scope | Notes |
|---|---|---|
| `default` | — | Root: the `Footer.Column` items; laid out as a 2-column grid that becomes 4 columns at `md`. |
| `default` | — | `footer-column`: its `Footer.Link` items. |
| `default` | — | `footer-link`: the link label. |

> The root also exposes the `social-start` named slot (leading cluster of the social bar: brand + social icon buttons) and the `social-end` named slot (trailing cluster: status indicator + language select; rendered above `social-start` below `md`, per the Mobile variant). They are kept out of the table above because the compliance parser cannot read hyphenated (quoted) slot keys from `defineSlots`. The social bar renders only when `social-start` or `social-end` is provided.

## States

- Visual states: `default` on the shell and columns; `default`, `hover`, `focus-visible`, `active`, `visited` on `footer-link`.
- No `data-state`; the shell has no interactive states — slotted children own their own.

## Motion & Animations

| Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback |
|---|---|---|---|
| link hover/focus color change | `transition-colors duration-150 ease-out` | inline (matches catalog) | `motion-reduce:transition-none` |

## Tokens

| Region | Token (DESIGN.md) |
|---|---|
| shell surface | `var(--bg-canvas)` |
| content max-width | `max-w-(--container-5xl)` |
| columns row gap (mobile 2-row wrap) | `var(--spacing-lg)` |
| column padding | `var(--spacing-lg)` |
| column divider (border-right, `md`+) | `var(--border-width-default)` / `var(--border-default)` |
| column internal gap (title → links) | `var(--spacing-md)` |
| link stack gap | `var(--spacing-xs)` |
| social bar border (top) | `var(--border-width-default)` / `var(--border-default)` |
| social bar height (`md`+) | `h-14` (56px) |
| social bar padding-y (below `md`) | `var(--spacing-md)` |
| social bar padding-x | `var(--spacing-lg)` |
| social bar left-cluster gap (brand ↔ social icons) | `var(--spacing-md)` |
| social bar right-cluster gap (status ↔ language) | `var(--spacing-lg)` |
| column title typography / color | `.text-label-sm` / `var(--text-muted)` |
| link typography / color | `.text-label-sm` / `var(--text-default)` |
| link hover color | `var(--text-muted)` |
| ring (link focus) | `var(--ring-color)` |

## Theme gaps

| Figma variable | Temporary primitive | Follow-up |
|---|---|---|
| `--container-max-width` (1280px content measure) | `max-w-(--container-5xl)` (1192px, nearest container primitive) | `TODO: tokenizar` |

## Accessibility (WCAG 2.1 AA)

- Root renders as `<footer>` — the implicit `contentinfo` landmark — with `aria-label` from `ariaLabel`.
- Each `footer-column` renders a `<nav>` labelled via `aria-labelledby` pointing at its title (`useId`), so link groups are unique, navigable landmarks.
- Keyboard map: none on the shell; `Tab` moves through links in DOM order; `Enter` activates.
- Visible focus on links: `focus-visible:ring-2 focus-visible:ring-(--ring-color) focus-visible:ring-offset-2 focus-visible:ring-offset-(--bg-canvas)`.
- Contrast ≥4.5:1 for column titles and links on `--bg-canvas`.
- `motion-reduce:transition-none` on the link color transition.
- Touch target: text links follow the platform convention for footer link lists (<40px height); justified deviation — targets are full-width-of-text with `--spacing-xs` separation.

## Stories (Storybook)

- Default

## Constraints — DO NOT

<!-- This block is injected VERBATIM into every sub-agent prompt.
spec-validator rejects the spec if this block is missing or shorter than the template. -->

- Do not add props beyond the Props table above. If you need a prop that is not listed, emit `BLOCKED: missing prop <name>` and stop — do not invent.
- Do not add events beyond the Events table above. Same rule for slots and sub-components.
- Do not invent imports. Every `@aziontech/webkit/*` path must exist in `packages/webkit/package.json#exports`. Every relative import must resolve to a real file. Every npm package must be installed.
- Do not use HEX/RGB/HSL colors, Tailwind palette names (e.g. `bg-blue-500`), raw typography classes (e.g. `text-sm`), `any`, `@ts-ignore`, or `class` inside `defineProps`.
- Do not install or import positioning/animation libraries (`@floating-ui/*`, `popper.js`, `tippy.js`, `gsap`, `framer-motion`, `motion`, `@vueuse/motion`, `@formkit/auto-animate`, drag-drop runtimes, scroll virtualization libs). Use CSS + Vue primitives (`<Teleport>`, `<Transition>`). See `.claude/rules/dependencies.md`.
- Do not improvise animations. Every `animate-*` / `transition-*` class must come from `packages/theme/src/tokens/semantic/animations.js`; every motion-bearing class pairs with `motion-reduce:*` on the same class string; no component-local `@keyframes`.
- Do not create class presets in JavaScript (`const kindClasses = {...}`, `const sharedClasses = [...]`, `const sizeClasses = {...}`, `const rootClasses = computed(...)`). Variants live on `data-*` attributes consumed by Tailwind `data-[attr=value]:`. All utilities live inline on the root element's `class` attribute. No `<style>` block, no component-local `.css`/`.scss`. See `.claude/rules/styling.md`.
- Do not inherit artifacts as-is from another design system, Figma file, library, or pre-existing `CONTRACT.md` / `README.md`. Rewrite to our conventions. See `.claude/rules/migration.md`.
- Do not add Figma references to Storybook stories. No `parameters.design`, no `parameters.figma`, no Figma URLs in `docs.description.*`, no `@storybook/addon-designs` import. The Figma link is owned by `<name>.figma.ts` (Code Connect). See `.claude/docs/COMPONENT_REQUIREMENTS.md`.
- Do not use `parameters.actions.argTypesRegex` (deprecated in Storybook 8 and silently misroutes Vue 3 emits) or `parameters.actions.handles` (DOM-only). Declare every event explicitly in `argTypes` with a camelCase `on<Event>` key and `{ action: '<emitted-name>' }`. Do not use the legacy CSF2 `Name.args = {...}` form — always object-style CSF3.
- Do not add bespoke Storybook stories beyond Default + Types + Sizes + state stories (`Loading`, `Disabled`) for the props the component actually declares, unless the spec's "Stories (Storybook)" section explicitly justifies the addition. Do not split Types/Sizes into one-story-per-variant — the composite stories are the canonical pattern.
- Do not duplicate the `## Usage` block from the spec inside the Storybook story body. The block is injected once into `parameters.docs.description.component` by the storybook-write skill; copy it nowhere else.
- Do not edit `.claude/docs/DESIGN.md`, `.claude/docs/COMPONENT_REQUIREMENTS.md`, or `.claude/docs/PRIMEVUE_ABSTRACTION.md`.
- Do not edit the root `package.json` or `.github/workflows/*`.
- Do not export composition sub-components without attaching them to the root compound (`index.ts` via `Object.assign`; vue-tsc generates `index.d.ts` — never hand-write it); the root export points at `index.ts`, and a standalone `./<name>-root` export points at the root `.vue` (tree-shaking). Do not invent overlay part names (`Trigger` / `Content`) on a component with no `data-state=open|closed`, and do not collapse a slot-shaped concern into a config-array prop. See `.claude/rules/compound-api.md`.
- Do not change `structure` after `status: approved`. To change structure, bump `spec_version` and re-author the spec.
- Do not create files outside the paths declared by your task (the orchestrator tells you exactly which files to write).
- Do not run `git` commands, `pnpm install`, or any command that changes the lockfile.
- If anything in the spec is ambiguous or contradicts the rules, emit `BLOCKED: <one-sentence reason>` and write nothing.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import Brand from '@aziontech/webkit/brand'
import Footer from '@aziontech/webkit/footer'
import IconButton from '@aziontech/webkit/icon-button'
import Select from '@aziontech/webkit/select'
import StatusIndicator from '@aziontech/webkit/status-indicator'
import { ref } from 'vue'

import { toSfc } from '../../../_shared/story-source'

const IMPORT = [
"import Brand from '@aziontech/webkit/brand'",
"import Footer from '@aziontech/webkit/footer'",
"import IconButton from '@aziontech/webkit/icon-button'",
"import Select from '@aziontech/webkit/select'",
"import StatusIndicator from '@aziontech/webkit/status-indicator'",
"import { ref } from 'vue'"
]

// Compound sub-components registered under their dot-notation names so they
// resolve in Storybook's runtime-compiled string template: Vue compiles
// `<Footer.Column>` to `resolveComponent("Footer.Column")`, an exact-name
// lookup (a bare `Footer` registration does not satisfy it). In a real SFC
// the dotted tag resolves off the imported `Footer` binding, so consumer
// code needs only `import Footer` — these extra registrations are a
// Storybook-runtime concern.
const components = {
Footer,
'Footer.Column': Footer.Column,
'Footer.Link': Footer.Link,
Brand,
IconButton,
'Select.Trigger': Select.Trigger,
'Select.Content': Select.Content,
'Select.Option': Select.Option,
Select,
StatusIndicator
}

/** @type {import('@storybook/vue3').Meta<typeof Footer>} */
const meta = {
title: 'Components/Layout/Footer',
component: Footer,
subcomponents: {
'Footer.Column': Footer.Column,
'Footer.Link': Footer.Link
},
tags: ['autodocs'],
parameters: {
layout: 'padded',
backgrounds: { default: 'dark' },
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'focus-order-semantics', enabled: true }
]
}
},
docs: {
description: {
component:
'Page footer, the bottom-of-page counterpart to GlobalHeader: link columns over a social bar, folding from four columns to the stacked mobile presentation at the md breakpoint. Links, brand, status, and language content come from the consumer.'
},
canvas: { sourceState: 'shown' }
}
},
argTypes: {
ariaLabel: {
control: 'text',
description: 'Accessible name for the contentinfo landmark.',
table: {
type: { summary: 'string' },
defaultValue: { summary: "'Footer'" },
category: 'props'
}
},
default: {
control: false,
description: 'The Footer.Column items; a 2-column grid that becomes 4 columns at md.',
table: { type: { summary: '—' }, category: 'slots' }
},
'social-start': {
control: false,
description: 'Leading cluster of the social bar (brand + social icon buttons).',
table: { type: { summary: '—' }, category: 'slots' }
},
'social-end': {
control: false,
description: 'Trailing cluster of the social bar (status indicator + language select).',
table: { type: { summary: '—' }, category: 'slots' }
}
},
args: {
ariaLabel: 'Footer'
}
}

export default meta

// The full anatomy, authored once so the live canvas (Template) and the
// "Show code" snippet (DEFAULT_MARKUP) never drift.
const FOOTER_CONTENT = ` <Footer.Column title="Products">
<Footer.Link href="/products/edge-application">Edge Application</Footer.Link>
<Footer.Link href="/products/edge-firewall">Edge Firewall</Footer.Link>
<Footer.Link href="/products/edge-storage">Edge Storage</Footer.Link>
</Footer.Column>
<Footer.Column title="Developers">
<Footer.Link href="/documentation">Documentation</Footer.Link>
<Footer.Link href="/blog">Blog</Footer.Link>
<Footer.Link href="/integrations">Integrations</Footer.Link>
</Footer.Column>
<Footer.Column title="Company">
<Footer.Link href="/about">About us</Footer.Link>
<Footer.Link href="/careers">Careers</Footer.Link>
<Footer.Link href="/legal">Legal</Footer.Link>
</Footer.Column>
<Footer.Column title="Support">
<Footer.Link href="/contact">Contact sales</Footer.Link>
<Footer.Link href="/support">Help center</Footer.Link>
<Footer.Link href="https://status.azion.com/">System status</Footer.Link>
</Footer.Column>
<template #social-start>
<a href="/" aria-label="Azion home">
<Brand />
</a>
<div class="flex items-center gap-1">
<IconButton kind="transparent" icon="pi pi-github" aria-label="Azion on GitHub" href="https://github.com/aziontech" target="_blank" />
<IconButton kind="transparent" icon="pi pi-linkedin" aria-label="Azion on LinkedIn" href="https://www.linkedin.com/company/aziontech" target="_blank" />
<IconButton kind="transparent" icon="pi pi-youtube" aria-label="Azion on YouTube" href="https://www.youtube.com/aziontech" target="_blank" />
<IconButton kind="transparent" icon="pi pi-twitter" aria-label="Azion on X" href="https://x.com/aziontech" target="_blank" />
<IconButton kind="transparent" icon="pi pi-instagram" aria-label="Azion on Instagram" href="https://www.instagram.com/aziontech" target="_blank" />
<IconButton kind="transparent" icon="pi pi-discord" aria-label="Azion on Discord" href="https://discord.gg/azion" target="_blank" />
<IconButton kind="transparent" icon="pi pi-reddit" aria-label="Azion on Reddit" href="https://www.reddit.com/r/azion" target="_blank" />
</div>
</template>
<template #social-end>
<StatusIndicator severity="success" label="All Systems Operational" />
<div class="w-24">
<Select v-model="language" placeholder="Language">
<Select.Trigger aria-label="Language" />
<Select.Content>
<Select.Option
v-for="o in languageOptions"
:key="o.value"
:value="o.value"
>{{ o.label }}</Select.Option>
</Select.Content>
</Select>
</div>
</template>`

const SETUP_SNIPPET = `const language = ref('en')
const languageOptions = [
{ value: 'en', label: 'EN' },
{ value: 'pt-br', label: 'PT-BR' },
{ value: 'es', label: 'ES' }
]`

const Template = (args) => ({
components,
setup() {
const language = ref('en')
const languageOptions = [
{ value: 'en', label: 'EN' },
{ value: 'pt-br', label: 'PT-BR' },
{ value: 'es', label: 'ES' }
]
return { args, language, languageOptions }
},
template: `<Footer v-bind="args">
${FOOTER_CONTENT}
</Footer>`
})

const DEFAULT_MARKUP = `<Footer aria-label="Footer">
${FOOTER_CONTENT}
</Footer>`

/** @type {import('@storybook/vue3').StoryObj<typeof Footer>} */
export const DefaultFooter = {
name: 'Default',
render: Template,
parameters: {
docs: {
description: {
story:
'The footer composed with four link columns and the social bar: brand plus social icon buttons at the start, the system status indicator at the end.'
},
source: { code: toSfc([...IMPORT, '', SETUP_SNIPPET], DEFAULT_MARKUP) }
}
}
}
5 changes: 5 additions & 0 deletions packages/webkit/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,10 @@
"name": "password-requirements",
"path": "src/components/inputs/field-password/password-requirements.ts",
"limit": "0.3 KB"
},
{
"name": "footer-root",
"path": "src/components/layout/footer/footer.vue",
"limit": "1.1 KB"
}
]
Loading
Loading