diff --git a/.specs/section-title.md b/.specs/section-title.md index 3d6189a2f..751f25a25 100644 --- a/.specs/section-title.md +++ b/.specs/section-title.md @@ -1,15 +1,15 @@ --- name: section-title -category: content +category: marketing structure: monolithic status: approved -spec_version: 2 -checksum: 3a4b15c27d3dda6af9bda55a10bb9e9db13df6865900f9ef805ae97589696e55 +spec_version: 3 +checksum: 3d3a1e4ec59dd948f5f2f4d236ce30ec39428bc5dd991f0903478658c5e3c7f9 figma: url: https://www.figma.com/design/QEbHSTFDWfh4VHkBp6NWN3/Azion.com?node-id=7495-24338 node_id: 7495:24338 created: 2026-08-11 -last_updated: 2026-08-11 +last_updated: 2026-09-25 --- # Section Title — Component Spec @@ -26,20 +26,23 @@ The framed header row that opens a page section: an optional overline, the secti ## When NOT to use -- For the page's leading band and its `h1` → use `hero-title` instead. +- For the page's leading band and its `h1` → use `hero` instead. - For the top bar of an application shell → use `global-header` instead. - For a plain framed container with arbitrary content → use `frame-box` instead. ## Related -- `hero-title` — the hero counterpart; renders the page's `h1` at hero scale. +- `hero` — the opening band; its `Hero.Title` renders the page's `h1` at hero scale. - `frame-box` — the frame this component composes. - `section-gap` — the empty frame that sets the air before and after a section header; this component holds no vertical air of its own beyond its padding. - `overline` — the eyebrow treatment rendered above the headline. ## Best practices -- Keep one `section-title` per section, and let it own the section's `h2` so the page keeps one document outline. +- Leave `framed` on when the header is a brick of its own in a page column; turn it off when a band composes the header inside a frame it already draws, so the rule and the padding are not drawn twice. + +- Keep one `section-title` per section, and let it own the section's `h2` so the page keeps one document outline. `size` changes the headline's step on the type scale, never its heading level — a page with a `large` and a `small` header still reads as two `h2`s. +- Leave `size` at `medium` for an ordinary section opener. Reach for `large` only where the headline is the band's whole statement, and for `small` where the header opens a subsection inside a band that already has one. - Write the eyebrow as one or two words: it is set uppercase and prefixed with `//`, so a sentence in it reads as noise. - Reach for `horizontal` when the description is long enough to earn its own column — on a narrow viewport it stacks back under the headline. - Put the section's CTAs in the `actions` slot rather than in the body; the slot already stacks them fluid below `sm` and aligns them with the chosen `kind` above it. @@ -74,6 +77,8 @@ import Button from '@aziontech/webkit/button' | `description` | `string` | `''` | false | Supporting sentence under the headline; overridden by the default slot. | | `eyebrow` | `string` | `''` | false | Short uppercase overline rendered above the headline. | | `kind` | `'centered' \| 'left' \| 'horizontal'` | `'centered'` | false | Layout of the header: `centered` stacks and centers the copy, `left` stacks it at the start edge, `horizontal` sets the headline and its description in two columns. | +| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | false | Step of the headline on the heading scale: `small` for a subsection inside a band, `medium` for an ordinary section opener, `large` for a band whose headline is the statement. | +| `framed` | `boolean` | `true` | false | Draw the header's own frame and padding. Turn it off when the header is composed inside a band that already owns both. | ## Events @@ -90,6 +95,8 @@ import Button from '@aziontech/webkit/button' - Visual states: `default` - `data-kind` mirrors the `kind` prop: `centered` | `left` | `horizontal` +- `data-size` mirrors the `size` prop: `small` | `medium` | `large` +- `data-framed` present when the header draws its own frame and padding ## Motion & Animations @@ -99,7 +106,9 @@ _none_ | Region | Token (DESIGN.md) | |---|---| -| typography (headline) | `.text-heading-xl` | +| typography (headline, `size="small"`) | `.text-heading-lg` | +| typography (headline, `size="medium"`) | `.text-heading-xl` | +| typography (headline, `size="large"`) | `.text-heading-2xl` | | typography (description) | `.text-heading-sm` | | headline text | `var(--text-default)` | | description text | `var(--text-muted)` | @@ -120,7 +129,7 @@ _none_ - Visible focus: not applicable to the header itself; controls composed into the `actions` slot keep their own `focus-visible:ring-2 focus-visible:ring-(--ring-color) focus-visible:ring-offset-2 focus-visible:ring-offset-(--bg-canvas)` ring. - Keyboard map: none of its own — `Tab` reaches only the controls placed in the `actions` slot, in DOM order. -- ARIA: the headline is a real `h2` (the page's `h1` belongs to `hero-title`), so no `role` or `aria-label` is added; the frame's rules and corner marks stay `aria-hidden="true"`. In `horizontal` the headline precedes its description in DOM order, so the reading order matches the visual one. +- ARIA: the headline is a real `h2` (the page's `h1` belongs to `Hero.Title`), so no `role` or `aria-label` is added; the frame's rules and corner marks stay `aria-hidden="true"`. In `horizontal` the headline precedes its description in DOM order, so the reading order matches the visual one. - Contrast ≥4.5:1 (text) / ≥3:1 (large + icons): headline on `var(--text-default)`, description on `var(--text-muted)`, both over the page canvas. - `motion-reduce:transition-none motion-reduce:transform-none` — not applicable, the component is static. - Touch target ≥40×40 px — the `actions` row stretches its children to full width below `sm`, so slotted buttons keep their own target size. @@ -128,7 +137,8 @@ _none_ ## Stories (Storybook) - Default -- Kinds — composite story rendering every `kind` value stacked, so the three layouts can be compared (justified: `kind` is the component's only enum axis and the difference is structural) +- Kinds — composite story rendering every `kind` value stacked, so the three layouts can be compared (justified: the difference between the layouts is structural) +- Sizes — composite story rendering every `size` value stacked, so the three steps of the headline scale can be compared - WithActions — an `actions` row under the description (justified: the slot owns its own responsive layout, which no prop-driven story shows) ## Constraints — DO NOT diff --git a/apps/storybook/src/stories/components/content/section-title/SectionTitle.stories.js b/apps/storybook/src/stories/components/marketing/section-title/SectionTitle.stories.js similarity index 75% rename from apps/storybook/src/stories/components/content/section-title/SectionTitle.stories.js rename to apps/storybook/src/stories/components/marketing/section-title/SectionTitle.stories.js index 6c72b8230..8b67c8882 100644 --- a/apps/storybook/src/stories/components/content/section-title/SectionTitle.stories.js +++ b/apps/storybook/src/stories/components/marketing/section-title/SectionTitle.stories.js @@ -8,7 +8,7 @@ const IMPORT_WITH_BUTTON = [IMPORT, "import Button from '@aziontech/webkit/butto /** @type {import('@storybook/vue3').Meta} */ const meta = { - title: 'Components/Content/SectionTitle', + title: 'Components/Marketing/SectionTitle', component: SectionTitle, tags: ['autodocs'], parameters: { @@ -56,6 +56,17 @@ const meta = { defaultValue: { summary: "'centered'" } } }, + size: { + control: 'inline-radio', + options: ['small', 'medium', 'large'], + description: + 'Step of the headline on the heading scale: `small` for a subsection inside a band, `medium` for an ordinary section opener, `large` for a band whose headline is the statement.', + table: { + category: 'props', + type: { summary: "'small' | 'medium' | 'large'" }, + defaultValue: { summary: "'medium'" } + } + }, default: { control: false, description: 'Description body; replaces the `description` prop when provided.', @@ -71,7 +82,8 @@ const meta = { title: 'Everything runs at the edge', description: 'One platform for applications, security and observability.', eyebrow: 'Platform', - kind: 'centered' + kind: 'centered', + size: 'medium' } } @@ -143,6 +155,46 @@ export const Kinds = { } } +const SIZES_TEMPLATE = `
+ + + +
` + +/** @type {import('@storybook/vue3').StoryObj} */ +export const Sizes = { + render: () => ({ components: { SectionTitle }, template: SIZES_TEMPLATE }), + parameters: { + controls: { disable: true }, + docs: { + controls: { disable: true }, + description: { + story: + 'The three steps of the headline scale, stacked. `size` moves the headline between `text-heading-lg`, `text-heading-xl` and `text-heading-2xl` — the description stays at reading size in all three, and the heading stays an `h2`, so the page outline is unaffected by how loud a header is.' + }, + source: { code: toSfc(IMPORT, SIZES_TEMPLATE) } + } + } +} + const WITH_ACTIONS_TEMPLATE = ` { const { getByTestId } = render(SectionTitle, { props }) const root = getByTestId(TESTID) - // flush="top" is subtracted from borders="y", so the header keeps only its bottom rule — - // the divider between the header and the section body. expect(root).toHaveAttribute('data-flush', 'top') expect(root).toHaveAttribute('data-borders', 'bottom') }) @@ -45,6 +43,27 @@ describe('SectionTitle', () => { expect(getByTestId(TESTID)).toHaveAttribute('data-kind', 'centered') }) + it.each(['small', 'medium', 'large'] as const)('reflects size="%s" on data-size', (size) => { + const { getByTestId } = render(SectionTitle, { props: { ...props, size } }) + + expect(getByTestId(TESTID)).toHaveAttribute('data-size', size) + }) + + it('defaults to the medium size', () => { + const { getByTestId } = render(SectionTitle, { props }) + + expect(getByTestId(TESTID)).toHaveAttribute('data-size', 'medium') + }) + + it.each(['small', 'medium', 'large'] as const)( + 'keeps the headline an h2 at size="%s"', + (size) => { + const { getByRole } = render(SectionTitle, { props: { ...props, size } }) + + expect(getByRole('heading', { level: 2 })).toHaveTextContent('Everything runs at the edge') + } + ) + it.each(['centered', 'left', 'horizontal'] as const)( 'keeps the headline before its description in DOM order when kind="%s"', (kind) => { @@ -57,8 +76,6 @@ describe('SectionTitle', () => { expect(heading).toHaveTextContent('Everything runs at the edge') expect(paragraph).toHaveTextContent('One platform.') - // Node.DOCUMENT_POSITION_FOLLOWING === 4: the description follows the headline, so the - // reading order matches the visual one in every layout — including the two-column one. expect(heading.compareDocumentPosition(paragraph) & 4).toBe(4) } ) @@ -148,6 +165,16 @@ describe('SectionTitle', () => { ]) }) + it('renders the Sizes story with one header per step', () => { + const { getAllByTestId } = render(Sizes()) + + expect(getAllByTestId(TESTID).map((el) => el.getAttribute('data-size'))).toEqual([ + 'small', + 'medium', + 'large' + ]) + }) + it('renders the WithActions story with both CTAs', () => { const { getAllByRole } = render(WithActions()) @@ -157,4 +184,40 @@ describe('SectionTitle', () => { ]) }) }) + + describe('framed', () => { + it('draws its own frame by default', () => { + const { getByTestId } = render(SectionTitle, { props: { title: 'Platform' } }) + const root = getByTestId(TESTID) + + expect(root).toHaveAttribute('data-framed') + expect(root).toHaveAttribute('data-borders') + }) + + it('renders as a plain block when framed is false', () => { + const { getByTestId } = render(SectionTitle, { + props: { title: 'Platform', framed: false } + }) + const root = getByTestId(TESTID) + + expect(root).not.toHaveAttribute('data-framed') + expect(root).not.toHaveAttribute('data-borders') + expect(root).not.toHaveAttribute('data-marks') + }) + + it('keeps its copy and its heading level unframed', () => { + const { getByRole, getByText } = render(SectionTitle, { + props: { + title: 'Platform', + eyebrow: 'Build', + description: 'One sentence.', + framed: false + } + }) + + expect(getByRole('heading', { level: 2, name: 'Platform' })).toBeInTheDocument() + expect(getByText('Build')).toBeInTheDocument() + expect(getByText('One sentence.')).toBeInTheDocument() + }) + }) }) diff --git a/packages/webkit/src/components/content/section-title/section-title.vue b/packages/webkit/src/components/marketing/section-title/section-title.vue similarity index 57% rename from packages/webkit/src/components/content/section-title/section-title.vue rename to packages/webkit/src/components/marketing/section-title/section-title.vue index 97030a05f..0e37dc236 100644 --- a/packages/webkit/src/components/content/section-title/section-title.vue +++ b/packages/webkit/src/components/marketing/section-title/section-title.vue @@ -11,6 +11,8 @@ /** Layout of the header row. */ export type SectionTitleKind = 'centered' | 'left' | 'horizontal' + /** Step of the headline on the heading scale. */ + export type SectionTitleSize = 'small' | 'medium' | 'large' interface Props { /** Headline of the section, rendered as the section's `h2`. */ @@ -21,12 +23,18 @@ eyebrow?: string /** Layout of the header: `centered` stacks and centers the copy, `left` stacks it at the start edge, `horizontal` sets the headline and its description in two columns. */ kind?: SectionTitleKind + /** Step of the headline on the heading scale: `small` for a subsection inside a band, `medium` for an ordinary section opener, `large` for a band whose headline is the statement. */ + size?: SectionTitleSize + /** Draw the header's own frame and padding. Turn it off when the header is composed inside a band that already owns both. */ + framed?: boolean } const props = withDefaults(defineProps(), { description: '', eyebrow: '', - kind: 'centered' + kind: 'centered', + size: 'medium', + framed: true }) const slots = defineSlots<{ @@ -37,35 +45,32 @@ const attrs = useAttrs() const testId = computed( - () => (attrs['data-testid'] as string | undefined) ?? 'content-section-title' + () => (attrs['data-testid'] as string | undefined) ?? 'marketing-section-title' ) const hasDescription = computed( () => Boolean(slots.default) || props.description.length > 0 ) + + const frame = computed(() => (props.framed ? FrameBox : 'div')) + const frameProps = computed(() => + props.framed ? { flush: true, borders: 'y', marks: 'all' } : {} + )