@contentrain/astro-kit 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +16 -0
  2. package/catalog.json +123 -15
  3. package/components/_shared/Lines.astro +12 -0
  4. package/components/_shared/Section.astro +13 -9
  5. package/components/_shared/SectionHeader.astro +3 -2
  6. package/components/_shared/bleed.ts +8 -0
  7. package/components/_shared/instance-id.ts +24 -0
  8. package/components/_shared/muted.ts +19 -0
  9. package/components/band/Band.astro +10 -7
  10. package/components/band/meta.json +2 -0
  11. package/components/behavior/Behavior.astro +7 -2
  12. package/components/behavior/fixtures.json +2 -2
  13. package/components/behavior/meta.json +7 -0
  14. package/components/card-grid/CardGrid.astro +84 -24
  15. package/components/card-grid/fixtures.json +4 -1
  16. package/components/card-grid/meta.json +21 -0
  17. package/components/contact-details/ContactDetails.astro +7 -3
  18. package/components/contact-details/meta.json +2 -0
  19. package/components/contact-form/ContactForm.astro +11 -7
  20. package/components/contact-form/meta.json +2 -0
  21. package/components/cta/Cta.astro +14 -10
  22. package/components/cta/meta.json +2 -2
  23. package/components/dialog/Dialog.astro +6 -3
  24. package/components/dialog/meta.json +2 -0
  25. package/components/faq/Faq.astro +8 -4
  26. package/components/faq/meta.json +1 -1
  27. package/components/feature-list/FeatureList.astro +10 -6
  28. package/components/feature-list/meta.json +3 -1
  29. package/components/footer/Footer.astro +4 -3
  30. package/components/footer/meta.json +1 -0
  31. package/components/gallery/Gallery.astro +8 -4
  32. package/components/gallery/meta.json +2 -0
  33. package/components/header/Header.astro +8 -4
  34. package/components/header/meta.json +1 -1
  35. package/components/hero/Hero.astro +44 -15
  36. package/components/hero/fixtures.json +7 -1
  37. package/components/hero/meta.json +30 -1
  38. package/components/language-switcher/LanguageSwitcher.astro +3 -2
  39. package/components/language-switcher/meta.json +1 -1
  40. package/components/logo-cloud/LogoCloud.astro +9 -5
  41. package/components/logo-cloud/meta.json +3 -1
  42. package/components/nav/Nav.astro +10 -6
  43. package/components/nav/meta.json +2 -0
  44. package/components/newsletter/Newsletter.astro +9 -5
  45. package/components/newsletter/meta.json +2 -0
  46. package/components/post-list/PostList.astro +7 -3
  47. package/components/post-list/meta.json +2 -0
  48. package/components/pricing/Pricing.astro +15 -11
  49. package/components/pricing/meta.json +3 -1
  50. package/components/search-box/SearchBox.astro +4 -1
  51. package/components/search-box/meta.json +1 -1
  52. package/components/slider/Slider.astro +4 -1
  53. package/components/slider/SliderTrack.astro +11 -5
  54. package/components/slider/meta.json +1 -1
  55. package/components/social-links/SocialLinks.astro +2 -1
  56. package/components/social-links/meta.json +3 -2
  57. package/components/split/Split.astro +16 -11
  58. package/components/split/fixtures.json +4 -1
  59. package/components/split/meta.json +9 -3
  60. package/components/stats/Stats.astro +11 -7
  61. package/components/stats/meta.json +3 -1
  62. package/components/steps/Steps.astro +9 -5
  63. package/components/steps/meta.json +3 -1
  64. package/components/tabs/Tabs.astro +8 -4
  65. package/components/tabs/meta.json +1 -1
  66. package/components/team/Team.astro +10 -6
  67. package/components/team/meta.json +4 -2
  68. package/components/testimonial/Testimonial.astro +9 -5
  69. package/components/testimonial/fixtures.json +1 -1
  70. package/components/testimonial/meta.json +3 -1
  71. package/package.json +1 -1
@@ -19,6 +19,8 @@ import Button from '../_shared/Button.astro'
19
19
  import Section from '../_shared/Section.astro'
20
20
  import type { ActionInput, MeasuredStyle } from '../_shared/types'
21
21
  import Prose from '../prose/Prose.astro'
22
+ import { instanceId } from '../_shared/instance-id'
23
+ import { mutedReset } from '../_shared/muted'
22
24
 
23
25
  const dialog = tv({
24
26
  slots: {
@@ -28,7 +30,7 @@ const dialog = tv({
28
30
  // modal (the `open` preview prop) it is a block in the flow under the button, not a box over it. The inner box
29
31
  // scrolls, not the dialog: a click on the scrollbar then never lands on the dialog itself (the backdrop test). The
30
32
  // display is set only when open (`open:flex`): an author `display` would override the UA's `display: none` when closed.
31
- box: 'm-auto w-[min(92vw,var(--dialog-width))] max-h-[85dvh] flex-col open:flex rounded-card border border-line bg-surface p-0 text-ink shadow-xl backdrop:bg-ink/50 [&[open]:not(:modal)]:relative [&[open]:not(:modal)]:m-0 [&[open]:not(:modal)]:basis-full',
33
+ box: `m-auto w-[min(92vw,var(--dialog-width))] max-h-[85dvh] flex-col open:flex rounded-card border border-line bg-surface p-0 text-ink shadow-xl backdrop:bg-ink/50 [&[open]:not(:modal)]:relative [&[open]:not(:modal)]:m-0 [&[open]:not(:modal)]:basis-full ${mutedReset}`,
32
34
  inner: 'flex min-h-0 flex-col gap-4 overflow-y-auto p-6 md:p-8',
33
35
  heading: 'pe-10 text-xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-2xl',
34
36
  actions: 'flex flex-wrap gap-3',
@@ -78,10 +80,11 @@ interface Props {
78
80
  fact?: string | undefined
79
81
  }
80
82
 
81
- const { id: givenId = 'dialog', triggerLabel, heading, html, actions = [], closeLabel = 'Close', triggerStyle, size, align: given, tone, measured, open = false, trigger, fact } = Astro.props
83
+ const { id: givenId, triggerLabel, heading, html, actions = [], closeLabel = 'Close', triggerStyle, size, align: given, tone, measured, open = false, trigger, fact } = Astro.props
82
84
  const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
83
85
  // The id goes into attributes and into the scripting-off style sheet, so it is a plain token or the default.
84
- const id = /^[A-Za-z][\w-]*$/.test(givenId) ? givenId : 'dialog'
86
+ // Two dialogs without one of their own never share it (each opener would open the first).
87
+ const id = instanceId(Astro.locals, givenId && /^[A-Za-z][\w-]*$/.test(givenId) ? givenId : undefined, 'dialog')
85
88
  const align = given ?? measured?.align
86
89
  // On the accent band the primary button (accent) would vanish: drawn as the secondary style there, as in Cta. The
87
90
  // dialog's own actions sit on the dialog's surface, not the band, so they keep their styles.
@@ -9,7 +9,9 @@
9
9
  "shared": [
10
10
  "Button.astro",
11
11
  "Section.astro",
12
+ "instance-id.ts",
12
13
  "measured.ts",
14
+ "muted.ts",
13
15
  "safe-url.ts",
14
16
  "types.ts"
15
17
  ],
@@ -7,6 +7,8 @@ import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
8
  import { measuredColumns } from '../_shared/measured'
9
9
  import type { MeasuredStyle } from '../_shared/types'
10
+ import { instanceId } from '../_shared/instance-id'
11
+ import { muted } from '../_shared/muted'
10
12
 
11
13
  export interface FaqItem {
12
14
  question: string
@@ -19,7 +21,7 @@ const faq = tv({
19
21
  root: '',
20
22
  head: 'mb-8 flex flex-col gap-3',
21
23
  heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
22
- intro: 'max-w-2xl text-lg opacity-80',
24
+ intro: `max-w-2xl text-lg ${muted}`,
23
25
  list: 'flex flex-col',
24
26
  item: 'group',
25
27
  question: '',
@@ -37,7 +39,7 @@ const faq = tv({
37
39
  columns: { '1': {}, '2': { list: 'lg:block lg:columns-2 lg:gap-x-12', item: 'break-inside-avoid' } },
38
40
  // The marker in each question's row: a plus that turns into a cross, or a chevron that turns over.
39
41
  icon: { plus: {}, chevron: { marker: 'group-open:rotate-180' } },
40
- numbered: { true: { list: '[counter-reset:faq]', item: '[counter-increment:faq]', question: 'before:me-4 before:tabular-nums before:opacity-60 before:content-[counter(faq,decimal-leading-zero)]' }, false: {} },
42
+ numbered: { true: { list: '[counter-reset:faq]', item: '[counter-increment:faq]', question: 'before:me-4 before:tabular-nums before:text-[color:var(--kit-muted,var(--color-ink-muted))] before:content-[counter(faq,decimal-leading-zero)]' }, false: {} },
41
43
  style: {
42
44
  lines: { list: 'divide-y divide-line border-y border-line' },
43
45
  cards: { list: 'gap-3', item: 'rounded-card border border-line px-5', summary: 'py-5' },
@@ -84,7 +86,9 @@ interface Props {
84
86
  id?: string | undefined
85
87
  }
86
88
 
87
- const { items, heading, intro, layout, columns, icon, numbered = false, style, questionStyle, exclusive = false, openFirst = false, schema = false, tone, measured, id = 'faq' } = Astro.props
89
+ const { items, heading, intro, layout, columns, icon, numbered = false, style, questionStyle, exclusive = false, openFirst = false, schema = false, tone, measured, id: givenId } = Astro.props
90
+ // Two on one page never share an id: their labels and controls would point into each other.
91
+ const id = instanceId(Astro.locals, givenId, 'faq')
88
92
  const s = faq({ layout, columns, icon, numbered, style, questionStyle: style === 'plain' ? 'heading' : questionStyle, tone })
89
93
  // Two columns and the split layout need the page's width; one column reads in the narrow one.
90
94
  const measuredCols = measuredColumns(measured)
@@ -103,7 +107,7 @@ const structured = schema && items.length > 0
103
107
  : null
104
108
  ---
105
109
  {items.length > 0 && (
106
- <Section measured={measured} tone={tone} spacing="md" width={wide ? 'page' : 'prose'} id={id} aria-labelledby={headingId}>
110
+ <Section kit="faq" measured={measured} tone={tone} spacing="md" width={wide ? 'page' : 'prose'} id={id} aria-labelledby={headingId}>
107
111
  <div class={s.root()}>
108
112
  {(heading || intro) && (
109
113
  <header class={s.head()}>
@@ -4,7 +4,7 @@
4
4
  "description": "Questions and answers as native <details> disclosures (no script), in lines or cards; optionally one open at a time, and optionally with FAQPage structured data for search engines and AI answers.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Section.astro", "measured.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["Section.astro", "instance-id.ts", "measured.ts", "muted.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["single", "split"], "default": "single", "description": "single: the heading above one column. split: the heading and introduction stay in a left column beside the list; stacks on a phone." },
10
10
  "columns": { "options": ["1", "2"], "default": "1", "description": "Columns of questions on wide screens." },
@@ -4,8 +4,10 @@
4
4
  import { tv } from 'tailwind-variants'
5
5
  import KitImage from '../_shared/KitImage.astro'
6
6
  import Section, { type Tone } from '../_shared/Section.astro'
7
+ import { instanceId } from '../_shared/instance-id'
7
8
  import { measuredGrid } from '../_shared/measured'
8
9
  import type { MeasuredStyle } from '../_shared/types'
10
+ import { muted } from '../_shared/muted'
9
11
 
10
12
  const list = tv({
11
13
  slots: {
@@ -13,7 +15,7 @@ const list = tv({
13
15
  item: 'flex gap-4',
14
16
  mark: 'flex size-6 shrink-0 items-center justify-center text-accent-text',
15
17
  title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
16
- text: 'mt-1 opacity-80',
18
+ text: `mt-1 ${muted}`,
17
19
  },
18
20
  variants: {
19
21
  columns: {
@@ -28,7 +30,7 @@ const list = tv({
28
30
  list: { grid: 'flex flex-col gap-3', item: 'items-center gap-3', mark: 'size-5', title: 'text-[length:var(--text-body,1rem)] leading-normal [font-weight:inherit] text-pretty', text: 'mt-0' },
29
31
  },
30
32
  // On accent the mark takes the ink colour, and text is never dimmed.
31
- tone: { default: {}, muted: {}, inverse: {}, accent: { mark: 'text-current', text: 'opacity-100' } },
33
+ tone: { default: {}, muted: {}, inverse: {}, accent: { mark: 'text-current' } },
32
34
  // A list inside another section's column: one column, tighter, no section around it.
33
35
  bare: { true: { grid: 'sm:grid-cols-1 gap-y-5' }, false: {} },
34
36
  },
@@ -73,9 +75,11 @@ interface Props {
73
75
  measured?: MeasuredStyle | undefined
74
76
  }
75
77
 
76
- const { heading, intro, items, columns, style = 'icon', tone, bare = false, titleLevel = 3, id, measured } = Astro.props
78
+ const { heading, intro, items, columns, style = 'icon', tone, bare = false, titleLevel = 3, id: givenId, measured } = Astro.props
79
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `feature-list`, `feature-list-2`, … in render order.
80
+ const id = instanceId(Astro.locals, givenId, 'feature-list')
77
81
  const s = list({ columns, style, tone, bare })
78
- const headingId = heading && id ? `${id}-title` : undefined
82
+ const headingId = heading ? `${id}-title` : undefined
79
83
  const pathOf = (item: Item) => (style !== 'check' && item.icon && icons[item.icon]) || icons.check
80
84
  // The feature's own image icon, where the style shows each feature's own mark.
81
85
  const imageOf = (item: Item) => (style !== 'check' && item.iconImage) || undefined
@@ -101,11 +105,11 @@ const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : head
101
105
  </ul>
102
106
  )
103
107
  : (
104
- <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
108
+ <Section kit="feature-list" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
105
109
  {(heading || intro) && (
106
110
  <header class="mb-10 flex max-w-2xl flex-col gap-3">
107
111
  {heading && <h2 id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{heading}</h2>}
108
- {intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']} data-kit-text>{intro}</p>}
112
+ {intro && <p class:list={['text-lg', muted]} data-kit-text>{intro}</p>}
109
113
  </header>
110
114
  )}
111
115
  <ul class={s.grid({ class: measuredGrid(measured) })}>
@@ -7,7 +7,9 @@
7
7
  "shared": [
8
8
  "KitImage.astro",
9
9
  "Section.astro",
10
+ "instance-id.ts",
10
11
  "measured.ts",
12
+ "muted.ts",
11
13
  "safe-url.ts",
12
14
  "types.ts"
13
15
  ],
@@ -219,7 +221,7 @@
219
221
  "id": {
220
222
  "type": "string",
221
223
  "content": false,
222
- "description": "Anchor id of the section; the heading gets `<id>-title`."
224
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `feature-list`, `feature-list-2`, … in render order, so every section on a page has its own."
223
225
  }
224
226
  },
225
227
  "slots": {},
@@ -5,13 +5,14 @@ import { tv } from 'tailwind-variants'
5
5
  import type { LinkInput } from '../_shared/types'
6
6
  import { contactKeys, footerLinkSizeClasses, footerVars, type ContactInput } from './footer'
7
7
  import { safeHref } from '../_shared/safe-url'
8
+ import { muted as quiet, mutedInverse } from '../_shared/muted'
8
9
 
9
10
  const footer = tv({
10
11
  slots: {
11
12
  root: 'mt-auto',
12
13
  inner: 'container-page py-10 text-sm',
13
- muted: 'opacity-75',
14
- link: 'text-current no-underline opacity-75 hover:opacity-100 hover:underline',
14
+ muted: quiet,
15
+ link: `no-underline ${quiet} hover:text-current hover:underline`,
15
16
  },
16
17
  variants: {
17
18
  layout: {
@@ -21,7 +22,7 @@ const footer = tv({
21
22
  tone: {
22
23
  default: { root: 'bg-surface text-ink' },
23
24
  muted: { root: 'bg-surface-muted text-ink' },
24
- inverse: { root: 'bg-ink text-surface' },
25
+ inverse: { root: `bg-ink text-surface ${mutedInverse}` },
25
26
  },
26
27
  // The border on the footer's top side: the default tone's own unless `border` says otherwise.
27
28
  border: { on: { root: 'border-t border-line' }, off: {} },
@@ -5,6 +5,7 @@
5
5
  "category": "chrome",
6
6
  "uses": [],
7
7
  "shared": [
8
+ "muted.ts",
8
9
  "safe-url.ts",
9
10
  "types.ts"
10
11
  ],
@@ -12,6 +12,8 @@ import type { MeasuredStyle } from '../_shared/types'
12
12
  import { imageOf, type ImageSource } from '../_shared/image'
13
13
  import { type ImageInput } from '../_shared/types'
14
14
  import { safeHref } from '../_shared/safe-url'
15
+ import { instanceId } from '../_shared/instance-id'
16
+ import { muted } from '../_shared/muted'
15
17
 
16
18
  export interface GalleryItem {
17
19
  /** The image's address (or, until 0.7, an `ImageInput`). */
@@ -29,7 +31,7 @@ const gallery = tv({
29
31
  item: 'm-0',
30
32
  frame: 'group relative block w-full cursor-zoom-in overflow-hidden rounded-[var(--radius-image,var(--radius-card))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
31
33
  thumb: 'h-full w-full object-cover transition-transform duration-300 hover:scale-[1.02]',
32
- caption: 'mt-2 text-sm opacity-75',
34
+ caption: `mt-2 text-sm ${muted}`,
33
35
  viewer: 'm-auto max-h-[92vh] max-w-[92vw] bg-transparent p-0 backdrop:bg-ink/85',
34
36
  },
35
37
  variants: {
@@ -85,14 +87,16 @@ interface Props {
85
87
  id?: string | undefined
86
88
  }
87
89
 
88
- const { items: given, heading, layout, columns, aspect, gap, captionStyle, hoverEffect, enlargeLabel = 'Enlarge', regionLabel = 'Images', open = 'popover', closeLabel = 'Close', tone, measured, id = 'gallery' } = Astro.props
90
+ const { items: given, heading, layout, columns, aspect, gap, captionStyle, hoverEffect, enlargeLabel = 'Enlarge', regionLabel = 'Images', open = 'popover', closeLabel = 'Close', tone, measured, id: givenId } = Astro.props
91
+ // Two on one page never share an id: their labels and controls would point into each other.
92
+ const id = instanceId(Astro.locals, givenId, 'gallery')
89
93
  const items = given.flatMap(item => { const image = imageOf(item.image, item.imageAlt); return image ? [{ ...item, image, full: imageOf(item.full, item.imageAlt) }] : [] }) satisfies Array<{ image: ImageInput, caption?: string | undefined }>
90
94
  const s = gallery({ layout, columns, open, aspect, gap, captionStyle, hoverEffect })
91
95
  const headingId = heading ? `${id}-title` : undefined
92
96
  const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 25vw, 50vw' : '(min-width: 768px) 33vw, 50vw'
93
97
  ---
94
98
  {items.length > 0 && (
95
- <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
99
+ <Section kit="gallery" measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
96
100
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
97
101
  {/* A scrolling strip is a labelled, focusable region so the keyboard can scroll it. */}
98
102
  <div tabindex={layout === 'row' ? 0 : undefined} role={layout === 'row' ? 'region' : undefined} aria-label={layout === 'row' ? heading ?? regionLabel : undefined}>
@@ -107,7 +111,7 @@ const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 2
107
111
  : open === 'link'
108
112
  ? <a href={safeHref(item.image.src)} class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></a>
109
113
  : <div class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></div>}
110
- {item.caption && <figcaption class={s.caption({ class: tone === 'accent' ? 'opacity-100' : undefined })}>{item.caption}</figcaption>}
114
+ {item.caption && <figcaption class={s.caption()}>{item.caption}</figcaption>}
111
115
  </figure>
112
116
  {open === 'popover' && (
113
117
  <div id={viewer} popover class={s.viewer()}>
@@ -8,7 +8,9 @@
8
8
  "KitImage.astro",
9
9
  "Section.astro",
10
10
  "image.ts",
11
+ "instance-id.ts",
11
12
  "measured.ts",
13
+ "muted.ts",
12
14
  "safe-url.ts",
13
15
  "types.ts"
14
16
  ],
@@ -7,8 +7,10 @@ import KitImage from '../_shared/KitImage.astro'
7
7
  import type { ActionInput, ImageInput, NavItem } from '../_shared/types'
8
8
  import Nav from '../nav/Nav.astro'
9
9
  import SearchBox from '../search-box/SearchBox.astro'
10
+ import { instanceId } from '../_shared/instance-id'
10
11
  import { safeHref } from '../_shared/safe-url'
11
12
  import { brandColorValue } from './header'
13
+ import { muted, mutedInverse } from '../_shared/muted'
12
14
 
13
15
  const header = tv({
14
16
  slots: {
@@ -24,8 +26,8 @@ const header = tv({
24
26
  },
25
27
  tone: {
26
28
  default: { root: 'bg-surface text-ink' },
27
- inverse: { root: 'bg-ink text-surface' },
28
- transparent: { root: 'absolute inset-x-0 top-0 bg-transparent text-surface' },
29
+ inverse: { root: `bg-ink text-surface ${mutedInverse}` },
30
+ transparent: { root: `absolute inset-x-0 top-0 bg-transparent text-surface ${mutedInverse}` },
29
31
  },
30
32
  position: { static: {}, sticky: { root: 'sticky top-0' } },
31
33
  bordered: { true: {}, false: {} },
@@ -76,6 +78,8 @@ interface Props {
76
78
 
77
79
  const { siteName, tagline, brandSize, brandWeight, brandTracking, bordered, offsetTop, padTop, padBottom, linkNav, brandColor, logo, showName = true, homeHref = '/', items, action, search, navLabel, menuLabel, closeLabel, layout, tone, position, menu } = Astro.props
78
80
  const { root, inner, brand } = header({ layout, tone, position, bordered })
81
+ // The header's search field: `header-search` on a page, a second header's its own (its label points at its own input).
82
+ const searchId = search ? instanceId(Astro.locals, undefined, 'header-search') : undefined
79
83
  const rootStyle = offsetTop ? `margin-top: ${offsetTop}` : undefined
80
84
  const innerStyle = [padTop && `padding-top: ${padTop}`, padBottom && `padding-bottom: ${padBottom}`].filter(Boolean).join('; ') || undefined
81
85
  const brandColour = brandColorValue(brandColor)
@@ -93,7 +97,7 @@ const wrap = search ? 'flex-wrap lg:flex-nowrap' : tagline ? 'flex-wrap md:flex-
93
97
  {logo && <KitImage image={logo} priority class="h-8 w-auto" sizes="160px" />}
94
98
  {showName && <span>{siteName}</span>}
95
99
  </a>
96
- <p class="opacity-75" data-kit-tagline>{tagline}</p>
100
+ <p class={muted} data-kit-tagline>{tagline}</p>
97
101
  </div>
98
102
  )
99
103
  : (
@@ -104,7 +108,7 @@ const wrap = search ? 'flex-wrap lg:flex-nowrap' : tagline ? 'flex-wrap md:flex-
104
108
  )}
105
109
  {search && (
106
110
  <div class="order-last basis-full lg:order-none lg:ms-auto lg:basis-auto">
107
- <SearchBox action={search.action} label={search.label} placeholder={search.placeholder} submitLabel={search.submitLabel} size="sm" id="header-search" />
111
+ <SearchBox action={search.action} label={search.label} placeholder={search.placeholder} submitLabel={search.submitLabel} size="sm" id={searchId} />
108
112
  </div>
109
113
  )}
110
114
  <div class:list={['flex items-center gap-4', layout === 'centered' ? 'justify-center' : 'ms-auto']}>
@@ -4,7 +4,7 @@
4
4
  "description": "Site header: brand (logo and/or name), navigation and an optional call-to-action button.",
5
5
  "category": "chrome",
6
6
  "uses": ["nav", "search-box"],
7
- "shared": ["Button.astro", "KitImage.astro", "safe-url.ts", "types.ts"],
7
+ "shared": ["Button.astro", "KitImage.astro", "instance-id.ts", "muted.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["split", "centered", "brand-left"], "default": "split", "description": "split: brand left, navigation right. centered: brand above centred navigation. brand-left: navigation right after the brand." },
10
10
  "tone": { "options": ["default", "inverse", "transparent"], "default": "default", "description": "Surface with a bottom border, dark (ink) background, or transparent over the hero that follows." },
@@ -5,17 +5,21 @@ import { tv } from 'tailwind-variants'
5
5
  import Button from '../_shared/Button.astro'
6
6
  import KitImage from '../_shared/KitImage.astro'
7
7
  import Section from '../_shared/Section.astro'
8
+ import Lines from '../_shared/Lines.astro'
9
+ import { instanceId } from '../_shared/instance-id'
8
10
  import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
9
11
  import { measuredFrame } from '../_shared/measured'
10
12
  import { coverHeight as heightOf, headingStyle as styleOf } from './hero'
13
+ import { muted, mutedInverse } from '../_shared/muted'
14
+ import { BLEED } from '../_shared/bleed'
11
15
 
12
16
  const hero = tv({
13
17
  slots: {
14
18
  grid: 'grid items-center gap-10',
15
19
  copy: 'flex flex-col gap-5',
16
- eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
20
+ eyebrow: `text-sm font-semibold tracking-wide uppercase ${muted}`,
17
21
  heading: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-5xl lg:text-6xl',
18
- lead: 'max-w-2xl text-lg text-pretty opacity-85 md:text-xl',
22
+ lead: `max-w-2xl text-lg text-pretty ${muted} md:text-xl`,
19
23
  // A hero's short promise list ("Fast, easy and painless"), each with its icon when it has one.
20
24
  points: 'flex flex-wrap gap-x-6 gap-y-2 text-base font-medium md:text-lg',
21
25
  point: 'flex items-center gap-2',
@@ -29,7 +33,7 @@ const hero = tv({
29
33
  cover: { grid: 'relative z-10 min-h-[28rem] md:min-h-[36rem]', copy: 'max-w-3xl' },
30
34
  },
31
35
  // accent/accent-ink is only ~5:1 — dimming text on it drops body copy under 4.5:1 (WCAG 1.4.3), so accent keeps it at full strength.
32
- tone: { default: {}, muted: {}, inverse: {}, accent: { eyebrow: 'opacity-100', lead: 'opacity-100' } },
36
+ tone: { default: {}, muted: {}, inverse: {}, accent: {} },
33
37
  // Where a cover sets its text over the image. WordPress covers centre it unless told otherwise.
34
38
  align: { start: {}, center: {} },
35
39
  // The lead's own alignment: `start` keeps it at the start edge, across the column, under a centred heading
@@ -45,6 +49,18 @@ const hero = tv({
45
49
  headingScale: { default: {}, theme: {} },
46
50
  // The level whose site size the heading takes when it is not the level it is written at (set inline, see ./hero).
47
51
  headingSize: { own: {}, h1: {}, h2: {} },
52
+ // The heading as the author broke it: `lines` prints each `\n`-separated line as its own block (`data-kit-line`),
53
+ // `flow` (the default) as one run of text.
54
+ headingLines: { flow: {}, lines: {} },
55
+ // Split and centered (a cover ignores both): the image before the text, on phones and on the start side (split),
56
+ // or above the heading (centered).
57
+ stackOrder: { 'text-first': {}, 'media-first': { media: 'order-first' } },
58
+ // edge: the image runs on to the window's edge on its side (wide screens), or across the whole width when centered.
59
+ // overlap: it also crosses the section's bottom edge by `--hero-overlap` (3rem), drawn over the next section's top
60
+ // padding: set at the grid's bottom and moved past the hero's own bottom padding (`spacing="lg"`: 4rem, 7rem from md),
61
+ // not laid out there, so the next section stays where it was. Only an image that comes last can cross the bottom: one
62
+ // that comes first (`media-first`) would be moved down over the text, so there overlap draws as edge.
63
+ imageBleed: { none: {}, edge: { media: 'rounded-none' }, overlap: { media: 'relative z-10 self-end translate-y-[calc(4rem+var(--hero-overlap,3rem))] rounded-none md:translate-y-[calc(7rem+var(--hero-overlap,3rem))]' } },
48
64
  },
49
65
  compoundVariants: [
50
66
  // After the layout's own heights, so a boxed cover keeps WordPress's 430px default.
@@ -54,7 +70,7 @@ const hero = tv({
54
70
  { leadAlign: 'start', class: { lead: 'mx-0 max-w-none self-stretch text-start' } },
55
71
  { actionsAlign: 'start', class: { actions: 'justify-start self-stretch' } },
56
72
  ],
57
- defaultVariants: { layout: 'split', tone: 'default', align: 'center', leadAlign: 'inherit', actionsAlign: 'inherit', width: 'full', headingScale: 'default', headingSize: 'own' },
73
+ defaultVariants: { layout: 'split', tone: 'default', align: 'center', leadAlign: 'inherit', actionsAlign: 'inherit', width: 'full', headingScale: 'default', headingSize: 'own', headingLines: 'flow', stackOrder: 'text-first', imageBleed: 'none' },
58
74
  })
59
75
 
60
76
  interface Props {
@@ -84,33 +100,46 @@ interface Props {
84
100
  headingScale?: 'default' | 'theme' | undefined
85
101
  /** The level whose site size the heading takes, when it is not the level it is written at (`h1` size on an `h2`: a page keeps one `h1`). `own` (absent): the heading's own level under `theme`, as before. */
86
102
  headingSize?: 'own' | 'h1' | 'h2' | undefined
103
+ /** `lines`: each line of the heading (split at `\n`) is a block of its own, `data-kit-line="<n>"`, for a theme to set per line. `flow` (absent): one run of text, as before. */
104
+ headingLines?: 'flow' | 'lines' | undefined
105
+ /** Split and centered: `media-first` puts the image before the text (phones and the start side in split, above the heading in centered). `text-first` (absent): as before. */
106
+ stackOrder?: 'text-first' | 'media-first' | undefined
107
+ /** Split and centered: `edge` runs the image on to the window's edge on its side (both sides when centered); `overlap` also lets it cross the section's bottom edge by `--hero-overlap` (3rem), when the image comes last (with `media-first` it draws as `edge`). `none` (absent): as before. */
108
+ imageBleed?: 'none' | 'edge' | 'overlap' | undefined
87
109
  /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
88
110
  measured?: MeasuredStyle | undefined
89
111
  }
90
112
 
91
113
 
92
- const { heading, level = 1, eyebrow, lead, items = [], actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id, minHeight, headingScale, headingSize, measured } = Astro.props
93
- const s = hero({ layout, align, leadAlign, actionsAlign, width, tone, headingScale, headingSize })
114
+ const { heading, level = 1, eyebrow, lead, items = [], actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id: givenId, minHeight, headingScale, headingSize, headingLines, stackOrder, imageBleed, measured } = Astro.props
115
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `hero`, `hero-2`, … in render order.
116
+ const id = instanceId(Astro.locals, givenId, 'hero')
117
+ // Overlap moves the image down past the section's bottom, so only an image that comes last may: first, it is edge.
118
+ const bleedAs = imageBleed === 'overlap' && stackOrder === 'media-first' ? 'edge' : imageBleed
119
+ const s = hero({ layout, align, leadAlign, actionsAlign, width, tone, headingScale, headingSize, headingLines, stackOrder, imageBleed: bleedAs })
94
120
  const Heading = level === 1 ? 'h1' : 'h2'
95
- const headingId = id ? `${id}-title` : undefined
121
+ const headingId = `${id}-title`
96
122
  const cover = layout === 'cover' && image
97
123
  const coverHeight = cover ? heightOf(minHeight) : undefined
98
124
  const headingStyle = styleOf(headingScale, level, headingSize)
99
125
  // A cover draws its own image: the measured background image is that image, and the measured overlay is its scrim.
100
126
  const sectionMeasured = cover && measured ? { ...measured, bg: measured.bg ? { ...measured.bg, image: undefined, overlay: undefined } : undefined } : measured
101
127
  const scrim = cover ? measuredFrame(measured).overlay : undefined
128
+ // The window's edge for a bleeding image: its own side in split (the start side when it comes first), both when centered.
129
+ const bleeds = !cover && image && imageBleed !== undefined && imageBleed !== 'none'
130
+ const bleed = bleeds ? (layout === 'centered' ? `max-w-none md:w-auto ${BLEED.start} ${BLEED.end}` : BLEED[stackOrder === 'media-first' ? 'start' : 'end']) : undefined
102
131
  ---
103
132
  {cover && width === 'content'
104
133
  ? (
105
- <Section measured={sectionMeasured} tone="none" spacing="sm" width="prose" id={id} aria-labelledby={headingId}>
106
- <div class="relative isolate overflow-hidden bg-ink text-surface">
134
+ <Section kit="hero" measured={sectionMeasured} tone="none" spacing="sm" width="prose" id={id} aria-labelledby={headingId}>
135
+ <div class={`relative isolate overflow-hidden bg-ink text-surface ${mutedInverse}`}>
107
136
  <KitImage image={image} priority sizes="(min-width: 768px) 46rem, 100vw" class="absolute inset-0 -z-10 h-full w-full object-cover" />
108
137
  <div class:list={['absolute inset-0 -z-10', !scrim && 'bg-ink/60']} style={scrim ? `background: ${scrim}` : undefined} aria-hidden="true" />
109
138
  <div class="px-6 py-10 md:px-10">
110
139
  <div class={s.grid()} style={coverHeight ? `min-height: ${coverHeight}` : undefined}>
111
140
  <div class={s.copy()}>
112
141
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
113
- <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
142
+ <Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
114
143
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
115
144
  {items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
116
145
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
@@ -122,14 +151,14 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
122
151
  )
123
152
  : cover
124
153
  ? (
125
- <Section measured={sectionMeasured} tone="inverse" spacing="none" width="none" id={id} aria-labelledby={headingId} class="relative isolate overflow-hidden">
154
+ <Section kit="hero" measured={sectionMeasured} tone="inverse" spacing="none" width="none" id={id} aria-labelledby={headingId} class="relative isolate overflow-hidden">
126
155
  <KitImage image={image} priority sizes="100vw" class="absolute inset-0 -z-10 h-full w-full object-cover" />
127
156
  <div class:list={['absolute inset-0 -z-10', !scrim && 'bg-ink/60']} style={scrim ? `background: ${scrim}` : undefined} aria-hidden="true" />
128
157
  <div class:list={['container-page', measured?.padY === undefined && 'py-16 md:py-28']}>
129
158
  <div class={s.grid()} style={coverHeight ? `min-height: ${coverHeight}` : undefined}>
130
159
  <div class={s.copy()}>
131
160
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
132
- <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
161
+ <Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
133
162
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
134
163
  {items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
135
164
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
@@ -139,18 +168,18 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
139
168
  </Section>
140
169
  )
141
170
  : (
142
- <Section measured={sectionMeasured} tone={tone} spacing="lg" id={id} aria-labelledby={headingId}>
171
+ <Section kit="hero" measured={sectionMeasured} tone={tone} spacing="lg" id={id} aria-labelledby={headingId} class={bleeds ? 'overflow-x-clip' : undefined}>
143
172
  {/* Without an image a split has nothing beside the text: the text takes the whole row, not half of it. */}
144
173
  <div class={s.grid({ class: image ? undefined : 'md:grid-cols-1' })}>
145
174
  <div class={s.copy()}>
146
175
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
147
- <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
176
+ <Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
148
177
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
149
178
  {items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
150
179
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
151
180
  </div>
152
181
  {image && (
153
- <div class={s.media()}>
182
+ <div class:list={[s.media(), bleed]}>
154
183
  <KitImage image={image} priority sizes={layout === 'split' ? '(min-width: 768px) 50vw, 100vw' : '(min-width: 1024px) 56rem, 100vw'} class="h-auto w-full object-cover" />
155
184
  </div>
156
185
  )}
@@ -241,5 +241,11 @@
241
241
  }
242
242
  },
243
243
  {"name": "cover-measured-overlay", "props": {"heading": "Fresh eggs, every morning", "lead": "A family farm since 1962.", "layout": "cover", "image": {"src": "/kit-fixtures/photo-4.svg", "alt": "", "width": 1600, "height": 900}, "actions": [{"label": "Visit us", "href": "/visit/"}], "measured": {"padY": 140, "minHeight": 560, "tone": "dark", "bg": {"image": "/kit-fixtures/photo-4.svg", "overlay": "rgba(20, 40, 10, 0.45)"}}}},
244
- {"name": "points", "props": {"heading": "Private Sigorta'ya Hoşgeldiniz!", "items": [{"icon": "/kit-fixtures/logo.svg", "title": "Hızlı, Kolay & Sorunsuz"}, {"title": "En Uygun Fiyatlar"}], "actions": [{"label": "Ürünlerimiz", "href": "/urunlerimiz/"}], "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}}}
244
+ {"name": "points", "props": {"heading": "Private Sigorta'ya Hoşgeldiniz!", "items": [{"icon": "/kit-fixtures/logo.svg", "title": "Hızlı, Kolay & Sorunsuz"}, {"title": "En Uygun Fiyatlar"}], "actions": [{"label": "Ürünlerimiz", "href": "/urunlerimiz/"}], "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}}},
245
+ {"name": "heading-lines", "props": {"layout": "centered", "tone": "accent", "headingLines": "lines", "eyebrow": "Northwind Journal", "heading": "Slow travel.\nGood coffee.\nTwelve years on the road.", "lead": "Essays, guides and photographs, now in one place.", "actions": [{"label": "Start reading", "href": "/journal/"}]}},
246
+ {"name": "split-media-first", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}, "actions": [{"label": "Start", "href": "/start/"}]}},
247
+ {"name": "centered-media-first-edge", "props": {"layout": "centered", "heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "edge", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
248
+ {"name": "split-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
249
+ {"name": "centered-media-first-overlap", "props": {"layout": "centered", "heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
250
+ {"name": "split-media-first-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}}
245
251
  ]
@@ -7,8 +7,12 @@
7
7
  "shared": [
8
8
  "Button.astro",
9
9
  "KitImage.astro",
10
+ "Lines.astro",
10
11
  "Section.astro",
12
+ "bleed.ts",
13
+ "instance-id.ts",
11
14
  "measured.ts",
15
+ "muted.ts",
12
16
  "safe-url.ts",
13
17
  "types.ts"
14
18
  ],
@@ -80,6 +84,31 @@
80
84
  ],
81
85
  "default": "own",
82
86
  "description": "The level whose site size the heading takes when it is not the level it is written at: h1 on an h2 heading keeps a source's second h1 at the h1 size under a page that keeps one h1. own: the heading's own level under headingScale theme, nothing otherwise. Any other value than own sets the size by itself."
87
+ },
88
+ "headingLines": {
89
+ "options": [
90
+ "flow",
91
+ "lines"
92
+ ],
93
+ "default": "flow",
94
+ "description": "flow: the heading is one run of text. lines: each line the author broke it into (a line break, \\n, in the heading) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one heading, read as one sentence."
95
+ },
96
+ "stackOrder": {
97
+ "options": [
98
+ "text-first",
99
+ "media-first"
100
+ ],
101
+ "default": "text-first",
102
+ "description": "Split and centered: the image before the text, on phones and on the start side (split), or above the heading (centered). A cover ignores it."
103
+ },
104
+ "imageBleed": {
105
+ "options": [
106
+ "none",
107
+ "edge",
108
+ "overlap"
109
+ ],
110
+ "default": "none",
111
+ "description": "Split and centered: edge runs the image on to the window's edge on its side (both sides when centered); overlap also lets it cross the section's bottom edge by --hero-overlap (3rem), over the next section's top padding, when the image comes last: with stackOrder media-first it draws as edge, so it never covers the text. A cover ignores it."
83
112
  }
84
113
  },
85
114
  "props": {
@@ -198,7 +227,7 @@
198
227
  "id": {
199
228
  "type": "string",
200
229
  "content": false,
201
- "description": "Anchor id of the section; the heading gets `<id>-title`."
230
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `hero`, `hero-2`, … in render order, so every section on a page has its own."
202
231
  },
203
232
  "minHeight": {
204
233
  "type": "integer",
@@ -5,13 +5,14 @@
5
5
  // equivalent in each language); a link without one is not drawn.
6
6
  import { tv } from 'tailwind-variants'
7
7
  import { safeHref } from '../_shared/safe-url'
8
+ import { muted, mutedReset } from '../_shared/muted'
8
9
 
9
10
  const switcher = tv({
10
11
  slots: {
11
12
  list: 'flex flex-wrap items-center gap-x-4 gap-y-1',
12
- link: 'text-current no-underline opacity-80 hover:opacity-100 hover:underline aria-[current=true]:font-semibold aria-[current=true]:opacity-100',
13
+ link: `no-underline ${muted} hover:text-current hover:underline aria-[current=true]:font-semibold aria-[current=true]:text-current`,
13
14
  summary: 'inline-flex cursor-pointer list-none items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-current/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent [&::-webkit-details-marker]:hidden',
14
- panel: 'absolute z-30 mt-1 flex min-w-40 flex-col gap-1 rounded-lg border border-line bg-surface p-2 text-ink shadow-lg',
15
+ panel: `absolute z-30 mt-1 flex min-w-40 flex-col gap-1 rounded-lg border border-line bg-surface p-2 text-ink shadow-lg ${mutedReset}`,
15
16
  },
16
17
  variants: {
17
18
  layout: { list: {}, menu: {} },
@@ -4,7 +4,7 @@
4
4
  "description": "Links to the same page in the site's other languages, each with hreflang and its own lang; the current one is marked aria-current. A plain list or a native <details> menu; no JavaScript. The addresses are the page's equivalents in each language, supplied by the caller.",
5
5
  "category": "chrome",
6
6
  "uses": [],
7
- "shared": ["safe-url.ts"],
7
+ "shared": ["muted.ts", "safe-url.ts"],
8
8
  "variants": {
9
9
  "layout": {
10
10
  "options": [