@contentrain/astro-kit 0.39.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 (76) hide show
  1. package/README.md +18 -2
  2. package/catalog.json +99 -18
  3. package/components/_shared/Section.astro +13 -9
  4. package/components/_shared/SectionHeader.astro +3 -2
  5. package/components/_shared/behaviors.ts +38 -2
  6. package/components/_shared/embed-frame.ts +22 -0
  7. package/components/{embed/embed.ts → _shared/embed-target.ts} +30 -0
  8. package/components/_shared/instance-id.ts +24 -0
  9. package/components/_shared/muted.ts +19 -0
  10. package/components/band/Band.astro +10 -7
  11. package/components/band/meta.json +2 -0
  12. package/components/behavior/Behavior.astro +15 -2
  13. package/components/behavior/fixtures.json +5 -3
  14. package/components/behavior/meta.json +9 -0
  15. package/components/card-grid/CardGrid.astro +13 -9
  16. package/components/card-grid/meta.json +7 -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/embed/Embed.astro +7 -10
  26. package/components/embed/meta.json +1 -1
  27. package/components/faq/Faq.astro +8 -4
  28. package/components/faq/meta.json +1 -1
  29. package/components/feature-list/FeatureList.astro +10 -6
  30. package/components/feature-list/meta.json +3 -1
  31. package/components/footer/Footer.astro +4 -3
  32. package/components/footer/meta.json +1 -0
  33. package/components/gallery/Gallery.astro +8 -4
  34. package/components/gallery/meta.json +2 -0
  35. package/components/header/Header.astro +8 -4
  36. package/components/header/meta.json +1 -1
  37. package/components/hero/Hero.astro +13 -9
  38. package/components/hero/meta.json +3 -1
  39. package/components/language-switcher/LanguageSwitcher.astro +3 -2
  40. package/components/language-switcher/meta.json +1 -1
  41. package/components/logo-cloud/LogoCloud.astro +9 -5
  42. package/components/logo-cloud/meta.json +3 -1
  43. package/components/nav/Nav.astro +10 -6
  44. package/components/nav/meta.json +2 -0
  45. package/components/newsletter/Newsletter.astro +9 -5
  46. package/components/newsletter/meta.json +2 -0
  47. package/components/post-list/PostList.astro +7 -3
  48. package/components/post-list/meta.json +2 -0
  49. package/components/pricing/Pricing.astro +15 -11
  50. package/components/pricing/meta.json +3 -1
  51. package/components/search-box/SearchBox.astro +4 -1
  52. package/components/search-box/meta.json +1 -1
  53. package/components/slider/Slider.astro +9 -2
  54. package/components/slider/SliderTrack.astro +71 -15
  55. package/components/slider/fixtures.json +2 -1
  56. package/components/slider/meta.json +5 -4
  57. package/components/social-links/SocialLinks.astro +2 -1
  58. package/components/social-links/meta.json +3 -2
  59. package/components/split/Split.astro +9 -5
  60. package/components/split/meta.json +3 -1
  61. package/components/stats/Stats.astro +11 -7
  62. package/components/stats/meta.json +5 -1
  63. package/components/steps/Steps.astro +9 -5
  64. package/components/steps/meta.json +3 -1
  65. package/components/tabs/Tabs.astro +8 -4
  66. package/components/tabs/meta.json +1 -1
  67. package/components/team/Team.astro +10 -6
  68. package/components/team/meta.json +4 -2
  69. package/components/testimonial/Testimonial.astro +9 -5
  70. package/components/testimonial/fixtures.json +1 -1
  71. package/components/testimonial/meta.json +3 -1
  72. package/mapping/classic.json +14 -7
  73. package/mapping/divi.json +6 -3
  74. package/mapping/elementor.json +14 -7
  75. package/mapping/gutenberg.json +2 -1
  76. package/package.json +1 -1
@@ -0,0 +1,22 @@
1
+ // The player a click on an embed poster becomes: one recipe for the kit Embed, a Slider slide and a behaviour
2
+ // island's video link, so the frame's permissions are written once. The address is checked again at the click: what
3
+ // the page carries (`data-embed-src`, which an edited page could change) goes through embed-target.ts once more, and
4
+ // the frame loads the address that builds from a known provider, never the attribute's text.
5
+ import { embedTarget } from './embed-target'
6
+
7
+ /** An iframe for the player at `src`, named `title`, filling its positioned frame; none when `src` is no known player. */
8
+ export function playerFrame(src: string | undefined, title: string, className = 'absolute inset-0 h-full w-full border-0'): HTMLIFrameElement | null {
9
+ const target = src ? embedTarget(src) : null
10
+ if (!target) return null
11
+ const frame = document.createElement('iframe')
12
+ frame.src = target.src
13
+ frame.title = title
14
+ frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write'
15
+ frame.allowFullscreen = true
16
+ frame.referrerPolicy = 'strict-origin-when-cross-origin'
17
+ frame.className = className
18
+ return frame
19
+ }
20
+
21
+ /** A plain left click: a modified one (new tab, new window) keeps the link's own behaviour. */
22
+ export const plainClick = (event: MouseEvent): boolean => !event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
@@ -107,3 +107,33 @@ export function mapTarget(address: string): EmbedTarget | null {
107
107
  const query = encodeURIComponent(q)
108
108
  return { provider: 'google-maps', kind: 'map', src: `https://www.google.com/maps?q=${query}&output=embed`, href: `https://www.google.com/maps/search/?api=1&query=${query}` }
109
109
  }
110
+
111
+ const VIDEO_PAGE: Record<string, (id: string) => string> = {
112
+ youtube: id => `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`,
113
+ vimeo: id => `https://vimeo.com/${encodeURIComponent(id)}`,
114
+ }
115
+
116
+ /**
117
+ * A video to play in place: a video page or player URL, or `youtube:<id>` / `vimeo:<id>` (what the engine writes on a
118
+ * slide or an island link). Only a video comes back (never a map or a form); the id is checked by the same parser as a
119
+ * URL, so the player address is always built here, never taken from the content.
120
+ */
121
+ export function videoTarget(value: string | undefined): EmbedTarget | null {
122
+ if (!value) return null
123
+ const short = /^(youtube|vimeo):([\w-]+)$/.exec(value.trim())
124
+ const target = embedTarget(short ? VIDEO_PAGE[short[1]!]!(short[2]!) : value)
125
+ return target?.kind === 'video' ? target : null
126
+ }
127
+
128
+ /** Hosts a video provider serves its players, thumbnails and streams from. */
129
+ const VIDEO_HOST = /(?:^|\.)(?:youtube|youtube-nocookie|youtu|ytimg|googlevideo|ggpht|vimeo|vimeocdn|loom|wistia)\.(?:com|be|net)$/i
130
+
131
+ /**
132
+ * An address on a video provider's own hosts (a YouTube thumbnail on i.ytimg.com, a Vimeo one on vimeocdn.com): never
133
+ * drawn as a poster, since loading it is a request to the provider before the reader asked for the video.
134
+ */
135
+ export function onVideoHost(src: string | undefined): boolean {
136
+ if (!src) return false
137
+ try { return VIDEO_HOST.test(new URL(src, 'https://site.invalid').hostname) }
138
+ catch { return false }
139
+ }
@@ -0,0 +1,24 @@
1
+ // A component's id when the page gives none. Its tabs, lightboxes, labels and buttons point at elements by id
2
+ // (`aria-controls`, `popovertarget`, `for`), so two instances on one page that both took the same default (two galleries,
3
+ // two FAQs, two newsletters) would point into each other: a click in the second opened the first one's image. Each
4
+ // default is taken once per page instead: `gallery`, then `gallery-2`, `gallery-3`, in the order the page renders
5
+ // them, the same on every build. An id the page gives is kept as it is, and a default steps around it.
6
+
7
+ const taken = new WeakMap<object, Set<string>>()
8
+
9
+ /** `given` when set, else `base`, `base-2`, … — the first one not yet used on this page (`page` = `Astro.locals`). */
10
+ export function instanceId(page: object, given: string | undefined, base: string): string {
11
+ let ids = taken.get(page)
12
+ if (!ids) {
13
+ ids = new Set()
14
+ taken.set(page, ids)
15
+ }
16
+ if (given) {
17
+ ids.add(given)
18
+ return given
19
+ }
20
+ let id = base
21
+ for (let n = 2; ids.has(id); n++) id = `${base}-${n}`
22
+ ids.add(id)
23
+ return id
24
+ }
@@ -0,0 +1,19 @@
1
+ // Quieter text (an intro, a caption, a role, a footer link) in a colour role, never a dimmed ink: opacity lays the
2
+ // text over whatever is behind it, and on a mid-tone band that falls under 4.5:1. The role is the one made for the
3
+ // band the text sits on. A band sets `--kit-muted` (dark: `--color-ink-muted-inverse`, a fill: its own ink), a light
4
+ // surface inside it (a card, a form panel, a drawer) clears it again, and without either it is `--color-ink-muted`.
5
+
6
+ /** Quieter text: the band's muted role. */
7
+ export const muted = 'text-[color:var(--kit-muted,var(--color-ink-muted))]'
8
+
9
+ /** On a dark band: the muted role made for it, or the band's own text where the site's theme has none. */
10
+ export const mutedInverse = '[--kit-muted:var(--color-ink-muted-inverse,var(--color-surface))]'
11
+
12
+ /** On the accent fill: no muted role is made for it, so quieter text there is the fill's own ink. */
13
+ export const mutedAccent = '[--kit-muted:var(--color-accent-ink)]'
14
+
15
+ /** On the brand fill: the fill's own ink, as on the accent. */
16
+ export const mutedBrand = '[--kit-muted:var(--color-brand-ink)]'
17
+
18
+ /** A light surface inside a band (a card, a form panel, a drawer): back to the page's muted role. */
19
+ export const mutedReset = '[--kit-muted:initial]'
@@ -9,6 +9,8 @@ import Section, { type Tone } from '../_shared/Section.astro'
9
9
  import { measuredGrid } from '../_shared/measured'
10
10
  import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
11
11
  import { safeHref } from '../_shared/safe-url'
12
+ import { instanceId } from '../_shared/instance-id'
13
+ import { muted } from '../_shared/muted'
12
14
 
13
15
  const band = tv({
14
16
  slots: {
@@ -16,16 +18,16 @@ const band = tv({
16
18
  copy: 'flex max-w-3xl flex-col gap-4',
17
19
  eyebrow: 'text-sm font-semibold tracking-wide text-accent-text uppercase',
18
20
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
19
- lead: 'text-lg text-pretty opacity-85',
21
+ lead: `text-lg text-pretty ${muted}`,
20
22
  media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
21
23
  list: 'grid items-start gap-6 md:gap-8',
22
24
  item: 'relative flex flex-col gap-3 rounded-[var(--radius-card)]',
23
25
  itemMedia: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
24
26
  itemTitle: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
25
27
  itemValue: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight',
26
- itemMeta: 'text-sm opacity-75',
27
- itemText: 'opacity-80',
28
- itemPoints: 'flex list-disc flex-col gap-1 ps-5 opacity-80',
28
+ itemMeta: `text-sm ${muted}`,
29
+ itemText: muted,
30
+ itemPoints: `flex list-disc flex-col gap-1 ps-5 ${muted}`,
29
31
  actions: 'flex flex-wrap gap-3',
30
32
  },
31
33
  variants: {
@@ -42,7 +44,7 @@ const band = tv({
42
44
  center: { copy: 'mx-auto items-center text-center', actions: 'justify-center', item: 'items-center text-center' },
43
45
  },
44
46
  // Accent backgrounds have the least contrast headroom: text on them is never dimmed.
45
- tone: { default: {}, muted: {}, inverse: {}, accent: { lead: 'opacity-100', itemText: 'opacity-100', itemMeta: 'opacity-100', itemPoints: 'opacity-100', eyebrow: 'text-current' } },
47
+ tone: { default: {}, muted: {}, inverse: {}, accent: { eyebrow: 'text-current' } },
46
48
  },
47
49
  defaultVariants: { layout: 'stack', columns: '3', align: 'start', tone: 'default' },
48
50
  })
@@ -89,10 +91,11 @@ const aligned = align ?? (measured?.align === 'center' ? 'center' : undefined)
89
91
  const s = band({ layout: media ? layout : 'stack', columns, align: aligned, tone })
90
92
  const Heading = `h${headingLevel}` as 'h2' | 'h3'
91
93
  const ItemHeading = `h${heading ? headingLevel + 1 : headingLevel}` as 'h2' | 'h3' | 'h4'
92
- const headingId = heading ? (id ? `${id}-title` : `band-${heading.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`) : undefined
94
+ // Two bands with the same heading on one page get two ids (`band-<heading>`, then `band-<heading>-2`).
95
+ const headingId = heading ? (id ? `${id}-title` : instanceId(Astro.locals, undefined, `band-${heading.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`)) : undefined
93
96
  const hasCopy = Boolean(heading || eyebrow || lead || actions.length)
94
97
  ---
95
- <Section tone={tone} id={id} aria-labelledby={headingId} measured={measured}>
98
+ <Section kit="band" tone={tone} id={id} aria-labelledby={headingId} measured={measured}>
96
99
  <div class="flex flex-col gap-10">
97
100
  {(hasCopy || media) && (
98
101
  <div class={s.grid()}>
@@ -8,7 +8,9 @@
8
8
  "Button.astro",
9
9
  "KitImage.astro",
10
10
  "Section.astro",
11
+ "instance-id.ts",
11
12
  "measured.ts",
13
+ "muted.ts",
12
14
  "safe-url.ts",
13
15
  "types.ts"
14
16
  ],
@@ -9,6 +9,7 @@
9
9
  // nothing hidden. Only a page that has an island loads the module (Astro bundles a component's script once, for the
10
10
  // pages that use it).
11
11
  import Section from '../_shared/Section.astro'
12
+ import { instanceId } from '../_shared/instance-id'
12
13
  import type { MeasuredStyle } from '../_shared/types'
13
14
 
14
15
  interface Props {
@@ -29,9 +30,13 @@ interface Props {
29
30
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
30
31
  /** The source section's measured style (`MeasuredStyle`). */
31
32
  measured?: MeasuredStyle | undefined
33
+ /** Section id, unique per page; without one `behavior`, `behavior-2`, … in render order. */
34
+ id?: string | undefined
32
35
  }
33
36
 
34
- const { kind, html, items, title, panel, fact, previousLabel, nextLabel, closeLabel, tone, measured } = Astro.props
37
+ const { kind, html, items, title, panel, fact, previousLabel, nextLabel, closeLabel, tone, measured, id: givenId } = Astro.props
38
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `behavior`, `behavior-2`, … in render order.
39
+ const id = instanceId(Astro.locals, givenId, 'behavior')
35
40
  // Selectors and ids go into attributes only, never into markup or a script; an empty one is left out.
36
41
  const attr = (value: string | undefined): string | undefined => (value && value.length <= 300 ? value : undefined)
37
42
  const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
@@ -39,7 +44,7 @@ const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
39
44
  const NOSCRIPT = '<style>.cr-behavior dialog:not([open]){display:block;position:static;margin:0;max-width:none}</style>'
40
45
  ---
41
46
  {html && (
42
- <Section tone={tone} measured={measured}>
47
+ <Section kit="behavior" tone={tone} id={id} measured={measured}>
43
48
  <div
44
49
  class="cr-behavior"
45
50
  data-cr-behavior={kind}
@@ -71,4 +76,12 @@ const NOSCRIPT = '<style>.cr-behavior dialog:not([open]){display:block;position:
71
76
  .cr-behavior-dialog { margin: auto; max-width: min(92vw, 40rem); max-height: 85dvh; border: 1px solid var(--color-line); border-radius: var(--radius-card, 0.75rem); padding: 1.5rem; }
72
77
  .cr-behavior-dialog::backdrop { background: rgb(0 0 0 / 0.5); }
73
78
  .cr-behavior-lightbox img { display: block; max-width: 100%; max-height: 75dvh; margin-inline: auto; }
79
+ /* A source video player kept as a poster link (data-cr-video): a play button over it, drawn in CSS (nothing loads). */
80
+ .cr-behavior a[data-cr-video] { position: relative; display: block; }
81
+ .cr-behavior a[data-cr-video] img { display: block; width: 100%; height: auto; }
82
+ .cr-behavior a[data-cr-video]::before { content: ''; position: absolute; z-index: 1; top: 50%; left: 50%; width: 4rem; height: 4rem; translate: -50% -50%; border-radius: 9999px; background: var(--color-accent); box-shadow: 0 4px 12px rgb(0 0 0 / 0.25); }
83
+ .cr-behavior a[data-cr-video]::after { content: ''; position: absolute; z-index: 2; top: 50%; left: 50%; translate: -35% -50%; border-top: 0.75rem solid transparent; border-bottom: 0.75rem solid transparent; border-left: 1.25rem solid var(--color-accent-ink, #fff); }
84
+ /* No poster (only the title): a 16:9 dark panel, the title along its foot, the button in its middle. */
85
+ .cr-behavior a[data-cr-video]:not(:has(img)) { aspect-ratio: 16 / 9; display: flex; align-items: flex-end; padding: 1rem 1.25rem; border-radius: var(--radius-card, 0.75rem); background: var(--color-ink, #111827); color: var(--color-surface, #fff); font-weight: 600; text-decoration: none; }
86
+ .cr-behavior a[data-cr-video]:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
74
87
  </style>
@@ -39,7 +39,7 @@
39
39
  "kind": "disclosure",
40
40
  "fact": "interactive:fx3",
41
41
  "tone": "inverse",
42
- "html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx3-a\">Do you ship abroad?</button></h2><div id=\"cr-fx3-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
42
+ "html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx5-a\">Do you ship abroad?</button></h2><div id=\"cr-fx5-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
43
43
  }
44
44
  },
45
45
  {
@@ -48,7 +48,9 @@
48
48
  "kind": "disclosure",
49
49
  "fact": "interactive:fx3",
50
50
  "tone": "accent",
51
- "html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx3-a\">Do you ship abroad?</button></h2><div id=\"cr-fx3-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
51
+ "html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx6-a\">Do you ship abroad?</button></h2><div id=\"cr-fx6-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
52
52
  }
53
- }
53
+ },
54
+ {"name": "video-link", "props": {"kind": "anchor", "fact": "interactive:fx5", "html": "<h2>Studio tour</h2><p>A walk through the workshop.</p><a href=\"https://www.youtube.com/watch?v=aqz-KE-bpKQ\" target=\"_blank\" rel=\"noopener noreferrer\" data-cr-video=\"youtube:aqz-KE-bpKQ\"><img src=\"/kit-fixtures/photo-2.svg\" alt=\"\" loading=\"lazy\" width=\"1200\" height=\"800\"><span>Studio tour</span></a>"}},
55
+ {"name": "video-link-no-poster", "props": {"kind": "anchor", "fact": "interactive:fx6", "html": "<h2>Studio tour</h2><a href=\"https://vimeo.com/76979871\" target=\"_blank\" rel=\"noopener noreferrer\" data-cr-video=\"vimeo:76979871\"><span>Behind the scenes</span></a>"}}
54
56
  ]
@@ -7,7 +7,11 @@
7
7
  "shared": [
8
8
  "Section.astro",
9
9
  "behaviors.ts",
10
+ "embed-frame.ts",
11
+ "embed-target.ts",
12
+ "instance-id.ts",
10
13
  "measured.ts",
14
+ "muted.ts",
11
15
  "safe-url.ts",
12
16
  "types.ts"
13
17
  ],
@@ -193,6 +197,11 @@
193
197
  "description": "The section's minimum height."
194
198
  }
195
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."
196
205
  }
197
206
  },
198
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.