@contentrain/astro-kit 0.20.0 → 0.21.1

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.
package/catalog.json CHANGED
@@ -276,7 +276,7 @@
276
276
  "max": 3,
277
277
  "default": 2,
278
278
  "content": false,
279
- "description": "Level of the section heading; card titles are one below."
279
+ "description": "Level of the section heading; card titles are one below, or at this level when there is no title."
280
280
  }
281
281
  },
282
282
  "slots": {},
@@ -1098,7 +1098,7 @@
1098
1098
  "divi/et_pb_blurb"
1099
1099
  ]
1100
1100
  },
1101
- "a11y": "A list; icons are decorative (aria-hidden), the title and text carry the meaning. The section is labelled by its heading.",
1101
+ "a11y": "A list; icons are decorative (aria-hidden), the title and text carry the meaning. Each title is an <h3> under the section's <h2>, or an <h2> when the section has no heading (the list style's are plain text). The section is labelled by its heading.",
1102
1102
  "pattern": {
1103
1103
  "summary": "repeated icon + short title + line of text",
1104
1104
  "signals": [
@@ -1257,7 +1257,7 @@
1257
1257
  },
1258
1258
  "copyright": {
1259
1259
  "type": "string",
1260
- "description": "Copyright line; {year} is the current year. Defaults to © <year> <siteName>."
1260
+ "description": "Copyright line; {year} is the current year. Defaults to © <year> <siteName>; the component also takes false for no line (the cookie settings control stays)."
1261
1261
  },
1262
1262
  "consentLabel": {
1263
1263
  "type": "string",
@@ -1278,6 +1278,11 @@
1278
1278
  "linkSize": {
1279
1279
  "type": "string",
1280
1280
  "description": "Font size of the footer's links, a CSS length such as 17.88px; absent, the footer's own."
1281
+ },
1282
+ "border": {
1283
+ "type": "boolean",
1284
+ "content": false,
1285
+ "description": "A border on the footer's top side; absent, the default tone has one and the others do not."
1281
1286
  }
1282
1287
  },
1283
1288
  "slots": {},
@@ -2505,7 +2510,7 @@
2505
2510
  "divi/et_pb_pricing_tables"
2506
2511
  ]
2507
2512
  },
2508
- "a11y": "Plans are a <ul>; each name is an <h3>. A featured plan is marked by a text badge and a ring, not colour alone. Buttons are links with their own label.",
2513
+ "a11y": "Plans are a <ul>; each name is an <h3> under the section's <h2>, or an <h2> when the section has no heading. A featured plan is marked by a text badge and a ring, not colour alone. Buttons are links with their own label.",
2509
2514
  "pattern": {
2510
2515
  "summary": "repeated plan units: name + price + feature list + button",
2511
2516
  "signals": [
@@ -3175,7 +3180,7 @@
3175
3180
  "divi/et_pb_blurb"
3176
3181
  ]
3177
3182
  },
3178
- "a11y": "Steps are an <ol>, so their order and count are announced; the visible numbers are CSS counters, not text. Each title is an <h3>. The section is labelled by its heading when it has one.",
3183
+ "a11y": "Steps are an <ol>, so their order and count are announced; the visible numbers are CSS counters, not text. Each title is an <h3> under the section's <h2>, or an <h2> when the section has no heading. The section is labelled by its heading when it has one.",
3179
3184
  "pattern": {
3180
3185
  "summary": "ordered sequence: numbered title + short text units",
3181
3186
  "signals": [
@@ -3286,7 +3291,7 @@
3286
3291
  "classic/tabs"
3287
3292
  ]
3288
3293
  },
3289
- "a11y": "Zag.js tabs: role=tablist/tab/tabpanel with aria-selected, aria-controls and aria-labelledby; arrow keys (per orientation), Home and End move between tabs. Before the script runs every panel is visible with its own h3 title; after, the titles become screen-reader-only and the tabs label the panels.",
3294
+ "a11y": "Zag.js tabs: role=tablist/tab/tabpanel with aria-selected, aria-controls and aria-labelledby; arrow keys (per orientation), Home and End move between tabs. Before the script runs every panel is visible with its own title (an h3 under the section's h2, an h2 when there is no heading); after, the titles become screen-reader-only and the tabs label the panels.",
3290
3295
  "files": [
3291
3296
  "Tabs.astro"
3292
3297
  ]
@@ -3403,7 +3408,7 @@
3403
3408
  "divi/et_pb_team_member"
3404
3409
  ]
3405
3410
  },
3406
- "a11y": "Members are a <ul>; each name is an <h3>, and a linked member's link text is the name. A portrait's text defaults to the name. The section is labelled by its heading when it has one.",
3411
+ "a11y": "Members are a <ul>; each name is an <h3> under the section's <h2> (an <h2> when the section has no heading), and a linked member's link text is the name. A portrait's text defaults to the name. The section is labelled by its heading when it has one.",
3407
3412
  "pattern": {
3408
3413
  "summary": "repeated person units: portrait + name + role",
3409
3414
  "signals": [
@@ -44,7 +44,7 @@ interface Props {
44
44
  title?: string | undefined
45
45
  intro?: string | undefined
46
46
  items: Item[]
47
- /** Heading level of the section title; card titles are one level below. */
47
+ /** Heading level of the section title; card titles are one level below, or at this level when there is no title. */
48
48
  headingLevel?: 2 | 3 | undefined
49
49
  columns?: '2' | '3' | '4' | undefined
50
50
  style?: 'plain' | 'bordered' | 'elevated' | undefined
@@ -57,7 +57,8 @@ const { list, card, media, title: cardTitle, text } = grid({ columns, style, ali
57
57
  // Accent backgrounds have the least contrast headroom: text on them is never dimmed.
58
58
  const dim = tone === 'accent' ? 'opacity-100' : undefined
59
59
  const Heading = `h${headingLevel}` as 'h2' | 'h3'
60
- const CardHeading = `h${headingLevel + 1}` as 'h3' | 'h4'
60
+ // One level under the section title, or the title's level when there is none.
61
+ const CardHeading = `h${title ? headingLevel + 1 : headingLevel}` as 'h2' | 'h3' | 'h4'
61
62
  const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : undefined
62
63
  ---
63
64
  <Section tone={tone} aria-labelledby={headingId}>
@@ -107,7 +107,7 @@
107
107
  "max": 3,
108
108
  "default": 2,
109
109
  "content": false,
110
- "description": "Level of the section heading; card titles are one below."
110
+ "description": "Level of the section heading; card titles are one below, or at this level when there is no title."
111
111
  }
112
112
  },
113
113
  "slots": {},
@@ -64,7 +64,8 @@ const { heading, intro, items, columns, style = 'icon', tone, id } = Astro.props
64
64
  const s = list({ columns, style, tone })
65
65
  const headingId = heading && id ? `${id}-title` : undefined
66
66
  const pathOf = (item: Item) => (style !== 'check' && item.icon && icons[item.icon]) || icons.check
67
- const Title = style === 'list' ? 'span' : 'h3'
67
+ // A plain list's items are not headings; otherwise one level under the section heading, or the section's when there is none.
68
+ const Title = style === 'list' ? 'span' : heading ? 'h3' : 'h2'
68
69
  ---
69
70
  <Section tone={tone} id={id} aria-labelledby={headingId}>
70
71
  {(heading || intro) && (
@@ -106,7 +106,7 @@
106
106
  "divi/et_pb_blurb"
107
107
  ]
108
108
  },
109
- "a11y": "A list; icons are decorative (aria-hidden), the title and text carry the meaning. The section is labelled by its heading.",
109
+ "a11y": "A list; icons are decorative (aria-hidden), the title and text carry the meaning. Each title is an <h3> under the section's <h2>, or an <h2> when the section has no heading (the list style's are plain text). The section is labelled by its heading.",
110
110
  "pattern": {
111
111
  "summary": "repeated icon + short title + line of text",
112
112
  "signals": [
@@ -17,10 +17,12 @@ const footer = tv({
17
17
  columns: { inner: 'grid gap-10 md:grid-cols-[2fr_repeat(var(--footer-columns),1fr)] md:py-16' },
18
18
  },
19
19
  tone: {
20
- default: { root: 'border-t border-line bg-surface text-ink' },
20
+ default: { root: 'bg-surface text-ink' },
21
21
  muted: { root: 'bg-surface-muted text-ink' },
22
22
  inverse: { root: 'bg-ink text-surface' },
23
23
  },
24
+ // The border on the footer's top side: the default tone's own unless `border` says otherwise.
25
+ border: { on: { root: 'border-t border-line' }, off: {} },
24
26
  align: { start: {}, center: { inner: 'items-center justify-items-center text-center md:items-center [&_ul]:justify-center' } },
25
27
  // The `columns` layout's link columns: spread from the brand column over the footer's width, or packed at the end
26
28
  // edge at their own width (a theme whose footer is the brand left and its menus on the right).
@@ -33,7 +35,7 @@ const footer = tv({
33
35
  { layout: 'columns', pack: 'end', class: { inner: 'md:grid-cols-[1fr_repeat(var(--footer-columns),auto)] md:gap-x-[var(--footer-gap,2.5rem)] md:[&>div:first-child]:col-span-1' } },
34
36
  { layout: 'columns', pack: 'end', narrow: 'row', class: { inner: '[&>div:first-child]:col-span-full' } },
35
37
  ],
36
- defaultVariants: { layout: 'simple', tone: 'default', align: 'start', pack: 'spread', narrow: 'stack' },
38
+ defaultVariants: { layout: 'simple', tone: 'default', align: 'start', pack: 'spread', narrow: 'stack', border: 'off' },
37
39
  })
38
40
 
39
41
  interface Props {
@@ -46,8 +48,8 @@ interface Props {
46
48
  social?: LinkInput[] | undefined
47
49
  /** Contact details printed in an `<address>` block; each is optional, and the block is left out when none is set. */
48
50
  contact?: { address?: string | undefined, phone?: string | undefined, email?: string | undefined } | undefined
49
- /** The copyright line, e.g. "© 2026 Northwind Studio"; `{year}` is the current year. */
50
- copyright?: string | undefined
51
+ /** The copyright line, e.g. "© 2026 Northwind Studio"; `{year}` is the current year; `false` prints none. Absent: "© year Site". */
52
+ copyright?: string | false | undefined
51
53
  /** Label of the cookie settings link after the copyright line ("Cookie settings"); it reopens the consent bar, so a visitor can withdraw consent as easily as they gave it. Only with a consent bar on the page. */
52
54
  consentLabel?: string | undefined
53
55
  /** Accessible name of the footer navigation ("Footer"). */
@@ -65,16 +67,20 @@ interface Props {
65
67
  packGapNarrow?: string | undefined
66
68
  /** The links' font size, a CSS length (`17.88px`); absent, the footer's own. */
67
69
  linkSize?: string | undefined
70
+ /** A border on the footer's top side: absent, the default tone has one and the others do not. */
71
+ border?: boolean | undefined
68
72
  }
69
73
 
70
- const { siteName, tagline, columns = [], links = [], social = [], contact, copyright, consentLabel, navLabel = 'Footer', layout = 'simple', tone, align, pack, packGap, narrow, packGapNarrow, linkSize } = Astro.props
71
- const { root, inner, muted, link } = footer({ layout, tone, align, pack: layout === 'columns' ? pack : undefined, narrow: layout === 'columns' && pack === 'end' ? narrow : undefined })
74
+ const { siteName, tagline, columns = [], links = [], social = [], contact, copyright, consentLabel, navLabel = 'Footer', layout = 'simple', tone, align, pack, packGap, narrow, packGapNarrow, linkSize, border } = Astro.props
75
+ const { root, inner, muted, link } = footer({ layout, tone, align, pack: layout === 'columns' ? pack : undefined, narrow: layout === 'columns' && pack === 'end' ? narrow : undefined, border: (border ?? (tone ?? 'default') === 'default') ? 'on' : 'off' })
72
76
  // Custom properties the packed columns and sized links read; none when none is set.
73
77
  const vars = [layout === 'columns' ? `--footer-columns: ${Math.max(columns.length, 1)}` : '', layout === 'columns' && pack === 'end' && packGap ? `--footer-gap: ${packGap}` : '', layout === 'columns' && pack === 'end' && narrow === 'row' && packGapNarrow ? `--footer-gap-narrow: ${packGapNarrow}` : '', linkSize ? `--footer-link-size: ${linkSize}` : ''].filter(Boolean).join('; ')
74
78
  const year = String(new Date().getFullYear())
75
79
  const phoneHref = contact?.phone?.replace(/[^\d+]/g, '')
76
80
  const hasContact = Boolean(contact?.address || contact?.phone || contact?.email)
77
- const line = copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
81
+ const line = copyright === false ? undefined : copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
82
+ // The line and the cookie settings control share one paragraph; neither: no paragraph.
83
+ const hasBottom = Boolean(line || consentLabel)
78
84
  ---
79
85
  <!-- The cookie settings control is a button, not a link: the Consent component's listener reopens its bar on any [data-kit-consent-open]. -->
80
86
  <footer class={root()}>
@@ -89,7 +95,7 @@ const line = copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
89
95
  {contact?.email && <a href={`mailto:${contact.email}`} class={link()}>{contact.email}</a>}
90
96
  </address>
91
97
  )}
92
- {layout === 'columns' && <p class:list={[muted(), 'mt-auto pt-6']}>{line}{consentLabel && <> · <button type="button" data-kit-consent-open class={link()}>{consentLabel}</button></>}</p>}
98
+ {layout === 'columns' && hasBottom && <p class:list={[muted(), 'mt-auto pt-6']}>{line}{consentLabel && <>{line && ' · '}<button type="button" data-kit-consent-open class={link()}>{consentLabel}</button></>}</p>}
93
99
  </div>
94
100
  {layout === 'columns'
95
101
  ? columns.map(column => (
@@ -114,7 +120,7 @@ const line = copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
114
120
  </ul>
115
121
  </nav>
116
122
  )}
117
- <p class={muted()}>{line}{consentLabel && <> · <button type="button" data-kit-consent-open class={link()}>{consentLabel}</button></>}</p>
123
+ {hasBottom && <p class={muted()}>{line}{consentLabel && <>{line && ' · '}<button type="button" data-kit-consent-open class={link()}>{consentLabel}</button></>}</p>}
118
124
  </div>
119
125
  )}
120
126
  {layout === 'columns' && social.length > 0 && (
@@ -126,7 +126,7 @@
126
126
  },
127
127
  "copyright": {
128
128
  "type": "string",
129
- "description": "Copyright line; {year} is the current year. Defaults to © <year> <siteName>."
129
+ "description": "Copyright line; {year} is the current year. Defaults to © <year> <siteName>; the component also takes false for no line (the cookie settings control stays)."
130
130
  },
131
131
  "consentLabel": {
132
132
  "type": "string",
@@ -147,6 +147,11 @@
147
147
  "linkSize": {
148
148
  "type": "string",
149
149
  "description": "Font size of the footer's links, a CSS length such as 17.88px; absent, the footer's own."
150
+ },
151
+ "border": {
152
+ "type": "boolean",
153
+ "content": false,
154
+ "description": "A border on the footer's top side; absent, the default tone has one and the others do not."
150
155
  }
151
156
  },
152
157
  "slots": {},
@@ -61,6 +61,8 @@ interface Props {
61
61
  const { heading, intro, plans, featuredLabel = 'Most popular', columns, tone, id } = Astro.props
62
62
  const s = pricing({ columns, tone })
63
63
  const headingId = heading && id ? `${id}-title` : undefined
64
+ // One level under the section heading, or the section's heading when there is none.
65
+ const PlanHeading = heading ? 'h3' : 'h2'
64
66
  const features = await Promise.all(plans.map(plan => renderMarkdown(plan.features)))
65
67
  ---
66
68
  {plans.length > 0 && (
@@ -75,7 +77,7 @@ const features = await Promise.all(plans.map(plan => renderMarkdown(plan.feature
75
77
  {plans.map((plan, i) => (
76
78
  <li class={s.plan({ class: plan.featured ? 'ring-2 ring-accent shadow-lg md:-my-2 md:py-10' : undefined })}>
77
79
  {plan.featured && <p class={s.badge()}>{featuredLabel}</p>}
78
- <h3 class={s.name()}>{plan.name}</h3>
80
+ <PlanHeading class={s.name()}>{plan.name}</PlanHeading>
79
81
  <p class={s.price()}>
80
82
  <span class={s.amount()}>{plan.price}</span>
81
83
  {plan.period && <span class={s.period()}>{plan.period}</span>}
@@ -120,7 +120,7 @@
120
120
  "divi/et_pb_pricing_tables"
121
121
  ]
122
122
  },
123
- "a11y": "Plans are a <ul>; each name is an <h3>. A featured plan is marked by a text badge and a ring, not colour alone. Buttons are links with their own label.",
123
+ "a11y": "Plans are a <ul>; each name is an <h3> under the section's <h2>, or an <h2> when the section has no heading. A featured plan is marked by a text badge and a ring, not colour alone. Buttons are links with their own label.",
124
124
  "pattern": {
125
125
  "summary": "repeated plan units: name + price + feature list + button",
126
126
  "signals": [
@@ -60,6 +60,8 @@ interface Props {
60
60
  const { heading, intro, items, layout, style, id } = Astro.props
61
61
  const s = steps({ layout, style })
62
62
  const headingId = heading && id ? `${id}-title` : undefined
63
+ // One level under the section heading, or the section's heading when there is none.
64
+ const StepHeading = heading ? 'h3' : 'h2'
63
65
  ---
64
66
  {items.length > 0 && (
65
67
  <Section id={id} aria-labelledby={headingId}>
@@ -75,7 +77,7 @@ const headingId = heading && id ? `${id}-title` : undefined
75
77
  {step.number && <span class={s.badge()} aria-hidden="true">{step.number}</span>}
76
78
  <div class={s.body()}>
77
79
  {imageOf(step.image, step.imageAlt) && <div class={s.media()}><KitImage image={imageOf(step.image, step.imageAlt)!} sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
78
- <h3 class={s.title()}>{step.title}</h3>
80
+ <StepHeading class={s.title()}>{step.title}</StepHeading>
79
81
  <p class={s.text()} data-kit-text>{step.text}</p>
80
82
  </div>
81
83
  </li>
@@ -97,7 +97,7 @@
97
97
  "divi/et_pb_blurb"
98
98
  ]
99
99
  },
100
- "a11y": "Steps are an <ol>, so their order and count are announced; the visible numbers are CSS counters, not text. Each title is an <h3>. The section is labelled by its heading when it has one.",
100
+ "a11y": "Steps are an <ol>, so their order and count are announced; the visible numbers are CSS counters, not text. Each title is an <h3> under the section's <h2>, or an <h2> when the section has no heading. The section is labelled by its heading when it has one.",
101
101
  "pattern": {
102
102
  "summary": "ordered sequence: numbered title + short text units",
103
103
  "signals": [
@@ -52,6 +52,8 @@ interface Props {
52
52
  const { items, heading, label, style, orientation = 'horizontal', tone, id = 'tabs' } = Astro.props
53
53
  const s = tabs({ style, orientation, tone })
54
54
  const headingId = heading ? `${id}-title` : undefined
55
+ // One level under the section heading, or the section's heading when there is none.
56
+ const PanelHeading = heading ? 'h3' : 'h2'
55
57
  const value = (i: number) => `tab-${i + 1}`
56
58
  ---
57
59
  {items.length > 0 && (
@@ -63,7 +65,7 @@ const value = (i: number) => `tab-${i + 1}`
63
65
  </div>
64
66
  {items.map((item, i) => (
65
67
  <div data-part="content" data-value={value(i)} class="min-w-0 flex-1">
66
- <h3 class={s.title()}>{item.label}</h3>
68
+ <PanelHeading class={s.title()}>{item.label}</PanelHeading>
67
69
  <div class={s.panel()} set:html={item.content} />
68
70
  </div>
69
71
  ))}
@@ -24,5 +24,5 @@
24
24
  "divi": ["divi/et_pb_tabs"],
25
25
  "classic": ["classic/tabs"]
26
26
  },
27
- "a11y": "Zag.js tabs: role=tablist/tab/tabpanel with aria-selected, aria-controls and aria-labelledby; arrow keys (per orientation), Home and End move between tabs. Before the script runs every panel is visible with its own h3 title; after, the titles become screen-reader-only and the tabs label the panels."
27
+ "a11y": "Zag.js tabs: role=tablist/tab/tabpanel with aria-selected, aria-controls and aria-labelledby; arrow keys (per orientation), Home and End move between tabs. Before the script runs every panel is visible with its own title (an h3 under the section's h2, an h2 when there is no heading); after, the titles become screen-reader-only and the tabs label the panels."
28
28
  }
@@ -57,6 +57,8 @@ interface Props {
57
57
  const { heading, intro, items, columns, style, roleStyle, id } = Astro.props
58
58
  const s = team({ columns, style, roleStyle })
59
59
  const headingId = heading && id ? `${id}-title` : undefined
60
+ // One level under the section heading, or the section's heading when there is none.
61
+ const NameHeading = heading ? 'h3' : 'h2'
60
62
  ---
61
63
  {items.length > 0 && (
62
64
  <Section id={id} aria-labelledby={headingId}>
@@ -71,11 +73,11 @@ const headingId = heading && id ? `${id}-title` : undefined
71
73
  <li class={s.member()}>
72
74
  {/* The portrait's text defaults to the name: a photo of a named person is not decoration. */}
73
75
  {imageOf(member.image, member.imageAlt ?? member.name) && <div class={s.portrait()}><KitImage image={imageOf(member.image, member.imageAlt ?? member.name)!} sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw" class="aspect-square w-full object-cover" /></div>}
74
- <h3 class={s.name()}>
76
+ <NameHeading class={s.name()}>
75
77
  {member.href
76
78
  ? <a href={member.href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent">{member.name}</a>
77
79
  : member.name}
78
- </h3>
80
+ </NameHeading>
79
81
  {member.role && <p class={s.role()}>{member.role}</p>}
80
82
  {member.bio && <p class={s.bio()} data-kit-text>{member.bio}</p>}
81
83
  </li>
@@ -110,7 +110,7 @@
110
110
  "divi/et_pb_team_member"
111
111
  ]
112
112
  },
113
- "a11y": "Members are a <ul>; each name is an <h3>, and a linked member's link text is the name. A portrait's text defaults to the name. The section is labelled by its heading when it has one.",
113
+ "a11y": "Members are a <ul>; each name is an <h3> under the section's <h2> (an <h2> when the section has no heading), and a linked member's link text is the name. A portrait's text defaults to the name. The section is labelled by its heading when it has one.",
114
114
  "pattern": {
115
115
  "summary": "repeated person units: portrait + name + role",
116
116
  "signals": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
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",
@@ -42,7 +42,7 @@
42
42
  "mapping"
43
43
  ],
44
44
  "dependencies": {
45
- "@contentrain/types": "1.38.0"
45
+ "@contentrain/types": "1.40.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.10.0",