@contentrain/astro-kit 0.40.0 → 0.41.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 (66) hide show
  1. package/README.md +16 -0
  2. package/catalog.json +76 -13
  3. package/components/_shared/Section.astro +13 -9
  4. package/components/_shared/SectionHeader.astro +3 -2
  5. package/components/_shared/instance-id.ts +24 -0
  6. package/components/_shared/muted.ts +19 -0
  7. package/components/band/Band.astro +10 -7
  8. package/components/band/meta.json +2 -0
  9. package/components/behavior/Behavior.astro +7 -2
  10. package/components/behavior/fixtures.json +2 -2
  11. package/components/behavior/meta.json +7 -0
  12. package/components/card-grid/CardGrid.astro +13 -9
  13. package/components/card-grid/meta.json +7 -0
  14. package/components/contact-details/ContactDetails.astro +7 -3
  15. package/components/contact-details/meta.json +2 -0
  16. package/components/contact-form/ContactForm.astro +11 -7
  17. package/components/contact-form/meta.json +2 -0
  18. package/components/cta/Cta.astro +14 -10
  19. package/components/cta/meta.json +2 -2
  20. package/components/dialog/Dialog.astro +6 -3
  21. package/components/dialog/meta.json +2 -0
  22. package/components/faq/Faq.astro +8 -4
  23. package/components/faq/meta.json +1 -1
  24. package/components/feature-list/FeatureList.astro +10 -6
  25. package/components/feature-list/meta.json +3 -1
  26. package/components/footer/Footer.astro +4 -3
  27. package/components/footer/meta.json +1 -0
  28. package/components/gallery/Gallery.astro +8 -4
  29. package/components/gallery/meta.json +2 -0
  30. package/components/header/Header.astro +8 -4
  31. package/components/header/meta.json +1 -1
  32. package/components/hero/Hero.astro +13 -9
  33. package/components/hero/meta.json +3 -1
  34. package/components/language-switcher/LanguageSwitcher.astro +3 -2
  35. package/components/language-switcher/meta.json +1 -1
  36. package/components/logo-cloud/LogoCloud.astro +9 -5
  37. package/components/logo-cloud/meta.json +3 -1
  38. package/components/nav/Nav.astro +10 -6
  39. package/components/nav/meta.json +2 -0
  40. package/components/newsletter/Newsletter.astro +9 -5
  41. package/components/newsletter/meta.json +2 -0
  42. package/components/post-list/PostList.astro +7 -3
  43. package/components/post-list/meta.json +2 -0
  44. package/components/pricing/Pricing.astro +15 -11
  45. package/components/pricing/meta.json +3 -1
  46. package/components/search-box/SearchBox.astro +4 -1
  47. package/components/search-box/meta.json +1 -1
  48. package/components/slider/Slider.astro +4 -1
  49. package/components/slider/SliderTrack.astro +11 -5
  50. package/components/slider/meta.json +1 -1
  51. package/components/social-links/SocialLinks.astro +2 -1
  52. package/components/social-links/meta.json +3 -2
  53. package/components/split/Split.astro +9 -5
  54. package/components/split/meta.json +3 -1
  55. package/components/stats/Stats.astro +11 -7
  56. package/components/stats/meta.json +3 -1
  57. package/components/steps/Steps.astro +9 -5
  58. package/components/steps/meta.json +3 -1
  59. package/components/tabs/Tabs.astro +8 -4
  60. package/components/tabs/meta.json +1 -1
  61. package/components/team/Team.astro +10 -6
  62. package/components/team/meta.json +4 -2
  63. package/components/testimonial/Testimonial.astro +9 -5
  64. package/components/testimonial/fixtures.json +1 -1
  65. package/components/testimonial/meta.json +3 -1
  66. package/package.json +1 -1
@@ -9,7 +9,9 @@
9
9
  "behaviors.ts",
10
10
  "embed-frame.ts",
11
11
  "embed-target.ts",
12
+ "instance-id.ts",
12
13
  "measured.ts",
14
+ "muted.ts",
13
15
  "safe-url.ts",
14
16
  "types.ts"
15
17
  ],
@@ -195,6 +197,11 @@
195
197
  "description": "The section's minimum height."
196
198
  }
197
199
  }
200
+ },
201
+ "id": {
202
+ "type": "string",
203
+ "content": false,
204
+ "description": "Anchor id of the section. Without one, `behavior`, `behavior-2`, … in render order, so every section on a page has its own."
198
205
  }
199
206
  },
200
207
  "slots": {},
@@ -4,10 +4,12 @@
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'
9
10
  import { imageOf, type ImageSource } from '../_shared/image'
10
11
  import { safeHref } from '../_shared/safe-url'
12
+ import { muted, mutedReset } from '../_shared/muted'
11
13
 
12
14
  const grid = tv({
13
15
  slots: {
@@ -15,7 +17,7 @@ const grid = tv({
15
17
  card: 'group relative flex flex-col gap-3 rounded-[var(--radius-card)]',
16
18
  media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
17
19
  title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
18
- text: 'opacity-80',
20
+ text: muted,
19
21
  },
20
22
  variants: {
21
23
  columns: {
@@ -26,7 +28,7 @@ const grid = tv({
26
28
  style: {
27
29
  plain: {},
28
30
  bordered: { card: 'border border-line p-6', media: '-mx-6 -mt-6 mb-2 rounded-b-none' },
29
- elevated: { card: 'bg-surface p-6 text-ink shadow-md ring-1 ring-line/60', media: '-mx-6 -mt-6 mb-2 rounded-b-none' },
31
+ elevated: { card: `bg-surface p-6 text-ink shadow-md ring-1 ring-line/60 ${mutedReset}`, media: '-mx-6 -mt-6 mb-2 rounded-b-none' },
30
32
  },
31
33
  align: { start: { card: 'text-start' }, center: { card: 'items-center text-center' } },
32
34
  },
@@ -59,22 +61,24 @@ interface Props {
59
61
  tone?: Tone | undefined
60
62
  /** 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. */
61
63
  measured?: MeasuredStyle | undefined
64
+ /** Section id, unique per page; without one `card-grid`, `card-grid-2`, … in render order. Its title is `<id>-title`. */
65
+ id?: string | undefined
62
66
  }
63
67
 
64
- const { title, intro, items, headingLevel = 2, columns, style, align, tone, measured } = Astro.props
68
+ const { title, intro, items, headingLevel = 2, columns, style, align, tone, measured, id: givenId } = Astro.props
69
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `card-grid`, `card-grid-2`, … in render order.
70
+ const id = instanceId(Astro.locals, givenId, 'card-grid')
65
71
  const { list, card, media, title: cardTitle, text } = grid({ columns, style, align })
66
- // Accent backgrounds have the least contrast headroom: text on them is never dimmed.
67
- const dim = tone === 'accent' ? 'opacity-100' : undefined
68
72
  const Heading = `h${headingLevel}` as 'h2' | 'h3'
69
73
  // One level under the section title, or the title's level when there is none.
70
74
  const CardHeading = `h${title ? headingLevel + 1 : headingLevel}` as 'h2' | 'h3' | 'h4'
71
- const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : undefined
75
+ const headingId = title ? `${id}-title` : undefined
72
76
  ---
73
- <Section measured={measured} tone={tone} aria-labelledby={headingId}>
77
+ <Section kit="card-grid" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
74
78
  {(title || intro) && (
75
79
  <header class:list={['mb-10 flex max-w-2xl flex-col gap-3', align === 'center' && 'mx-auto items-center text-center']}>
76
80
  {title && <Heading id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{title}</Heading>}
77
- {intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']}>{intro}</p>}
81
+ {intro && <p class:list={['text-lg', muted]}>{intro}</p>}
78
82
  </header>
79
83
  )}
80
84
  <ul class={list({ class: measuredGrid(measured) })}>
@@ -88,7 +92,7 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
88
92
  ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
89
93
  : item.title}
90
94
  </CardHeading>
91
- {item.text && <p class={text({ class: dim })} data-kit-text>{item.text}</p>}
95
+ {item.text && <p class={text()} data-kit-text>{item.text}</p>}
92
96
  {item.href && item.linkLabel && <a href={safeHref(item.href)} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
93
97
  </li>
94
98
  ))}
@@ -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
  ],
@@ -230,6 +232,11 @@
230
232
  "description": "The section's minimum height."
231
233
  }
232
234
  }
235
+ },
236
+ "id": {
237
+ "type": "string",
238
+ "content": false,
239
+ "description": "Anchor id of the section; the title gets `<id>-title`. Without one, `card-grid`, `card-grid-2`, … in render order, so every section on a page has its own."
233
240
  }
234
241
  },
235
242
  "slots": {},
@@ -8,12 +8,14 @@ import Section from '../_shared/Section.astro'
8
8
  import { measuredGrid } from '../_shared/measured'
9
9
  import type { MeasuredStyle } from '../_shared/types'
10
10
  import { safeHref } from '../_shared/safe-url'
11
+ import { instanceId } from '../_shared/instance-id'
12
+ import { muted } from '../_shared/muted'
11
13
 
12
14
  const details = tv({
13
15
  slots: {
14
16
  heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
15
17
  list: 'grid gap-6',
16
- term: 'text-sm font-semibold opacity-75',
18
+ term: `text-sm font-semibold ${muted}`,
17
19
  value: 'mt-1 not-italic whitespace-pre-line',
18
20
  link: 'underline decoration-line underline-offset-4 hover:decoration-current',
19
21
  },
@@ -50,9 +52,11 @@ interface Props {
50
52
  const {
51
53
  heading, email, phone, address, hours, mapUrl,
52
54
  emailLabel = 'Email', phoneLabel = 'Phone', addressLabel = 'Address', hoursLabel = 'Opening hours', mapLabel = 'View on the map',
53
- layout, id = 'contact-details',
55
+ layout, id: givenId,
54
56
  measured,
55
57
  } = Astro.props
58
+ // Two on one page never share an id: their labels and controls would point into each other.
59
+ const id = instanceId(Astro.locals, givenId, 'contact-details')
56
60
  const s = details({ layout })
57
61
  const headingId = heading ? `${id}-title` : undefined
58
62
  // A dialler wants the number without spaces, dashes or brackets; the leading + stays.
@@ -60,7 +64,7 @@ const tel = phone ? `tel:${phone.replace(/[^\d+]/g, '')}` : undefined
60
64
  const shown = Boolean(email || phone || address || hours || mapUrl)
61
65
  ---
62
66
  {shown && (
63
- <Section measured={measured} spacing="md" id={id} aria-labelledby={headingId}>
67
+ <Section kit="contact-details" measured={measured} spacing="md" id={id} aria-labelledby={headingId}>
64
68
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
65
69
  <dl class={s.list({ class: measuredGrid(measured) })}>
66
70
  {email && <div><dt class={s.term()}>{emailLabel}</dt><dd class={s.value()}><a href={`mailto:${email}`} class={s.link()}>{email}</a></dd></div>}
@@ -6,7 +6,9 @@
6
6
  "uses": [],
7
7
  "shared": [
8
8
  "Section.astro",
9
+ "instance-id.ts",
9
10
  "measured.ts",
11
+ "muted.ts",
10
12
  "safe-url.ts",
11
13
  "types.ts"
12
14
  ],
@@ -18,6 +18,8 @@ import StudioForm from '../../studio/StudioForm.astro'
18
18
  import { formHome } from '../../../lib/features'
19
19
  import { contactSectionBare } from './contact-form'
20
20
  import { safeHref } from '../_shared/safe-url'
21
+ import { instanceId } from '../_shared/instance-id'
22
+ import { muted, mutedReset } from '../_shared/muted'
21
23
 
22
24
  export interface ContactDetail {
23
25
  /** What the detail is (Phone, Email); a source's icon list often has none. */
@@ -34,16 +36,16 @@ const contact = tv({
34
36
  grid: 'grid gap-10',
35
37
  copy: 'flex flex-col gap-4',
36
38
  heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
37
- intro: 'max-w-xl text-pretty opacity-85',
39
+ intro: `max-w-xl text-pretty ${muted}`,
38
40
  details: 'mt-2 flex flex-col gap-4',
39
- term: 'text-sm font-semibold opacity-75',
41
+ term: `text-sm font-semibold ${muted}`,
40
42
  value: 'text-current',
41
43
  mark: 'mr-2 inline-flex size-5 shrink-0 align-[-0.25em]',
42
44
  formColumn: 'flex flex-col gap-4',
43
45
  formHeading: 'text-xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-2xl',
44
46
  form: 'rounded-card',
45
47
  card: 'flex flex-col gap-1 rounded-card border border-line p-4',
46
- hours: 'mt-2 whitespace-pre-line text-sm opacity-85',
48
+ hours: `mt-2 whitespace-pre-line text-sm ${muted}`,
47
49
  socials: 'mt-2 flex flex-wrap gap-4 text-sm',
48
50
  },
49
51
  variants: {
@@ -65,7 +67,7 @@ const contact = tv({
65
67
  // Beside a form it then titles the form; without one it follows the details.
66
68
  headingPlace: { top: {}, after: {} },
67
69
  // 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.
68
- tone: { default: { form: 'border border-line bg-surface p-6 md:p-8' }, muted: { form: 'bg-surface p-6 text-ink md:p-8' }, inverse: { form: 'bg-surface p-6 text-ink md:p-8' }, accent: { form: 'bg-surface p-6 text-ink md:p-8', intro: 'opacity-100', term: 'opacity-100' } },
70
+ tone: { default: { form: 'border border-line bg-surface p-6 md:p-8' }, muted: { form: `bg-surface p-6 text-ink md:p-8 ${mutedReset}` }, inverse: { form: `bg-surface p-6 text-ink md:p-8 ${mutedReset}` }, accent: { form: `bg-surface p-6 text-ink md:p-8 ${mutedReset}` } },
69
71
  },
70
72
  compoundVariants: [
71
73
  { layout: 'stacked', align: 'center', class: { grid: 'mx-auto max-w-2xl' } },
@@ -100,7 +102,9 @@ interface Props {
100
102
  id?: string | undefined
101
103
  }
102
104
 
103
- const { model, heading, intro, formHeading, details = [], layout, detailsStyle, panel, hours, socials = [], map, align, tone, headingPlace = 'top', measured, id = 'contact' } = Astro.props
105
+ const { model, heading, intro, formHeading, details = [], layout, detailsStyle, panel, hours, socials = [], map, align, tone, headingPlace = 'top', measured, id: givenId } = Astro.props
106
+ // Two on one page never share an id: their labels and controls would point into each other.
107
+ const id = instanceId(Astro.locals, givenId, 'contact')
104
108
  // Nothing to render (Studio not bound and no fallback): a split would leave an empty column — the details stand alone.
105
109
  const formShown = formHome(model) !== null
106
110
  const bare = contactSectionBare({ formShown, heading, intro, details })
@@ -120,7 +124,7 @@ const LINK = 'underline decoration-line underline-offset-4 hover:decoration-curr
120
124
  const FormHeading = heading ? 'h3' : 'h2'
121
125
  ---
122
126
  {!bare && (
123
- <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
127
+ <Section kit="contact-form" measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
124
128
  <div class={s.grid()}>
125
129
  {!formOnly && (heading || intro || details.length > 0 || hours || socials.length > 0 || map) && !(headingInForm && !hours && socials.length === 0 && !map && !intro && details.length === 0) && (
126
130
  <div class={s.copy()}>
@@ -157,7 +161,7 @@ const FormHeading = heading ? 'h3' : 'h2'
157
161
  {map && (
158
162
  <div>
159
163
  <Embed address={map.address} url={map.url} title={map.title} aspect="map" width="page" />
160
- {map.address && <p class="text-sm opacity-85">{map.address}</p>}
164
+ {map.address && <p class:list={['text-sm', muted]}>{map.address}</p>}
161
165
  </div>
162
166
  )}
163
167
  {!headingTop && headingInCopy && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
@@ -9,7 +9,9 @@
9
9
  "shared": [
10
10
  "KitImage.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
  ],
@@ -5,21 +5,23 @@ 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 { instanceId } from '../_shared/instance-id'
8
9
  import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
10
+ import { muted, mutedAccent, mutedBrand, mutedInverse } from '../_shared/muted'
9
11
 
10
12
  const cta = tv({
11
13
  slots: {
12
14
  box: 'flex flex-col gap-6',
13
15
  copy: 'flex flex-col gap-3',
14
- eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
16
+ eyebrow: `text-sm font-semibold tracking-wide uppercase ${muted}`,
15
17
  // The text and buttons when a picture stands beside them.
16
18
  text: 'flex min-w-0 flex-1 flex-col gap-6',
17
19
  // The buttons with the small print under them.
18
20
  cta: 'flex flex-col gap-2 md:shrink-0',
19
- note: 'text-sm opacity-75',
21
+ note: `text-sm ${muted}`,
20
22
  media: 'w-full max-w-sm shrink-0 overflow-hidden rounded-[var(--radius-image,var(--radius-card))] md:w-2/5',
21
23
  heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
22
- body: 'max-w-2xl text-pretty opacity-85',
24
+ body: `max-w-2xl text-pretty ${muted}`,
23
25
  actions: 'flex flex-wrap gap-3',
24
26
  },
25
27
  variants: {
@@ -41,7 +43,7 @@ const cta = tv({
41
43
  },
42
44
  // 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.
43
45
  // none: no fill of its own — the page background and text colour show through.
44
- tone: { default: {}, muted: {}, inverse: {}, accent: { body: 'opacity-100', eyebrow: 'opacity-100', note: 'opacity-100' }, brand: { body: 'opacity-100', eyebrow: 'opacity-100', note: 'opacity-100' }, none: {} },
46
+ tone: { default: {}, muted: {}, inverse: {}, accent: {}, brand: {}, none: {} },
45
47
  // The buttons' own alignment: `start` keeps them at the start edge under centred text (a WordPress buttons block
46
48
  // with no justification sits left in a group whose heading and paragraph are centred). The row is the text's
47
49
  // column, as WordPress's content width is, not the whole section. Only centred needs it: inline and stacked
@@ -52,9 +54,9 @@ const cta = tv({
52
54
  // A card carries the tone itself, on a plain section; a plain card still needs a surface to read as one.
53
55
  { frame: 'card', tone: 'default', class: { box: 'bg-surface-muted text-ink' } },
54
56
  { frame: 'card', tone: 'muted', class: { box: 'border-transparent bg-surface-muted text-ink' } },
55
- { frame: 'card', tone: 'inverse', class: { box: 'border-transparent bg-ink text-surface' } },
56
- { frame: 'card', tone: 'accent', class: { box: 'border-transparent bg-accent text-accent-ink' } },
57
- { frame: 'card', tone: 'brand', class: { box: 'border-transparent bg-brand text-brand-ink' } },
57
+ { frame: 'card', tone: 'inverse', class: { box: `border-transparent bg-ink text-surface ${mutedInverse}` } },
58
+ { frame: 'card', tone: 'accent', class: { box: `border-transparent bg-accent text-accent-ink ${mutedAccent}` } },
59
+ { frame: 'card', tone: 'brand', class: { box: `border-transparent bg-brand text-brand-ink ${mutedBrand}` } },
58
60
  { layout: 'centered', actionsAlign: 'start', class: { actions: 'w-full max-w-2xl justify-start' } },
59
61
  { layout: 'centered', class: { cta: 'items-center' } },
60
62
  ],
@@ -87,19 +89,21 @@ interface Props {
87
89
  id?: string | undefined
88
90
  }
89
91
 
90
- const { heading, eyebrow, body, note, image, actions, layout, frame, size, align, mediaSide, tone = 'accent', actionsAlign, bgImage, bgOverlay, measured: given, id } = Astro.props
92
+ const { heading, eyebrow, body, note, image, actions, layout, frame, size, align, mediaSide, tone = 'accent', actionsAlign, bgImage, bgOverlay, measured: given, id: givenId } = Astro.props
93
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `cta`, `cta-2`, … in render order.
94
+ const id = instanceId(Astro.locals, givenId, 'cta')
91
95
  const s = cta({ layout, frame, size, align, mediaSide, tone, actionsAlign })
92
96
  // A background picture is the section's measured background: Section draws the image and its overlay, and the text
93
97
  // turns light so it reads on either.
94
98
  const measured: MeasuredStyle | undefined = bgImage
95
99
  ? { ...given, tone: 'dark', bg: { ...given?.bg, image: bgImage, overlay: bgOverlay ?? given?.bg?.overlay ?? 'rgba(0, 0, 0, 0.55)' } }
96
100
  : given
97
- const headingId = id ? `${id}-title` : undefined
101
+ const headingId = `${id}-title`
98
102
  // On the accent background the primary button (accent) would vanish: it is drawn as the secondary style there.
99
103
  const onAccent = (action: ActionInput): ActionInput => ({ label: action.label, href: action.href, newTab: action.newTab, style: 'secondary' })
100
104
  const shown = actions.map(action => (tone === 'accent' && !bgImage && (action.style ?? 'primary') === 'primary' ? onAccent(action) : action))
101
105
  ---
102
- <Section measured={measured} tone={frame === 'card' ? 'default' : tone} spacing={size === 'lg' ? 'lg' : 'md'} id={id} aria-labelledby={headingId}>
106
+ <Section kit="cta" measured={measured} tone={frame === 'card' ? 'default' : tone} spacing={size === 'lg' ? 'lg' : 'md'} id={id} aria-labelledby={headingId}>
103
107
  <div class:list={[s.box(), image && 'md:flex-row md:items-center md:justify-between md:gap-12']}>
104
108
  <div class={image ? s.text() : 'contents'}>
105
109
  <div class={s.copy()}>
@@ -4,7 +4,7 @@
4
4
  "description": "A call to action: a short heading, an optional line of text and one or two buttons — across the whole section, or as a card inside it.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Button.astro", "KitImage.astro", "Section.astro", "measured.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["Button.astro", "KitImage.astro", "Section.astro", "instance-id.ts", "measured.ts", "muted.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["inline", "centered", "stacked"], "default": "inline", "description": "inline: text left, buttons right on wide screens. centered: everything centred. stacked: heading, text and buttons in one column at the start edge on every width." },
10
10
  "frame": { "options": ["full", "card"], "default": "full", "description": "full: the tone fills the section. card: a rounded box in a plain section carries the tone." },
@@ -24,7 +24,7 @@
24
24
  "bgOverlay": { "type": "string", "content": false, "description": "The colour laid over the background image (a hex or rgb colour); default black at 55 %." },
25
25
  "actions": { "type": "array", "label": "Buttons", "required": true, "items": { "type": "object", "fields": { "label": { "type": "string", "label": "Label", "required": true, "description": "The button text." }, "href": { "type": "url", "label": "Link", "required": true, "description": "Where the button goes: a page on this site or a full address." }, "newTab": { "type": "boolean", "label": "Open in a new tab", "description": "Open the link in a new tab (an external site the reader should not leave for)." }, "style": { "type": "select", "label": "Style", "options": ["primary", "secondary", "ghost"], "description": "primary for the main action, secondary or ghost for the ones beside it." } } }, "description": "Buttons, in order; usually one." },
26
26
  "measured": { "type": "object", "content": false, "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.", "fields": { "containerPx": { "type": "integer", "min": 0, "max": 2560, "description": "The frame's content width." }, "padY": { "type": "integer", "min": 0, "max": 400, "description": "Top and bottom padding." }, "padX": { "type": "integer", "min": 0, "max": 200, "description": "The frame's side padding (gutter)." }, "bg": { "type": "object", "description": "The section's background.", "fields": { "color": { "type": "string", "description": "Fill colour: hex, rgb(a) or hsl(a)." }, "image": { "type": "string", "description": "Background image address (http(s) or root-relative), drawn behind the content." }, "overlay": { "type": "string", "description": "Colour or linear/radial gradient laid over the image or the fill." }, "size": { "type": "string", "description": "cover, contain or auto; anything else draws as cover." }, "position": { "type": "string", "description": "One or two of center/top/bottom/left/right or percentages." } } }, "columns": { "type": "object", "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.", "fields": { "390": { "type": "integer", "min": 1, "max": 6, "description": "Phone." }, "768": { "type": "integer", "min": 1, "max": 6, "description": "Tablet." }, "1280": { "type": "integer", "min": 1, "max": 6, "description": "Desktop." } } }, "gap": { "type": "integer", "min": 0, "max": 200, "description": "Gap between items." }, "tone": { "type": "select", "options": ["light", "dark"], "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides." }, "align": { "type": "select", "options": ["start", "center", "end"], "description": "Where the heading and lead sit; read by a component whose own align is not set." }, "radius": { "type": "integer", "min": 0, "max": 200, "description": "Card and item corner radius." }, "minHeight": { "type": "integer", "min": 0, "max": 2000, "description": "The section's minimum height." } } },
27
- "id": { "type": "string", "content": false, "description": "Anchor id of the section; the heading gets `<id>-title`." }
27
+ "id": { "type": "string", "content": false, "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `cta`, `cta-2`, … in render order, so every section on a page has its own." }
28
28
  },
29
29
  "slots": {},
30
30
  "js": "none",
@@ -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
  ],