@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
@@ -14,6 +14,8 @@ import { safeSrc } from '../_shared/safe-url'
14
14
  import { onVideoHost, videoTarget, type EmbedTarget } from '../_shared/embed-target'
15
15
  import { measuredSlides } from '../_shared/measured'
16
16
  import { type ActionInput, type ImageInput, type MeasuredStyle } from '../_shared/types'
17
+ import { instanceId } from '../_shared/instance-id'
18
+ import { muted, mutedReset } from '../_shared/muted'
17
19
 
18
20
  const slider = tv({
19
21
  slots: {
@@ -23,7 +25,7 @@ const slider = tv({
23
25
  image: 'w-full object-cover',
24
26
  caption: 'flex flex-col items-start gap-2',
25
27
  // A slide with no image: its text as a card the height of the row, never laid over an image that is not there.
26
- card: 'flex h-full flex-col items-start gap-2 rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60',
28
+ card: `flex h-full flex-col items-start gap-2 rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60 ${mutedReset}`,
27
29
  },
28
30
  variants: {
29
31
  perView: {
@@ -39,7 +41,7 @@ const slider = tv({
39
41
  caption: {
40
42
  below: { image: 'rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4' },
41
43
  // Over the image on wide screens; under it on phones, where the image is too short to hold text.
42
- overlay: { image: 'rounded-[var(--radius-image,var(--radius-card))] md:rounded-none', slide: 'md:rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4 md:absolute md:inset-x-0 md:bottom-0 md:bg-gradient-to-t md:from-ink/80 md:to-transparent md:p-6 md:pt-16 md:text-surface' },
44
+ overlay: { image: 'rounded-[var(--radius-image,var(--radius-card))] md:rounded-none', slide: 'md:rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4 md:absolute md:inset-x-0 md:bottom-0 md:bg-gradient-to-t md:from-ink/80 md:to-transparent md:p-6 md:pt-16 md:text-surface md:[--kit-muted:var(--color-ink-muted-inverse,var(--color-surface))]' },
43
45
  },
44
46
  },
45
47
  defaultVariants: { perView: '1', aspect: 'wide', caption: 'overlay' },
@@ -99,7 +101,9 @@ interface Props {
99
101
  framed?: boolean | undefined
100
102
  }
101
103
 
102
- const { slides: given, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', playLabel = 'Play', loop = false, perView = '1', aspect, caption, heading, intro, eyebrow, actions, measured, id = 'slider', framed = true } = Astro.props
104
+ const { slides: given, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', playLabel = 'Play', loop = false, perView = '1', aspect, caption, heading, intro, eyebrow, actions, measured, id: givenId, framed = true } = Astro.props
105
+ // Two on one page never share an id: their labels and controls would point into each other.
106
+ const id = instanceId(Astro.locals, givenId, 'slider')
103
107
  // A slide is drawn when it has something to show: an image, a video (a file or another site's), or a text card (a title, a text or a link) where
104
108
  // the source carousel moved quotes or testimonials. A slide with none of them is dropped.
105
109
  const slides = given.flatMap((slide) => {
@@ -120,6 +124,8 @@ const below = slider({ perView: measuredRow ? '1' : perView, aspect, caption: 'b
120
124
  const trackId = `${id}-track`
121
125
  const sizes = measuredRow ? '(min-width: 1216px) 1152px, 100vw' : perView === '1' ? '(min-width: 1216px) 1152px, 100vw' : perView === '2' ? '(min-width: 640px) 50vw, 85vw' : '(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 85vw'
122
126
  // Arrows from sm up; on phones the carousel is swiped and the dots show the position.
127
+ // Framed, the carousel is its own section: the same hooks as every other (`id`, `data-kit-section`), its padding its own,
128
+ // so a theme's section spacing leaves it as it is.
123
129
  const Root = framed ? 'section' : 'div'
124
130
  const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-center justify-center rounded-full bg-surface/90 text-ink shadow-md ring-1 ring-line hover:bg-surface disabled:opacity-40 sm:group-data-[ready]:flex'
125
131
  // A text card has no picture for the arrows to sit on: over it they would cover its words. A carousel with any text
@@ -129,7 +135,7 @@ const cards = slides.some(slide => !slide.image && !slide.video && !slide.player
129
135
  // ratio, so the player that replaces the link moves nothing.
130
136
  const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg-surface text-ink ring-1 ring-line hover:bg-surface disabled:opacity-40'
131
137
  ---
132
- <Root class:list={['group relative', framed && 'container-page py-8']} role={framed ? undefined : 'region'} aria-roledescription="carousel" aria-label={label} data-kit-slider data-loop={loop ? '' : undefined} data-slide-label={slideLabel}>
138
+ <Root id={framed ? id : undefined} class:list={['group relative', framed && 'container-page py-8']} data-kit-section={framed ? 'slider' : undefined} data-kit-spacing={framed ? 'none' : undefined} role={framed ? undefined : 'region'} aria-roledescription="carousel" aria-label={label} data-kit-slider data-loop={loop ? '' : undefined} data-slide-label={slideLabel}>
133
139
  <SectionHeader heading={heading} intro={intro} eyebrow={eyebrow} actions={actions} />
134
140
  <div class={slots.viewport()} data-viewport tabindex="0" role="group" aria-label={label}>
135
141
  <div class:list={['flex gap-4 [&>*]:shrink-0 group-data-[ready]:[&>*]:snap-align-none', measuredRow?.track]} id={trackId} data-container>
@@ -152,7 +158,7 @@ const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg
152
158
  {(slide.title || slide.text || slide.action) && (
153
159
  <div class={slide.video || slide.player ? below.caption() : slide.image ? slots.caption() : slots.card()}>
154
160
  {slide.title && <p class="text-xl [font-weight:var(--font-weight-heading,600)] text-balance">{slide.title}</p>}
155
- {slide.text && <p class="opacity-85">{slide.text}</p>}
161
+ {slide.text && <p class={muted}>{slide.text}</p>}
156
162
  {slide.action && <Button action={slide.action} size="sm" class="mt-2" />}
157
163
  </div>
158
164
  )}
@@ -4,7 +4,7 @@
4
4
  "description": "A carousel of images, videos (a file, or a YouTube/Vimeo link loaded only on click) or text cards, with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "embed-frame.ts", "embed-target.ts", "image.ts", "measured.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "embed-frame.ts", "embed-target.ts", "image.ts", "instance-id.ts", "measured.ts", "muted.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "perView": { "options": ["1", "2", "3"], "default": "1", "description": "Slides visible at once on wide screens." },
10
10
  "aspect": { "options": ["wide", "standard", "square"], "default": "wide", "description": "Image aspect ratio: 21:9, 3:2 or 1:1." },
@@ -5,11 +5,12 @@
5
5
  import { tv } from 'tailwind-variants'
6
6
  import { addressLabel, NETWORK_MARKS, networkOf } from '../_shared/social'
7
7
  import { safeHref } from '../_shared/safe-url'
8
+ import { muted } from '../_shared/muted'
8
9
 
9
10
  const social = tv({
10
11
  slots: {
11
12
  list: 'flex flex-wrap items-center',
12
- link: 'inline-flex items-center justify-center gap-2 text-current no-underline opacity-85 hover:opacity-100 focus-visible:opacity-100',
13
+ link: `inline-flex items-center justify-center gap-2 no-underline ${muted} hover:text-current focus-visible:text-current`,
13
14
  mark: 'shrink-0',
14
15
  },
15
16
  variants: {
@@ -5,8 +5,9 @@
5
5
  "category": "chrome",
6
6
  "uses": [],
7
7
  "shared": [
8
- "social.ts",
9
- "safe-url.ts"
8
+ "muted.ts",
9
+ "safe-url.ts",
10
+ "social.ts"
10
11
  ],
11
12
  "variants": {
12
13
  "display": {
@@ -9,13 +9,15 @@ import Embed from '../embed/Embed.astro'
9
9
  import FeatureList from '../feature-list/FeatureList.astro'
10
10
  import KitImage from '../_shared/KitImage.astro'
11
11
  import Section from '../_shared/Section.astro'
12
+ import { instanceId } from '../_shared/instance-id'
12
13
  import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
14
+ import { muted } from '../_shared/muted'
13
15
 
14
16
  const split = tv({
15
17
  slots: {
16
18
  grid: 'grid items-center gap-10 md:gap-16',
17
19
  copy: 'flex flex-col gap-5',
18
- eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
20
+ eyebrow: `text-sm font-semibold tracking-wide uppercase ${muted}`,
19
21
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
20
22
  body: 'prose prose-site max-w-none text-pretty',
21
23
  actions: 'flex flex-wrap gap-3 pt-2',
@@ -42,7 +44,7 @@ const split = tv({
42
44
  sticky: { true: { media: 'md:sticky md:top-8 md:self-start' }, false: {} },
43
45
  align: { start: {}, center: { copy: 'items-center text-center', actions: 'justify-center' } },
44
46
  // 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.
45
- tone: { default: {}, muted: {}, inverse: {}, accent: { eyebrow: 'opacity-100' }, brand: { eyebrow: 'opacity-100' }, none: {} },
47
+ tone: { default: {}, muted: {}, inverse: {}, accent: {}, brand: {}, none: {} },
46
48
  },
47
49
  defaultVariants: { imageSide: 'end', ratio: '1:1', imageFit: 'contain', imageShape: 'rounded', imageBleed: 'none', stackOrder: 'text-first', sticky: false, align: 'start', tone: 'default' },
48
50
  })
@@ -98,16 +100,18 @@ interface Props {
98
100
  id?: string | undefined
99
101
  }
100
102
 
101
- const { heading, headingLevel = 2, eyebrow, body, actions = [], image, features = [], videoUrl, playLabel, imageSide, ratio = '1:1', imageFit, imageShape, imageBleed, mediaKind, featureStyle = 'check', stackOrder, sticky = false, align, tone, measured, id } = Astro.props
103
+ const { heading, headingLevel = 2, eyebrow, body, actions = [], image, features = [], videoUrl, playLabel, imageSide, ratio = '1:1', imageFit, imageShape, imageBleed, mediaKind, featureStyle = 'check', stackOrder, sticky = false, align, tone, measured, id: givenId } = Astro.props
104
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `split`, `split-2`, … in render order.
105
+ const id = instanceId(Astro.locals, givenId, 'split')
102
106
  const s = split({ imageSide, ratio, imageFit, imageShape, imageBleed, stackOrder, sticky, align, tone })
103
107
  const Heading = `h${headingLevel}` as 'h2'
104
108
  const columns = COLUMNS[imageSide === 'start' ? 'image' : 'text'][ratio]
105
109
  const bleed = imageBleed === 'edge' ? BLEED[imageSide === 'start' ? 'start' : 'end'] : undefined
106
110
  const video = mediaKind === 'video' && videoUrl
107
- const headingId = id && heading ? `${id}-title` : undefined
111
+ const headingId = heading ? `${id}-title` : undefined
108
112
  const html = await renderMarkdown(body)
109
113
  ---
110
- <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId} class={bleed ? 'overflow-x-clip' : undefined}>
114
+ <Section kit="split" measured={measured} tone={tone} id={id} aria-labelledby={headingId} class={bleed ? 'overflow-x-clip' : undefined}>
111
115
  <div class:list={[s.grid(), columns]}>
112
116
  <div class={s.copy()}>
113
117
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
@@ -11,7 +11,9 @@
11
11
  "Button.astro",
12
12
  "KitImage.astro",
13
13
  "Section.astro",
14
+ "instance-id.ts",
14
15
  "measured.ts",
16
+ "muted.ts",
15
17
  "safe-url.ts",
16
18
  "types.ts"
17
19
  ],
@@ -339,7 +341,7 @@
339
341
  "id": {
340
342
  "type": "string",
341
343
  "content": false,
342
- "description": "Anchor id of the section; the heading gets `<id>-title`."
344
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `split`, `split-2`, … in render order, so every section on a page has its own."
343
345
  },
344
346
  "sticky": {
345
347
  "type": "boolean",
@@ -7,9 +7,11 @@ import { tv } from 'tailwind-variants'
7
7
  import BehaviorScript from '../_shared/BehaviorScript.astro'
8
8
  import KitImage from '../_shared/KitImage.astro'
9
9
  import Section, { type Tone } from '../_shared/Section.astro'
10
+ import { instanceId } from '../_shared/instance-id'
10
11
  import { imageOf, type ImageSource } from '../_shared/image'
11
12
  import { measuredGrid } from '../_shared/measured'
12
13
  import type { MeasuredStyle } from '../_shared/types'
14
+ import { muted, mutedReset } from '../_shared/muted'
13
15
 
14
16
  const stats = tv({
15
17
  slots: {
@@ -20,7 +22,7 @@ const stats = tv({
20
22
  // height comes with valueSize too: a font-size class merged in later would drop one set here.
21
23
  value: 'text-[length:var(--stat-max,2.25rem)] [font-weight:var(--font-weight-heading,700)] tracking-tight tabular-nums whitespace-nowrap [--stat-max-md:3rem] md:text-[length:var(--stat-max-md)]',
22
24
  icon: 'mb-3 block size-10 object-contain',
23
- label: 'text-sm opacity-80',
25
+ label: `text-sm ${muted}`,
24
26
  },
25
27
  variants: {
26
28
  // grid: figures in columns. inline: one line that wraps, for two to four short figures.
@@ -43,7 +45,7 @@ const stats = tv({
43
45
  },
44
46
  style: {
45
47
  plain: {},
46
- card: { item: 'rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60' },
48
+ card: { item: `rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60 ${mutedReset}` },
47
49
  // Hairlines between the figures, none before the first, no box: across on a wide screen, above each on a phone.
48
50
  // Meant for a single row (figures no more than the columns); a wrapped row starts with a line.
49
51
  divided: { item: 'border-t border-line pt-6 first:border-t-0 first:pt-0 md:border-t-0 md:pt-0 md:border-s md:ps-6 md:first:border-s-0 md:first:ps-0' },
@@ -57,7 +59,7 @@ const stats = tv({
57
59
  display: { value: '[--stat-max:3.75rem] [--stat-max-md:4.5rem] leading-none' },
58
60
  },
59
61
  // accent/accent-ink is only ~5:1 — dimming text on it drops it under 4.5:1 (WCAG 1.4.3).
60
- tone: { default: {}, muted: {}, inverse: {}, accent: { label: 'opacity-100' } },
62
+ tone: { default: {}, muted: {}, inverse: {}, accent: {} },
61
63
  // The label under each figure: small, body size, or large (a counter title set like a subheading).
62
64
  labelSize: { sm: {}, base: { label: 'text-base' }, lg: { label: 'text-lg' } },
63
65
  // Which comes first in the page's text: the source's own order (a Gutenberg figure is a heading over its label, an
@@ -95,23 +97,25 @@ interface Props {
95
97
  id?: string | undefined
96
98
  }
97
99
 
98
- const { heading, intro, items, layout, columns, style, align, valueSize, labelSize, order, motion, tone, measured, id } = Astro.props
100
+ const { heading, intro, items, layout, columns, style, align, valueSize, labelSize, order, motion, tone, measured, id: givenId } = Astro.props
101
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `stats`, `stats-2`, … in render order.
102
+ const id = instanceId(Astro.locals, givenId, 'stats')
99
103
  const count = motion === 'count'
100
104
  // A count-up island (behaviors.ts `countup`): the root names the kind, each figure's digits are their own span.
101
105
  const island = count ? { 'data-cr-behavior': 'countup', 'data-cr-items': '[data-kit-count]' } : {}
102
106
  const s = stats({ layout, columns, style, align, valueSize, labelSize, order, tone })
103
107
  const grid = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
104
- const headingId = heading && id ? `${id}-title` : undefined
108
+ const headingId = heading ? `${id}-title` : undefined
105
109
  const iconOf = (item: Item) => imageOf(item.icon, '')
106
110
  // The figure's width in characters, for the size it may take in a narrow cell.
107
111
  const widthOf = (item: Item) => `--stat-chars:${(item.prefix ?? '').length + item.value.length + (item.suffix ?? '').length}`
108
112
  // A <dl> names its term first, so figure-first is a plain list, read in the order it is drawn (WCAG 1.3.2).
109
113
  ---
110
- <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
114
+ <Section kit="stats" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
111
115
  {(heading || intro) && (
112
116
  <header class="mb-10 flex max-w-2xl flex-col gap-3">
113
117
  {heading && <h2 id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{heading}</h2>}
114
- {intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']} data-kit-text>{intro}</p>}
118
+ {intro && <p class:list={['text-lg', muted]} data-kit-text>{intro}</p>}
115
119
  </header>
116
120
  )}
117
121
  {order === 'value-first' ? (
@@ -12,7 +12,9 @@
12
12
  "embed-frame.ts",
13
13
  "embed-target.ts",
14
14
  "image.ts",
15
+ "instance-id.ts",
15
16
  "measured.ts",
17
+ "muted.ts",
16
18
  "safe-url.ts",
17
19
  "types.ts"
18
20
  ],
@@ -261,7 +263,7 @@
261
263
  "id": {
262
264
  "type": "string",
263
265
  "content": false,
264
- "description": "Anchor id of the section; the heading gets `<id>-title`."
266
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `stats`, `stats-2`, … in render order, so every section on a page has its own."
265
267
  }
266
268
  },
267
269
  "slots": {},
@@ -6,15 +6,17 @@
6
6
  import { tv } from 'tailwind-variants'
7
7
  import KitImage from '../_shared/KitImage.astro'
8
8
  import Section from '../_shared/Section.astro'
9
+ import { instanceId } from '../_shared/instance-id'
9
10
  import { measuredGrid } from '../_shared/measured'
10
11
  import type { MeasuredStyle } from '../_shared/types'
11
12
  import { imageOf, type ImageSource } from '../_shared/image'
13
+ import { muted } from '../_shared/muted'
12
14
 
13
15
  const steps = tv({
14
16
  slots: {
15
17
  header: 'mb-10 flex max-w-2xl flex-col gap-3',
16
18
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
17
- intro: 'text-lg opacity-80',
19
+ intro: `text-lg ${muted}`,
18
20
  list: 'grid gap-8 [counter-reset:kit-step]',
19
21
  step: 'flex gap-4 [counter-increment:kit-step]',
20
22
  date: 'text-sm font-semibold tabular-nums text-accent-text',
@@ -22,7 +24,7 @@ const steps = tv({
22
24
  badge: 'shrink-0',
23
25
  media: 'mb-2 overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
24
26
  title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
25
- text: 'text-pretty opacity-80',
27
+ text: `text-pretty ${muted}`,
26
28
  },
27
29
  variants: {
28
30
  layout: {
@@ -79,18 +81,20 @@ interface Props {
79
81
  id?: string | undefined
80
82
  }
81
83
 
82
- const { heading, intro, items, kind = 'steps', layout: givenLayout, marker, connector, currentLabel = 'Current', style, measured, id } = Astro.props
84
+ const { heading, intro, items, kind = 'steps', layout: givenLayout, marker, connector, currentLabel = 'Current', style, measured, id: givenId } = Astro.props
85
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `steps`, `steps-2`, … in render order.
86
+ const id = instanceId(Astro.locals, givenId, 'steps')
83
87
  // A timeline defaults to the rail layout and, without numbers, to the plain dot marker.
84
88
  const layout = givenLayout ?? (kind === 'timeline' ? 'rail' : 'list')
85
89
  const s = steps({ layout, marker: marker ?? (kind === 'timeline' ? 'dot' : 'number'), connector, style })
86
90
  // A bare year or an ISO date is machine-readable; anything else ("Spring 2021") stays text.
87
91
  const isoDate = (date: string) => /^\d{4}(-\d{2}(-\d{2})?)?$/.test(date.trim()) ? date.trim() : undefined
88
- const headingId = heading && id ? `${id}-title` : undefined
92
+ const headingId = heading ? `${id}-title` : undefined
89
93
  // One level under the section heading, or the section's heading when there is none.
90
94
  const StepHeading = heading ? 'h3' : 'h2'
91
95
  ---
92
96
  {items.length > 0 && (
93
- <Section measured={measured} id={id} aria-labelledby={headingId}>
97
+ <Section kit="steps" measured={measured} id={id} aria-labelledby={headingId}>
94
98
  {(heading || intro) && (
95
99
  <header class={s.header()}>
96
100
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
@@ -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
  ],
@@ -119,7 +121,7 @@
119
121
  "id": {
120
122
  "type": "string",
121
123
  "content": false,
122
- "description": "Anchor id of the section; the heading gets `<id>-title`."
124
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `steps`, `steps-2`, … in render order, so every section on a page has its own."
123
125
  },
124
126
  "currentLabel": {
125
127
  "type": "string",
@@ -7,6 +7,8 @@ import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
8
  import SectionHeader from '../_shared/SectionHeader.astro'
9
9
  import type { ActionInput, MeasuredStyle } from '../_shared/types'
10
+ import { instanceId } from '../_shared/instance-id'
11
+ import { muted } from '../_shared/muted'
10
12
 
11
13
  export interface TabItem {
12
14
  label: string
@@ -19,7 +21,7 @@ const tabs = tv({
19
21
  heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
20
22
  root: 'group/tabs flex flex-col gap-6',
21
23
  list: 'hidden gap-1 group-data-[ready]/tabs:flex',
22
- trigger: 'cursor-pointer rounded-md px-4 py-2 text-sm font-medium whitespace-nowrap opacity-75 transition hover:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent data-[selected]:opacity-100',
24
+ trigger: `cursor-pointer rounded-md px-4 py-2 text-sm font-medium whitespace-nowrap ${muted} transition hover:text-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent data-[selected]:text-current`,
23
25
  panel: 'prose-site prose max-w-none text-current',
24
26
  title: 'mb-3 text-lg [font-weight:var(--font-weight-heading,600)] group-data-[ready]/tabs:sr-only',
25
27
  },
@@ -32,7 +34,7 @@ const tabs = tv({
32
34
  horizontal: { list: 'overflow-x-auto' },
33
35
  vertical: { root: 'md:data-[ready]:flex-row md:data-[ready]:items-start md:data-[ready]:gap-10', list: 'md:w-56 md:shrink-0 md:flex-col', trigger: 'text-start' },
34
36
  },
35
- tone: { default: {}, muted: {}, inverse: { panel: 'prose-invert' }, accent: { panel: 'prose-invert', trigger: 'opacity-100' } },
37
+ tone: { default: {}, muted: {}, inverse: { panel: 'prose-invert' }, accent: { panel: 'prose-invert' } },
36
38
  },
37
39
  compoundVariants: [
38
40
  { style: 'underline', orientation: 'vertical', class: { list: 'md:border-b-0 md:border-s', trigger: 'md:mb-0 md:-ms-px md:border-b-0 md:border-s-2' } },
@@ -57,7 +59,9 @@ interface Props {
57
59
  measured?: MeasuredStyle | undefined
58
60
  }
59
61
 
60
- const { items, heading, intro, eyebrow, actions, label, style, orientation = 'horizontal', tone, id = 'tabs', measured } = Astro.props
62
+ const { items, heading, intro, eyebrow, actions, label, style, orientation = 'horizontal', tone, id: givenId, measured } = Astro.props
63
+ // Two on one page never share an id: their labels and controls would point into each other.
64
+ const id = instanceId(Astro.locals, givenId, 'tabs')
61
65
  const s = tabs({ style, orientation, tone })
62
66
  const headingId = heading ? `${id}-title` : undefined
63
67
  // One level under the section heading, or the section's heading when there is none.
@@ -65,7 +69,7 @@ const PanelHeading = heading ? 'h3' : 'h2'
65
69
  const value = (i: number) => `tab-${i + 1}`
66
70
  ---
67
71
  {items.length > 0 && (
68
- <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
72
+ <Section kit="tabs" measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
69
73
  {intro || eyebrow || actions?.length
70
74
  ? <SectionHeader heading={heading} intro={intro} eyebrow={eyebrow} actions={actions} headingId={headingId} />
71
75
  : heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
@@ -4,7 +4,7 @@
4
4
  "description": "Tabbed panels with the WAI-ARIA tabs pattern from Zag.js (arrow keys, Home/End, roving focus). Without script every panel shows in turn under its own title, so no content is ever hidden from a reader or a crawler.",
5
5
  "category": "content",
6
6
  "uses": [],
7
- "shared": ["Button.astro", "Section.astro", "SectionHeader.astro", "measured.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["Button.astro", "Section.astro", "SectionHeader.astro", "instance-id.ts", "measured.ts", "muted.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "style": { "options": ["underline", "pills"], "default": "underline", "description": "underline: a rule under the list, the selected tab underlined in accent. pills: the selected tab filled with accent." },
10
10
  "orientation": { "options": ["horizontal", "vertical"], "default": "horizontal", "description": "vertical: the tab list beside the panel on wide screens (Elementor vertical tabs)." },
@@ -4,26 +4,28 @@
4
4
  import { tv } from 'tailwind-variants'
5
5
  import KitImage from '../_shared/KitImage.astro'
6
6
  import Section 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 { profile } from './team'
11
12
  import { safeHref } from '../_shared/safe-url'
13
+ import { muted } from '../_shared/muted'
12
14
 
13
15
  const team = tv({
14
16
  slots: {
15
17
  header: 'mb-10 flex max-w-2xl flex-col gap-3',
16
18
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
17
- intro: 'text-lg opacity-80',
19
+ intro: `text-lg ${muted}`,
18
20
  list: 'grid gap-6 sm:grid-cols-2 md:gap-8',
19
21
  socials: 'relative z-10 mt-1 flex flex-wrap gap-3 text-sm',
20
22
  social: 'rounded text-accent-text underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
21
- more: 'text-pretty opacity-80 [&>summary]:text-sm [&>summary]:font-medium [&>summary]:text-accent-text',
23
+ more: `text-pretty ${muted} [&>summary]:text-sm [&>summary]:font-medium [&>summary]:text-accent-text`,
22
24
  member: 'group relative flex flex-col gap-3',
23
25
  portrait: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
24
26
  name: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)]',
25
27
  role: 'text-sm font-medium text-accent-text',
26
- bio: 'text-pretty opacity-80',
28
+ bio: `text-pretty ${muted}`,
27
29
  },
28
30
  variants: {
29
31
  columns: {
@@ -82,17 +84,19 @@ interface Props {
82
84
  id?: string | undefined
83
85
  }
84
86
 
85
- const { heading, intro, items, columns, layout, imageShape, align, bioClamp, readMoreLabel = 'Read more', socialLabel = '{name} on {network}', style, roleStyle, measured, id } = Astro.props
87
+ const { heading, intro, items, columns, layout, imageShape, align, bioClamp, readMoreLabel = 'Read more', socialLabel = '{name} on {network}', style, roleStyle, measured, id: givenId } = Astro.props
88
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `team`, `team-2`, … in render order.
89
+ const id = instanceId(Astro.locals, givenId, 'team')
86
90
  const s = team({ columns, layout, imageShape, align, style, roleStyle })
87
91
  const clamp = { 2: 'line-clamp-2', 3: 'line-clamp-3', 4: 'line-clamp-4' } as const
88
92
  // A bio that fits the clamp needs no disclosure: short means about 160 characters per two lines, a rough, build-time guess.
89
93
  const longBio = (bio: string) => bioClamp !== undefined && bio.length > bioClamp * 80
90
- const headingId = heading && id ? `${id}-title` : undefined
94
+ const headingId = heading ? `${id}-title` : undefined
91
95
  // One level under the section heading, or the section's heading when there is none.
92
96
  const NameHeading = heading ? 'h3' : 'h2'
93
97
  ---
94
98
  {items.length > 0 && (
95
- <Section measured={measured} id={id} aria-labelledby={headingId}>
99
+ <Section kit="team" measured={measured} id={id} aria-labelledby={headingId}>
96
100
  {(heading || intro) && (
97
101
  <header class={s.header()}>
98
102
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
@@ -8,9 +8,11 @@
8
8
  "KitImage.astro",
9
9
  "Section.astro",
10
10
  "image.ts",
11
+ "instance-id.ts",
11
12
  "measured.ts",
12
- "social.ts",
13
+ "muted.ts",
13
14
  "safe-url.ts",
15
+ "social.ts",
14
16
  "types.ts"
15
17
  ],
16
18
  "variants": {
@@ -131,7 +133,7 @@
131
133
  "id": {
132
134
  "type": "string",
133
135
  "content": false,
134
- "description": "Anchor id of the section; the heading gets `<id>-title`."
136
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `team`, `team-2`, … in render order, so every section on a page has its own."
135
137
  },
136
138
  "bioClamp": {
137
139
  "type": "integer",
@@ -5,9 +5,11 @@
5
5
  import { tv } from 'tailwind-variants'
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 { imageOf, type ImageSource } from '../_shared/image'
9
10
  import { measuredGrid } from '../_shared/measured'
10
11
  import type { MeasuredStyle } from '../_shared/types'
12
+ import { muted } from '../_shared/muted'
11
13
 
12
14
  export interface TestimonialItem {
13
15
  quote: string
@@ -35,7 +37,7 @@ const testimonial = tv({
35
37
  avatar: 'size-12 shrink-0 rounded-full object-cover',
36
38
  logo: 'ms-auto h-6 w-auto max-w-24 object-contain opacity-80',
37
39
  name: 'font-semibold',
38
- role: 'text-sm opacity-75',
40
+ role: `text-sm ${muted}`,
39
41
  },
40
42
  variants: {
41
43
  layout: {
@@ -51,7 +53,7 @@ const testimonial = tv({
51
53
  avatarPlace: { start: {}, top: {} },
52
54
  // marks: the quotation marks around the text, as the source writes it. glyph: one large decorative mark above.
53
55
  quoteMark: { marks: {}, glyph: {}, none: {} },
54
- tone: { default: {}, muted: { card: 'bg-surface' }, inverse: { card: 'border-surface/20' }, accent: { card: 'border-accent-ink/30', role: 'opacity-100', logo: 'opacity-100' } },
56
+ tone: { default: {}, muted: { card: 'bg-surface' }, inverse: { card: 'border-surface/20' }, accent: { card: 'border-accent-ink/30', logo: 'opacity-100' } },
55
57
  // The quote's size; `auto` is the layout's own (body size in a grid, large for a single quote).
56
58
  quoteSize: {
57
59
  auto: {},
@@ -89,9 +91,11 @@ interface Props {
89
91
  id?: string | undefined
90
92
  }
91
93
 
92
- const { items, heading, layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark = 'marks', ratingLabel = '{rating} out of 5 stars', label = 'Testimonials', tone, measured, id } = Astro.props
94
+ const { items, heading, layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark = 'marks', ratingLabel = '{rating} out of 5 stars', label = 'Testimonials', tone, measured, id: givenId } = Astro.props
95
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `testimonial`, `testimonial-2`, … in render order.
96
+ const id = instanceId(Astro.locals, givenId, 'testimonial')
93
97
  const s = testimonial({ layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark, tone })
94
- const headingId = heading && id ? `${id}-title` : undefined
98
+ const headingId = heading ? `${id}-title` : undefined
95
99
  // Two quotes share the row: a third column would stand empty beside them and narrow both (IP-4 elementor launch G9).
96
100
  const measuredList = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
97
101
  const list = s.list({ class: measuredList ?? (items.length === 2 && (layout ?? 'grid') === 'grid' ? 'lg:grid-cols-2' : undefined) })
@@ -105,7 +109,7 @@ const spoken = (rating: number) => String(Math.round(rating * 10) / 10)
105
109
  const scrolls = layout === 'row'
106
110
  ---
107
111
  {items.length > 0 && (
108
- <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
112
+ <Section kit="testimonial" measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
109
113
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
110
114
  <div class={list} tabindex={scrolls ? 0 : undefined} role={scrolls ? 'region' : undefined} aria-labelledby={scrolls && heading ? headingId : undefined} aria-label={scrolls && !heading ? label : undefined}>
111
115
  {items.map((item) => {
@@ -100,7 +100,7 @@
100
100
  "name": "measured-columns",
101
101
  "props": {
102
102
  "heading": "What readers say",
103
- "id": "readers",
103
+ "id": "readers-2",
104
104
  "items": [
105
105
  { "quote": "The only travel site I read to the end. Every guide feels written by someone who stayed.", "name": "Ayşe Demir", "role": "Reader since 2019", "avatar": { "src": "/kit-fixtures/avatar-1.svg", "alt": "", "width": 96, "height": 96 } },
106
106
  { "quote": "Their Lisbon guide saved our trip.", "name": "Tom Becker", "avatar": { "src": "/kit-fixtures/avatar-2.svg", "alt": "", "width": 96, "height": 96 } },
@@ -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
  ],
@@ -250,7 +252,7 @@
250
252
  "id": {
251
253
  "type": "string",
252
254
  "content": false,
253
- "description": "Anchor id of the section; the heading gets `<id>-title`."
255
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `testimonial`, `testimonial-2`, … in render order, so every section on a page has its own."
254
256
  },
255
257
  "ratingLabel": {
256
258
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "license": "MIT",
5
5
  "description": "Copy-in Astro components for Contentrain sites (shadcn model): typed props, tailwind-variants, zero JavaScript by default — with a machine-readable catalog",
6
6
  "type": "module",