@contentrain/astro-kit 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +16 -0
  2. package/catalog.json +123 -15
  3. package/components/_shared/Lines.astro +12 -0
  4. package/components/_shared/Section.astro +13 -9
  5. package/components/_shared/SectionHeader.astro +3 -2
  6. package/components/_shared/bleed.ts +8 -0
  7. package/components/_shared/instance-id.ts +24 -0
  8. package/components/_shared/muted.ts +19 -0
  9. package/components/band/Band.astro +10 -7
  10. package/components/band/meta.json +2 -0
  11. package/components/behavior/Behavior.astro +7 -2
  12. package/components/behavior/fixtures.json +2 -2
  13. package/components/behavior/meta.json +7 -0
  14. package/components/card-grid/CardGrid.astro +84 -24
  15. package/components/card-grid/fixtures.json +4 -1
  16. package/components/card-grid/meta.json +21 -0
  17. package/components/contact-details/ContactDetails.astro +7 -3
  18. package/components/contact-details/meta.json +2 -0
  19. package/components/contact-form/ContactForm.astro +11 -7
  20. package/components/contact-form/meta.json +2 -0
  21. package/components/cta/Cta.astro +14 -10
  22. package/components/cta/meta.json +2 -2
  23. package/components/dialog/Dialog.astro +6 -3
  24. package/components/dialog/meta.json +2 -0
  25. package/components/faq/Faq.astro +8 -4
  26. package/components/faq/meta.json +1 -1
  27. package/components/feature-list/FeatureList.astro +10 -6
  28. package/components/feature-list/meta.json +3 -1
  29. package/components/footer/Footer.astro +4 -3
  30. package/components/footer/meta.json +1 -0
  31. package/components/gallery/Gallery.astro +8 -4
  32. package/components/gallery/meta.json +2 -0
  33. package/components/header/Header.astro +8 -4
  34. package/components/header/meta.json +1 -1
  35. package/components/hero/Hero.astro +44 -15
  36. package/components/hero/fixtures.json +7 -1
  37. package/components/hero/meta.json +30 -1
  38. package/components/language-switcher/LanguageSwitcher.astro +3 -2
  39. package/components/language-switcher/meta.json +1 -1
  40. package/components/logo-cloud/LogoCloud.astro +9 -5
  41. package/components/logo-cloud/meta.json +3 -1
  42. package/components/nav/Nav.astro +10 -6
  43. package/components/nav/meta.json +2 -0
  44. package/components/newsletter/Newsletter.astro +9 -5
  45. package/components/newsletter/meta.json +2 -0
  46. package/components/post-list/PostList.astro +7 -3
  47. package/components/post-list/meta.json +2 -0
  48. package/components/pricing/Pricing.astro +15 -11
  49. package/components/pricing/meta.json +3 -1
  50. package/components/search-box/SearchBox.astro +4 -1
  51. package/components/search-box/meta.json +1 -1
  52. package/components/slider/Slider.astro +4 -1
  53. package/components/slider/SliderTrack.astro +11 -5
  54. package/components/slider/meta.json +1 -1
  55. package/components/social-links/SocialLinks.astro +2 -1
  56. package/components/social-links/meta.json +3 -2
  57. package/components/split/Split.astro +16 -11
  58. package/components/split/fixtures.json +4 -1
  59. package/components/split/meta.json +9 -3
  60. package/components/stats/Stats.astro +11 -7
  61. package/components/stats/meta.json +3 -1
  62. package/components/steps/Steps.astro +9 -5
  63. package/components/steps/meta.json +3 -1
  64. package/components/tabs/Tabs.astro +8 -4
  65. package/components/tabs/meta.json +1 -1
  66. package/components/team/Team.astro +10 -6
  67. package/components/team/meta.json +4 -2
  68. package/components/testimonial/Testimonial.astro +9 -5
  69. package/components/testimonial/fixtures.json +1 -1
  70. package/components/testimonial/meta.json +3 -1
  71. package/package.json +1 -1
@@ -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}
@@ -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,7 @@
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
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>"}},
@@ -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,13 +28,36 @@ 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
  },
33
35
  defaultVariants: { columns: '3', style: 'plain', align: 'start' },
34
36
  })
35
37
 
38
+ // `layout: 'rows'`: one item per row, its title, text and link on one axis (an image sits at the row's top, out of the
39
+ // baseline: a head led by an icon too, else the icon's foot would be the baseline and push the text down by its
40
+ // height), a hairline between rows and no card box.
41
+ // With `index`, a numbered list whose numbers (01, 02, …) are drawn in the muted role, tabular; the list itself
42
+ // carries the order for screen readers, so the drawn number is hidden from them.
43
+ const rows = tv({
44
+ slots: {
45
+ list: 'flex flex-col divide-y divide-line border-y border-line',
46
+ row: 'group relative grid gap-x-8 gap-y-2 py-6 md:items-baseline',
47
+ number: `text-sm tabular-nums ${muted}`,
48
+ head: 'flex flex-col gap-2',
49
+ end: 'flex flex-col gap-3 md:items-end',
50
+ media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))] md:w-48',
51
+ title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
52
+ text: muted,
53
+ link: 'font-medium text-accent-text underline-offset-4 hover:underline',
54
+ },
55
+ variants: {
56
+ index: { true: { row: 'md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,2fr)_auto]' }, false: { row: 'md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]' } },
57
+ },
58
+ defaultVariants: { index: false },
59
+ })
60
+
36
61
  interface Item {
37
62
  title: string
38
63
  text?: string | undefined
@@ -54,43 +79,78 @@ interface Props {
54
79
  /** Heading level of the section title; card titles are one level below, or at this level when there is no title. */
55
80
  headingLevel?: 2 | 3 | undefined
56
81
  columns?: '2' | '3' | '4' | undefined
82
+ /** `rows`: one item per row (title, text and link on one axis, a hairline between rows, no card box); `columns`, `style` and the measured columns do not apply. `grid` (absent): cards, as before. */
83
+ layout?: 'grid' | 'rows' | undefined
84
+ /** With `layout: 'rows'`: a numbered list, each row led by its number (01, 02, …) in the muted role. */
85
+ index?: boolean | undefined
57
86
  style?: 'plain' | 'bordered' | 'elevated' | undefined
58
87
  align?: 'start' | 'center' | undefined
59
88
  tone?: Tone | undefined
60
89
  /** 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
90
  measured?: MeasuredStyle | undefined
91
+ /** Section id, unique per page; without one `card-grid`, `card-grid-2`, … in render order. Its title is `<id>-title`. */
92
+ id?: string | undefined
62
93
  }
63
94
 
64
- const { title, intro, items, headingLevel = 2, columns, style, align, tone, measured } = Astro.props
95
+ const { title, intro, items, headingLevel = 2, columns, style, align, tone, measured, id: givenId, layout, index = false } = Astro.props
96
+ // 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.
97
+ const id = instanceId(Astro.locals, givenId, 'card-grid')
65
98
  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
99
  const Heading = `h${headingLevel}` as 'h2' | 'h3'
69
100
  // One level under the section title, or the title's level when there is none.
70
101
  const CardHeading = `h${title ? headingLevel + 1 : headingLevel}` as 'h2' | 'h3' | 'h4'
71
- const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : undefined
102
+ const headingId = title ? `${id}-title` : undefined
103
+ const r = rows({ index })
104
+ const List = index ? 'ol' : 'ul'
105
+ const number = (i: number) => String(i + 1).padStart(2, '0')
72
106
  ---
73
- <Section measured={measured} tone={tone} aria-labelledby={headingId}>
107
+ <Section kit="card-grid" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
74
108
  {(title || intro) && (
75
109
  <header class:list={['mb-10 flex max-w-2xl flex-col gap-3', align === 'center' && 'mx-auto items-center text-center']}>
76
110
  {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>}
111
+ {intro && <p class:list={['text-lg', muted]}>{intro}</p>}
78
112
  </header>
79
113
  )}
80
- <ul class={list({ class: measuredGrid(measured) })}>
81
- {items.map(item => (
82
- <li class={card()}>
83
- {imageOf(item.image, item.imageAlt) && <div class={media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
84
- {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
85
- {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
86
- <CardHeading class={cardTitle()}>
87
- {item.href && !item.linkLabel
88
- ? <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
- : item.title}
90
- </CardHeading>
91
- {item.text && <p class={text({ class: dim })} data-kit-text>{item.text}</p>}
92
- {item.href && item.linkLabel && <a href={safeHref(item.href)} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
93
- </li>
94
- ))}
95
- </ul>
114
+ {layout === 'rows' ? (
115
+ <List class={r.list()} role="list">
116
+ {items.map((item, i) => (
117
+ <li class={r.row()}>
118
+ {index && <span class={r.number()} aria-hidden="true">{number(i)}</span>}
119
+ <div class={r.head({ class: item.icon ? 'md:self-start' : undefined })}>
120
+ {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-8 w-8 object-contain" />}
121
+ {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
122
+ <CardHeading class={r.title()}>
123
+ {item.href && !item.linkLabel
124
+ ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
125
+ : item.title}
126
+ </CardHeading>
127
+ </div>
128
+ {item.text ? <p class={r.text()} data-kit-text>{item.text}</p> : <span aria-hidden="true" />}
129
+ {(imageOf(item.image, item.imageAlt) || (item.href && item.linkLabel)) && (
130
+ <div class={r.end({ class: imageOf(item.image, item.imageAlt) ? 'md:self-start' : undefined })}>
131
+ {imageOf(item.image, item.imageAlt) && <div class={r.media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 768px) 12rem, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
132
+ {item.href && item.linkLabel && <a href={safeHref(item.href)} class={r.link()}>{item.linkLabel}</a>}
133
+ </div>
134
+ )}
135
+ </li>
136
+ ))}
137
+ </List>
138
+ ) : (
139
+ <ul class={list({ class: measuredGrid(measured) })}>
140
+ {items.map(item => (
141
+ <li class={card()}>
142
+ {imageOf(item.image, item.imageAlt) && <div class={media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
143
+ {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
144
+ {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
145
+ <CardHeading class={cardTitle()}>
146
+ {item.href && !item.linkLabel
147
+ ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
148
+ : item.title}
149
+ </CardHeading>
150
+ {item.text && <p class={text()} data-kit-text>{item.text}</p>}
151
+ {item.href && item.linkLabel && <a href={safeHref(item.href)} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ )}
96
156
  </Section>
@@ -5,5 +5,8 @@
5
5
  { "name": "accent", "props": { "tone": "accent", "columns": "3", "items": [{ "title": "Fast", "text": "Static HTML, one stylesheet." }, { "title": "Editable", "text": "Content in Contentrain Studio." }, { "title": "Kept", "text": "Every address survives." }] } },
6
6
  {"name": "measured-columns", "props": {"title": "Services", "measured": {"containerPx": 1140, "padY": 80, "columns": {"390": 1, "768": 2, "1280": 4}, "gap": 30, "radius": 4, "bg": {"color": "#f4f1ea"}}, "items": [{"title": "Audits", "text": "Where a site loses readers."}, {"title": "Builds", "text": "Static and fast."}, {"title": "Hosting", "text": "Managed for you."}, {"title": "Care", "text": "Kept up to date."}]}},
7
7
  {"name": "icon-boxes", "props": {"columns": "3", "align": "center", "items": [{"title": "Design", "text": "Layouts built around your products.", "icon": "/kit-fixtures/icon-1.svg"}, {"title": "Build", "text": "Fast pages that stay fast.", "icon": "/kit-fixtures/icon-1.svg"}, {"title": "Grow", "text": "Monthly reports you can read in five minutes.", "icon": "/kit-fixtures/icon-1.svg"}]}},
8
- {"name": "link-labels", "props": {"columns": "4", "align": "center", "items": [{"title": "Kasko", "href": "/urunlerimiz/kasko/", "linkLabel": "Teklif Al"}, {"title": "Trafik Sigortası", "href": "/urunlerimiz/trafik-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Konut Sigortası", "href": "/urunlerimiz/konut-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Tamamlayıcı Sağlık"}]}}
8
+ {"name": "link-labels", "props": {"columns": "4", "align": "center", "items": [{"title": "Kasko", "href": "/urunlerimiz/kasko/", "linkLabel": "Teklif Al"}, {"title": "Trafik Sigortası", "href": "/urunlerimiz/trafik-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Konut Sigortası", "href": "/urunlerimiz/konut-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Tamamlayıcı Sağlık"}]}},
9
+ {"name": "rows-index", "props": {"layout": "rows", "index": true, "title": "How it works", "intro": "Three steps, in this order.", "items": [{"title": "Discovery", "text": "We read every page of the site you have, and write down what it holds.", "href": "/how/discovery/"}, {"title": "An offer", "text": "One price for the whole move, with what it includes and what it leaves out.", "href": "/how/offer/"}, {"title": "The move", "text": "Content, addresses and forms carried over, each one checked against the original.", "href": "/how/move/"}]}},
10
+ {"name": "rows-links-muted", "props": {"layout": "rows", "tone": "muted", "title": "What we do", "items": [{"eyebrow": "Studio", "title": "Design systems", "text": "Tokens, components and documentation your team keeps using.", "href": "/services/design/", "linkLabel": "Read more", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}}, {"eyebrow": "Studio", "title": "Migrations", "text": "WordPress to Astro without losing an address or a ranking.", "href": "/services/migrations/", "linkLabel": "Read more", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "", "width": 1200, "height": 800}}, {"eyebrow": "Studio", "title": "Performance", "href": "/services/performance/", "linkLabel": "Read more"}]}},
11
+ {"name": "rows-icons", "props": {"layout": "rows", "title": "What the move keeps", "items": [{"icon": "/kit-fixtures/icon-1.svg", "title": "Addresses", "text": "Every old address answers, or sends the visitor to its new one.", "href": "/keeps/addresses/"}, {"icon": "/kit-fixtures/icon-1.svg", "eyebrow": "Forms", "title": "Contact forms", "text": "Each form posts where it did, with the same fields.", "href": "/keeps/forms/", "linkLabel": "How"}, {"title": "Rankings", "text": "Titles, descriptions and structured data carried over as they were."}]}}
9
12
  ]
@@ -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
  ],
@@ -39,6 +41,14 @@
39
41
  "default": "start",
40
42
  "description": "Text alignment inside cards and of the header."
41
43
  },
44
+ "layout": {
45
+ "options": [
46
+ "grid",
47
+ "rows"
48
+ ],
49
+ "default": "grid",
50
+ "description": "Cards in a grid, or one item per row: title, text and link on one axis, a hairline between rows, no card box (columns and style do not apply)."
51
+ },
42
52
  "tone": {
43
53
  "options": [
44
54
  "default",
@@ -113,6 +123,12 @@
113
123
  },
114
124
  "description": "The cards. image is an ImageInput ({ src, alt, width?, height? })."
115
125
  },
126
+ "index": {
127
+ "type": "boolean",
128
+ "default": false,
129
+ "content": false,
130
+ "description": "With layout rows: a numbered list, each row led by its number (01, 02, …) in the muted role."
131
+ },
116
132
  "headingLevel": {
117
133
  "type": "integer",
118
134
  "min": 2,
@@ -230,6 +246,11 @@
230
246
  "description": "The section's minimum height."
231
247
  }
232
248
  }
249
+ },
250
+ "id": {
251
+ "type": "string",
252
+ "content": false,
253
+ "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
254
  }
234
255
  },
235
256
  "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",