@contentrain/astro-kit 0.30.0 → 0.32.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 (67) hide show
  1. package/README.md +25 -2
  2. package/catalog.json +3305 -83
  3. package/components/_shared/Section.astro +18 -3
  4. package/components/_shared/measured.ts +152 -0
  5. package/components/_shared/types.ts +40 -0
  6. package/components/band/Band.astro +132 -0
  7. package/components/band/fixtures.json +8 -0
  8. package/components/band/meta.json +323 -0
  9. package/components/card-grid/CardGrid.astro +7 -3
  10. package/components/card-grid/fixtures.json +2 -1
  11. package/components/card-grid/meta.json +111 -0
  12. package/components/contact-details/ContactDetails.astro +7 -2
  13. package/components/contact-details/meta.json +113 -1
  14. package/components/contact-form/ContactForm.astro +48 -12
  15. package/components/contact-form/fixtures.json +169 -9
  16. package/components/contact-form/meta.json +283 -15
  17. package/components/cta/Cta.astro +52 -12
  18. package/components/cta/fixtures.json +5 -1
  19. package/components/cta/meta.json +11 -2
  20. package/components/faq/Faq.astro +60 -17
  21. package/components/faq/fixtures.json +75 -0
  22. package/components/faq/meta.json +8 -2
  23. package/components/feature-list/FeatureList.astro +34 -6
  24. package/components/feature-list/fixtures.json +2 -1
  25. package/components/feature-list/meta.json +125 -1
  26. package/components/gallery/Gallery.astro +47 -13
  27. package/components/gallery/fixtures.json +275 -0
  28. package/components/gallery/meta.json +266 -14
  29. package/components/hero/Hero.astro +14 -8
  30. package/components/hero/fixtures.json +2 -1
  31. package/components/hero/meta.json +111 -0
  32. package/components/logo-cloud/LogoCloud.astro +85 -21
  33. package/components/logo-cloud/fixtures.json +239 -0
  34. package/components/logo-cloud/meta.json +145 -5
  35. package/components/newsletter/Newsletter.astro +5 -1
  36. package/components/newsletter/meta.json +113 -1
  37. package/components/post-card/PostCard.astro +4 -2
  38. package/components/post-card/meta.json +2 -1
  39. package/components/post-list/PostList.astro +114 -0
  40. package/components/post-list/fixtures.json +387 -0
  41. package/components/post-list/meta.json +308 -0
  42. package/components/pricing/Pricing.astro +128 -24
  43. package/components/pricing/fixtures.json +288 -4
  44. package/components/pricing/meta.json +188 -1
  45. package/components/split/Split.astro +76 -14
  46. package/components/split/fixtures.json +8 -1
  47. package/components/split/meta.json +226 -4
  48. package/components/stats/Stats.astro +42 -10
  49. package/components/stats/fixtures.json +173 -0
  50. package/components/stats/meta.json +153 -5
  51. package/components/steps/Steps.astro +36 -8
  52. package/components/steps/fixtures.json +217 -5
  53. package/components/steps/meta.json +157 -3
  54. package/components/tabs/Tabs.astro +5 -2
  55. package/components/tabs/meta.json +2 -1
  56. package/components/team/Team.astro +52 -8
  57. package/components/team/fixtures.json +217 -4
  58. package/components/team/meta.json +168 -3
  59. package/components/team/team.ts +9 -0
  60. package/components/testimonial/Testimonial.astro +71 -21
  61. package/components/testimonial/fixtures.json +73 -0
  62. package/components/testimonial/meta.json +167 -3
  63. package/mapping/classic.json +7 -1
  64. package/mapping/divi.json +21 -4
  65. package/mapping/elementor.json +33 -8
  66. package/mapping/gutenberg.json +20 -4
  67. package/package.json +2 -2
@@ -3,13 +3,21 @@
3
3
  // across the full section, or as a card inside it.
4
4
  import { tv } from 'tailwind-variants'
5
5
  import Button from '../_shared/Button.astro'
6
+ import KitImage from '../_shared/KitImage.astro'
6
7
  import Section from '../_shared/Section.astro'
7
- import type { ActionInput } from '../_shared/types'
8
+ import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
8
9
 
9
10
  const cta = tv({
10
11
  slots: {
11
12
  box: 'flex flex-col gap-6',
12
13
  copy: 'flex flex-col gap-3',
14
+ eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
15
+ // The text and buttons when a picture stands beside them.
16
+ text: 'flex min-w-0 flex-1 flex-col gap-6',
17
+ // The buttons with the small print under them.
18
+ cta: 'flex flex-col gap-2 md:shrink-0',
19
+ note: 'text-sm opacity-75',
20
+ media: 'w-full max-w-sm shrink-0 overflow-hidden rounded-[var(--radius-image,var(--radius-card))] md:w-2/5',
13
21
  heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
14
22
  body: 'max-w-2xl text-pretty opacity-85',
15
23
  actions: 'flex flex-wrap gap-3',
@@ -21,13 +29,19 @@ const cta = tv({
21
29
  // Heading, text and buttons in one column at the start edge, on every width.
22
30
  stacked: { box: 'items-start text-start' },
23
31
  },
32
+ // The heading's scale; `lg` also gives the band more room.
33
+ size: { md: {}, lg: { heading: 'text-3xl md:text-5xl' } },
34
+ // The text block's alignment (`centered` already sets its own).
35
+ align: { start: {}, center: { copy: 'items-center text-center', body: 'mx-auto' } },
36
+ // With a picture: which side it takes on wide screens (text first in the source).
37
+ mediaSide: { end: {}, start: { media: 'md:order-first' } },
24
38
  frame: {
25
39
  full: {},
26
40
  card: { box: 'rounded-card border border-line p-8 md:p-12' },
27
41
  },
28
42
  // 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.
29
43
  // none: no fill of its own — the page background and text colour show through.
30
- tone: { default: {}, muted: {}, inverse: {}, accent: { body: 'opacity-100' }, brand: { body: 'opacity-100' }, none: {} },
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: {} },
31
45
  // The buttons' own alignment: `start` keeps them at the start edge under centred text (a WordPress buttons block
32
46
  // with no justification sits left in a group whose heading and paragraph are centred). The row is the text's
33
47
  // column, as WordPress's content width is, not the whole section. Only centred needs it: inline and stacked
@@ -42,35 +56,61 @@ const cta = tv({
42
56
  { frame: 'card', tone: 'accent', class: { box: 'border-transparent bg-accent text-accent-ink' } },
43
57
  { frame: 'card', tone: 'brand', class: { box: 'border-transparent bg-brand text-brand-ink' } },
44
58
  { layout: 'centered', actionsAlign: 'start', class: { actions: 'w-full max-w-2xl justify-start' } },
59
+ { layout: 'centered', class: { cta: 'items-center' } },
45
60
  ],
46
- defaultVariants: { layout: 'inline', frame: 'full', tone: 'accent', actionsAlign: 'inherit' },
61
+ defaultVariants: { layout: 'inline', frame: 'full', size: 'md', align: 'start', mediaSide: 'end', tone: 'accent', actionsAlign: 'inherit' },
47
62
  })
48
63
 
49
64
  interface Props {
50
65
  heading: string
66
+ eyebrow?: string | undefined
51
67
  body?: string | undefined
68
+ /** The small print under the buttons ("No credit card required"). */
69
+ note?: string | undefined
70
+ /** A small picture beside the text. A large one is a `split`. */
71
+ image?: ImageInput | undefined
52
72
  actions: ActionInput[]
53
73
  layout?: 'inline' | 'centered' | 'stacked' | undefined
54
74
  frame?: 'full' | 'card' | undefined
75
+ size?: 'md' | 'lg' | undefined
76
+ align?: 'start' | 'center' | undefined
77
+ mediaSide?: 'start' | 'end' | undefined
55
78
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | 'brand' | 'none' | undefined
56
79
  /** The buttons' alignment: as the layout sets it, or at the start edge under centred text (layout centered). */
57
80
  actionsAlign?: 'inherit' | 'start' | undefined
81
+ /** A picture behind the band (its address); the text turns light and an overlay keeps it readable. For `frame` full. */
82
+ bgImage?: string | undefined
83
+ /** The colour laid over `bgImage` (default: black at 55 %). */
84
+ bgOverlay?: string | undefined
85
+ /** 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. */
86
+ measured?: MeasuredStyle | undefined
58
87
  id?: string | undefined
59
88
  }
60
89
 
61
- const { heading, body, actions, layout, frame, tone = 'accent', actionsAlign, id } = Astro.props
62
- const s = cta({ layout, frame, tone, actionsAlign })
90
+ const { heading, eyebrow, body, note, image, actions, layout, frame, size, align, mediaSide, tone = 'accent', actionsAlign, bgImage, bgOverlay, measured: given, id } = Astro.props
91
+ const s = cta({ layout, frame, size, align, mediaSide, tone, actionsAlign })
92
+ // A background picture is the section's measured background: Section draws the image and its overlay, and the text
93
+ // turns light so it reads on either.
94
+ const measured: MeasuredStyle | undefined = bgImage
95
+ ? { ...given, tone: 'dark', bg: { ...given?.bg, image: bgImage, overlay: bgOverlay ?? given?.bg?.overlay ?? 'rgba(0, 0, 0, 0.55)' } }
96
+ : given
63
97
  const headingId = id ? `${id}-title` : undefined
64
98
  // On the accent background the primary button (accent) would vanish: it is drawn as the secondary style there.
65
99
  const onAccent = (action: ActionInput): ActionInput => ({ label: action.label, href: action.href, newTab: action.newTab, style: 'secondary' })
66
- const shown = actions.map(action => (tone === 'accent' && (action.style ?? 'primary') === 'primary' ? onAccent(action) : action))
100
+ const shown = actions.map(action => (tone === 'accent' && !bgImage && (action.style ?? 'primary') === 'primary' ? onAccent(action) : action))
67
101
  ---
68
- <Section tone={frame === 'card' ? 'default' : tone} spacing="md" id={id} aria-labelledby={headingId}>
69
- <div class={s.box()}>
70
- <div class={s.copy()}>
71
- <h2 id={headingId} class={s.heading()}>{heading}</h2>
72
- {body && <p class={s.body()} data-kit-text>{body}</p>}
102
+ <Section measured={measured} tone={frame === 'card' ? 'default' : tone} spacing={size === 'lg' ? 'lg' : 'md'} id={id} aria-labelledby={headingId}>
103
+ <div class:list={[s.box(), image && 'md:flex-row md:items-center md:justify-between md:gap-12']}>
104
+ <div class={image ? s.text() : 'contents'}>
105
+ <div class={s.copy()}>
106
+ {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
107
+ <h2 id={headingId} class={s.heading()}>{heading}</h2>
108
+ {body && <p class={s.body()} data-kit-text>{body}</p>}
109
+ </div>
110
+ {note
111
+ ? <div class={s.cta()}>{shown.length > 0 && <div class={s.actions()}>{shown.map(action => <Button action={action} size="lg" />)}</div>}<p class={s.note()} data-kit-text>{note}</p></div>
112
+ : shown.length > 0 && <div class={s.actions()}>{shown.map(action => <Button action={action} size="lg" />)}</div>}
73
113
  </div>
74
- {shown.length > 0 && <div class={s.actions()}>{shown.map(action => <Button action={action} size="lg" />)}</div>}
114
+ {image && <div class={s.media()}><KitImage image={image} sizes="(min-width: 768px) 30vw, 100vw" class="h-auto w-full object-cover" /></div>}
75
115
  </div>
76
116
  </Section>
@@ -5,5 +5,9 @@
5
5
  { "name": "card-muted", "props": { "heading": "Support independent travel writing", "frame": "card", "tone": "muted", "layout": "centered", "actions": [{ "label": "Become a member", "href": "/members/" }] } },
6
6
  { "name": "stacked-none", "props": { "heading": "Ready when you are", "body": "Tell us where you want to go and we will send a first plan within a week.", "layout": "stacked", "tone": "none", "actions": [{ "label": "Start planning", "href": "/contact/" }] } },
7
7
  { "name": "centered-actions-start", "props": { "heading": "See what is on this week", "body": "New films every Thursday, and a late show on Saturdays.", "layout": "centered", "tone": "inverse", "actionsAlign": "start", "actions": [{ "label": "Programme", "href": "/programme/" }] } },
8
- { "name": "centered-brand", "props": { "heading": "Ready when you are", "body": "Two launch slots left this quarter.", "layout": "centered", "tone": "brand", "actions": [{ "label": "Book a launch call", "href": "/contact/" }] } }
8
+ { "name": "centered-brand", "props": { "heading": "Ready when you are", "body": "Two launch slots left this quarter.", "layout": "centered", "tone": "brand", "actions": [{ "label": "Book a launch call", "href": "/contact/" }] } },
9
+ { "name": "eyebrow-note-lg", "props": { "eyebrow": "Free trial", "heading": "Start building today", "body": "Ship a first page in an afternoon.", "note": "No credit card required", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "size": "lg", "tone": "muted", "align": "center" } },
10
+ { "name": "media-end", "props": { "heading": "See it in action", "body": "A short tour of the editor.", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "image": { "src": "/kit-fixtures/photo-1.svg", "alt": "The editor", "width": 640, "height": 480 }, "tone": "default", "frame": "card" } },
11
+ { "name": "media-start-centered", "props": { "heading": "Join the newsletter", "body": "One email a month.", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "", "width": 640, "height": 480 }, "mediaSide": "start", "layout": "centered", "tone": "inverse" } },
12
+ { "name": "background-image", "props": { "heading": "Planning a trip?", "body": "Start with the guides readers use most.", "actions": [{ "label": "Subscribe", "href": "/subscribe/" }], "layout": "centered", "bgImage": "/kit-fixtures/photo-3.svg", "tone": "inverse", "size": "lg" } }
9
13
  ]
@@ -4,17 +4,26 @@
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", "Section.astro", "types.ts"],
7
+ "shared": ["Button.astro", "KitImage.astro", "Section.astro", "measured.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." },
11
11
  "tone": { "options": ["default", "muted", "inverse", "accent", "brand", "none"], "default": "accent", "description": "Background. On accent, primary buttons are drawn in the secondary style so they stay visible. brand: the site's brand colour role (`--color-brand`, the builder's own colour on a band) with its ink; buttons keep their style. none: no fill of its own; the page background shows through (a source colour the palette does not have)." },
12
- "actionsAlign": { "options": ["inherit", "start"], "default": "inherit", "description": "The buttons' alignment. inherit: as the layout sets it. start: at the start edge under centred heading and text (layout centered; a WordPress buttons block with no justification in a centred group). inline and stacked ignore it." }
12
+ "actionsAlign": { "options": ["inherit", "start"], "default": "inherit", "description": "The buttons' alignment. inherit: as the layout sets it. start: at the start edge under centred heading and text (layout centered; a WordPress buttons block with no justification in a centred group). inline and stacked ignore it." },
13
+ "size": { "options": ["md", "lg"], "default": "md", "description": "md: the usual band. lg: a larger heading and more room above and below." },
14
+ "align": { "options": ["start", "center"], "default": "start", "description": "Alignment of the heading and text (the centred layout already centres them)." },
15
+ "mediaSide": { "options": ["end", "start"], "default": "end", "description": "With a picture: the side it takes on wide screens." }
13
16
  },
14
17
  "props": {
15
18
  "heading": { "type": "string", "label": "Heading", "required": true, "description": "The call's heading (Divi CTA title, Elementor call-to-action title)." },
19
+ "eyebrow": { "type": "string", "label": "Eyebrow", "description": "A short line above the heading." },
16
20
  "body": { "type": "text", "label": "Text", "description": "One or two sentences under the heading." },
21
+ "note": { "type": "string", "label": "Small print", "description": "A line under the buttons (No credit card required)." },
22
+ "image": { "type": "image", "label": "Image", "description": "A small picture beside the text; a large one belongs in a split section." },
23
+ "bgImage": { "type": "image", "label": "Background image", "description": "A picture behind the band; the text turns light and an overlay keeps it readable." },
24
+ "bgOverlay": { "type": "string", "content": false, "description": "The colour laid over the background image (a hex or rgb colour); default black at 55 %." },
17
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
+ "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." } } },
18
27
  "id": { "type": "string", "content": false, "description": "Anchor id of the section; the heading gets `<id>-title`." }
19
28
  },
20
29
  "slots": {},
@@ -5,6 +5,8 @@
5
5
  // and AI answers.
6
6
  import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
+ import { measuredColumns } from '../_shared/measured'
9
+ import type { MeasuredStyle } from '../_shared/types'
8
10
 
9
11
  export interface FaqItem {
10
12
  question: string
@@ -14,14 +16,28 @@ export interface FaqItem {
14
16
 
15
17
  const faq = tv({
16
18
  slots: {
17
- heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
19
+ root: '',
20
+ head: 'mb-8 flex flex-col gap-3',
21
+ heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
22
+ intro: 'max-w-2xl text-lg opacity-80',
18
23
  list: 'flex flex-col',
19
24
  item: 'group',
25
+ question: '',
20
26
  summary: 'flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg [font-weight:var(--font-weight-heading,600)] [&::-webkit-details-marker]:hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
21
- marker: 'size-5 shrink-0 transition-transform duration-200 group-open:rotate-45',
27
+ marker: 'size-5 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
22
28
  answer: 'prose-site prose max-w-none pb-5 text-current',
23
29
  },
24
30
  variants: {
31
+ // single: heading above, one column. split: the heading and intro stay in a left column (sticky) beside the list.
32
+ layout: {
33
+ single: {},
34
+ split: { root: 'grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-16', head: 'mb-0 lg:sticky lg:top-8 lg:self-start' },
35
+ },
36
+ // Two balanced columns of questions on wide screens.
37
+ columns: { '1': {}, '2': { list: 'lg:block lg:columns-2 lg:gap-x-12', item: 'break-inside-avoid' } },
38
+ // The marker in each question's row: a plus that turns into a cross, or a chevron that turns over.
39
+ icon: { plus: {}, chevron: { marker: 'group-open:rotate-180' } },
40
+ numbered: { true: { list: '[counter-reset:faq]', item: '[counter-increment:faq]', question: 'before:me-4 before:tabular-nums before:opacity-60 before:content-[counter(faq,decimal-leading-zero)]' }, false: {} },
25
41
  style: {
26
42
  lines: { list: 'divide-y divide-line border-y border-line' },
27
43
  cards: { list: 'gap-3', item: 'rounded-card border border-line px-5', summary: 'py-5' },
@@ -36,13 +52,23 @@ const faq = tv({
36
52
  },
37
53
  compoundVariants: [
38
54
  { style: 'cards', tone: 'muted', class: { item: 'border-transparent' } },
55
+ { icon: 'plus', class: { marker: 'group-open:rotate-45' } },
56
+ // A rule between items does not cross columns: each item carries its own, and the list's are dropped.
57
+ { columns: '2', style: 'lines', class: { list: 'lg:divide-y-0 lg:border-y-0', item: 'lg:border-b lg:border-line' } },
58
+ { columns: '2', style: 'cards', class: { item: 'lg:mb-3' } },
39
59
  ],
40
- defaultVariants: { style: 'lines', tone: 'default', questionStyle: 'heading' },
60
+ defaultVariants: { layout: 'single', columns: '1', icon: 'plus', numbered: false, style: 'lines', tone: 'default', questionStyle: 'heading' },
41
61
  })
42
62
 
43
63
  interface Props {
44
64
  items: FaqItem[]
45
65
  heading?: string | undefined
66
+ intro?: string | undefined
67
+ layout?: 'single' | 'split' | undefined
68
+ columns?: '1' | '2' | undefined
69
+ icon?: 'plus' | 'chevron' | undefined
70
+ /** Count the questions with a leading number (drawn by CSS, not part of the text). */
71
+ numbered?: boolean | undefined
46
72
  style?: 'lines' | 'cards' | 'plain' | undefined
47
73
  /** How the questions read; `plain` sets its own and ignores this. */
48
74
  questionStyle?: 'heading' | 'bold' | undefined
@@ -53,11 +79,19 @@ interface Props {
53
79
  /** Add FAQPage structured data (JSON-LD). Once per page. */
54
80
  schema?: boolean | undefined
55
81
  tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
82
+ /** 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. */
83
+ measured?: MeasuredStyle | undefined
56
84
  id?: string | undefined
57
85
  }
58
86
 
59
- const { items, heading, style, questionStyle, exclusive = false, openFirst = false, schema = false, tone, id = 'faq' } = Astro.props
60
- const s = faq({ style, questionStyle: style === 'plain' ? 'heading' : questionStyle, tone })
87
+ const { items, heading, intro, layout, columns, icon, numbered = false, style, questionStyle, exclusive = false, openFirst = false, schema = false, tone, measured, id = 'faq' } = Astro.props
88
+ const s = faq({ layout, columns, icon, numbered, style, questionStyle: style === 'plain' ? 'heading' : questionStyle, tone })
89
+ // Two columns and the split layout need the page's width; one column reads in the narrow one.
90
+ const measuredCols = measuredColumns(measured)
91
+ const wide = layout === 'split' || columns === '2' || measuredCols !== undefined
92
+ // Measured columns win over `columns`. A rule between items does not cross columns, so each item carries its own.
93
+ const listClass = measuredCols && (style === 'cards' || style === 'plain' ? measuredCols : `${measuredCols} divide-y-0 border-b-0`)
94
+ const itemClass = measuredCols ? (style === 'cards' ? 'break-inside-avoid mb-3' : style === 'plain' ? 'break-inside-avoid' : 'break-inside-avoid border-b border-line') : undefined
61
95
  const headingId = heading ? `${id}-title` : undefined
62
96
  const plain = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
63
97
  const structured = schema && items.length > 0
@@ -69,18 +103,27 @@ const structured = schema && items.length > 0
69
103
  : null
70
104
  ---
71
105
  {items.length > 0 && (
72
- <Section tone={tone} spacing="md" width="prose" id={id} aria-labelledby={headingId}>
73
- {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
74
- <div class={s.list()}>
75
- {items.map((item, i) => (
76
- <details class={s.item()} name={exclusive ? id : undefined} open={openFirst && i === 0}>
77
- <summary class={s.summary()}>
78
- <span>{item.question}</span>
79
- <svg class={s.marker()} viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="M10 4v12M4 10h12" /></svg>
80
- </summary>
81
- <div class={s.answer()} set:html={item.answer} />
82
- </details>
83
- ))}
106
+ <Section measured={measured} tone={tone} spacing="md" width={wide ? 'page' : 'prose'} id={id} aria-labelledby={headingId}>
107
+ <div class={s.root()}>
108
+ {(heading || intro) && (
109
+ <header class={s.head()}>
110
+ {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
111
+ {intro && <p class={s.intro()} data-kit-text>{intro}</p>}
112
+ </header>
113
+ )}
114
+ <div class={s.list({ class: listClass })}>
115
+ {items.map((item, i) => (
116
+ <details class={s.item({ class: itemClass })} name={exclusive ? id : undefined} open={openFirst && i === 0}>
117
+ <summary class={s.summary()}>
118
+ <span class={s.question()}>{item.question}</span>
119
+ {icon === 'chevron'
120
+ ? <svg class={s.marker()} viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 8 5 5 5-5" /></svg>
121
+ : <svg class={s.marker()} viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="M10 4v12M4 10h12" /></svg>}
122
+ </summary>
123
+ <div class={s.answer()} set:html={item.answer} />
124
+ </details>
125
+ ))}
126
+ </div>
84
127
  </div>
85
128
  {structured && <script type="application/ld+json" set:html={structured} />}
86
129
  </Section>
@@ -40,5 +40,80 @@
40
40
  {
41
41
  "name": "lines-bold",
42
42
  "props": { "heading": "Questions", "questionStyle": "bold", "id": "faq-bold", "items": [{ "question": "Do you ship printed guides abroad?", "answer": "<p>Yes, to every country in the EU and the UK.</p>" }, { "question": "Can I change my route later?", "answer": "<p>Any time before the trip starts.</p>" }] }
43
+ },
44
+ {
45
+ "name": "split-chevron",
46
+ "props": {
47
+ "layout": "split",
48
+ "icon": "chevron",
49
+ "heading": "Questions",
50
+ "intro": "Everything you need before you start.",
51
+ "id": "faq-split",
52
+ "items": [
53
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
54
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
55
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" }
56
+ ]
57
+ }
58
+ },
59
+ {
60
+ "name": "two-columns-numbered",
61
+ "props": {
62
+ "columns": "2",
63
+ "numbered": true,
64
+ "icon": "chevron",
65
+ "id": "faq-cols",
66
+ "items": [
67
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
68
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
69
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" },
70
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
71
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
72
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" }
73
+ ],
74
+ "heading": "Common questions"
75
+ }
76
+ },
77
+ {
78
+ "name": "split-two-cards-plus",
79
+ "props": {
80
+ "layout": "split",
81
+ "columns": "2",
82
+ "style": "cards",
83
+ "icon": "plus",
84
+ "id": "faq-split-cols",
85
+ "heading": "Help",
86
+ "intro": "Short answers.",
87
+ "items": [
88
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
89
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
90
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" },
91
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
92
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
93
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" }
94
+ ]
95
+ }
96
+ },
97
+ {
98
+ "name": "measured-frame",
99
+ "props": {
100
+ "heading": "Frequently asked questions",
101
+ "exclusive": true,
102
+ "openFirst": true,
103
+ "schema": true,
104
+ "items": [
105
+ { "question": "Do you accept guest essays?", "answer": "<p>Yes — we publish reader essays every season. Send a <a href=\"/pitch/\">short pitch</a> first.</p>" },
106
+ { "question": "Can I reuse your photographs?", "answer": "<p>Only with written permission. Most are licensed from the photographers, not owned by us.</p>" },
107
+ { "question": "How often do you publish?", "answer": "<p>Two or three pieces a month, plus the seasonal issue.</p><ul><li>Guides: monthly</li><li>Essays: seasonal</li></ul>" }
108
+ ],
109
+ "measured": {
110
+ "containerPx": 720,
111
+ "padY": 64,
112
+ "bg": {
113
+ "color": "#111827"
114
+ },
115
+ "tone": "dark"
116
+ }
117
+ }
43
118
  }
44
119
  ]
@@ -4,8 +4,11 @@
4
4
  "description": "Questions and answers as native <details> disclosures (no script), in lines or cards; optionally one open at a time, and optionally with FAQPage structured data for search engines and AI answers.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Section.astro"],
7
+ "shared": ["Section.astro", "measured.ts", "types.ts"],
8
8
  "variants": {
9
+ "layout": { "options": ["single", "split"], "default": "single", "description": "single: the heading above one column. split: the heading and introduction stay in a left column beside the list; stacks on a phone." },
10
+ "columns": { "options": ["1", "2"], "default": "1", "description": "Columns of questions on wide screens." },
11
+ "icon": { "options": ["plus", "chevron"], "default": "plus", "description": "The marker in each question's row: a plus that turns into a cross, or a chevron that turns over." },
9
12
  "style": { "options": ["lines", "cards", "plain"], "default": "lines", "description": "lines: rows divided by rules. cards: each question in a rounded box. plain: the browser's own disclosure triangle without rules or boxes, questions at the body size (WordPress's details block)." },
10
13
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." },
11
14
  "questionStyle": { "options": ["heading", "bold"], "default": "heading", "description": "How the questions read. heading: a small heading (lg, heading weight). bold: body size in bold, as accordion and toggle titles often are. The plain style sets its own and ignores this." }
@@ -13,10 +16,13 @@
13
16
  "props": {
14
17
  "items": { "type": "array", "label": "Questions", "required": true, "items": { "type": "object", "fields": { "question": { "type": "string", "label": "Question", "required": true, "description": "The question, as the visitor would ask it." }, "answer": { "type": "richtext", "label": "Answer", "required": true, "description": "The answer; links and lists allowed." } } }, "description": "The questions in order (core/details summary + content, Elementor accordion/toggle tabs, Divi accordion/toggle items)." },
15
18
  "heading": { "type": "string", "label": "Heading", "description": "Section heading." },
19
+ "intro": { "type": "text", "label": "Introduction", "description": "A sentence under the heading." },
16
20
  "exclusive": { "type": "boolean", "default": false, "content": false, "description": "One answer open at a time (Elementor accordion, Divi accordion). Off for toggles." },
17
21
  "openFirst": { "type": "boolean", "default": false, "content": false, "description": "The first answer starts open (Divi open_toggle, Elementor active item 1)." },
18
22
  "schema": { "type": "boolean", "default": false, "content": false, "description": "Emit FAQPage JSON-LD built from these items. Use on at most one FAQ per page, and only when the page is really a FAQ." },
19
- "id": { "type": "string", "content": false, "description": "Section id; also the exclusive group's name." }
23
+ "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." } } },
24
+ "id": { "type": "string", "content": false, "description": "Section id; also the exclusive group's name." },
25
+ "numbered": { "type": "boolean", "default": false, "content": false, "description": "Count the questions with a leading number, drawn by the page's style rather than written into the text." }
20
26
  },
21
27
  "slots": {},
22
28
  "js": "none",
@@ -3,6 +3,8 @@
3
3
  // the feature's icon or a shared check, under an optional heading and intro.
4
4
  import { tv } from 'tailwind-variants'
5
5
  import Section, { type Tone } from '../_shared/Section.astro'
6
+ import { measuredGrid } from '../_shared/measured'
7
+ import type { MeasuredStyle } from '../_shared/types'
6
8
 
7
9
  const list = tv({
8
10
  slots: {
@@ -26,8 +28,10 @@ const list = tv({
26
28
  },
27
29
  // On accent the mark takes the ink colour, and text is never dimmed.
28
30
  tone: { default: {}, muted: {}, inverse: {}, accent: { mark: 'text-current', text: 'opacity-100' } },
31
+ // A list inside another section's column: one column, tighter, no section around it.
32
+ bare: { true: { grid: 'sm:grid-cols-1 gap-y-5' }, false: {} },
29
33
  },
30
- defaultVariants: { columns: '3', style: 'icon', tone: 'default' },
34
+ defaultVariants: { columns: '3', style: 'icon', tone: 'default', bare: false },
31
35
  })
32
36
 
33
37
  // 24×24 stroke paths (Lucide-style); unknown names fall back to check.
@@ -57,24 +61,47 @@ interface Props {
57
61
  columns?: '2' | '3' | '4' | undefined
58
62
  style?: 'icon' | 'check' | 'list' | undefined
59
63
  tone?: Tone | undefined
64
+ /** Only the list, with no section of its own, for use inside another section's column. */
65
+ bare?: boolean | undefined
66
+ /** The level of the item titles when `bare` (the section's heading level plus one). */
67
+ titleLevel?: 3 | 4 | 5 | undefined
60
68
  id?: string | undefined
69
+ /** 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. */
70
+ measured?: MeasuredStyle | undefined
61
71
  }
62
72
 
63
- const { heading, intro, items, columns, style = 'icon', tone, id } = Astro.props
64
- const s = list({ columns, style, tone })
73
+ const { heading, intro, items, columns, style = 'icon', tone, bare = false, titleLevel = 3, id, measured } = Astro.props
74
+ const s = list({ columns, style, tone, bare })
65
75
  const headingId = heading && id ? `${id}-title` : undefined
66
76
  const pathOf = (item: Item) => (style !== 'check' && item.icon && icons[item.icon]) || icons.check
67
77
  // 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'
78
+ const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : heading ? 'h3' : 'h2'
69
79
  ---
70
- <Section tone={tone} id={id} aria-labelledby={headingId}>
80
+ {bare
81
+ ? (
82
+ <ul class={s.grid({ class: measuredGrid(measured) })}>
83
+ {items.map(item => (
84
+ <li class={s.item()}>
85
+ <span class={s.mark()} aria-hidden="true">
86
+ <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>
87
+ </span>
88
+ <div>
89
+ <Title class={s.title()}>{item.title}</Title>
90
+ {item.text && <p class={s.text()} data-kit-text>{item.text}</p>}
91
+ </div>
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ )
96
+ : (
97
+ <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
71
98
  {(heading || intro) && (
72
99
  <header class="mb-10 flex max-w-2xl flex-col gap-3">
73
100
  {heading && <h2 id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{heading}</h2>}
74
101
  {intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']} data-kit-text>{intro}</p>}
75
102
  </header>
76
103
  )}
77
- <ul class={s.grid()}>
104
+ <ul class={s.grid({ class: measuredGrid(measured) })}>
78
105
  {items.map(item => (
79
106
  <li class={s.item()}>
80
107
  <span class={s.mark()} aria-hidden="true">
@@ -88,3 +115,4 @@ const Title = style === 'list' ? 'span' : heading ? 'h3' : 'h2'
88
115
  ))}
89
116
  </ul>
90
117
  </Section>
118
+ )}
@@ -150,5 +150,6 @@
150
150
  ],
151
151
  "style": "list"
152
152
  }
153
- }
153
+ },
154
+ {"name": "measured-gap", "props": {"heading": "Why us", "measured": {"columns": {"390": 1, "768": 2, "1280": 3}, "gap": 16}, "items": [{"title": "Fast", "text": "Static HTML."}, {"title": "Kept", "text": "Every address survives."}, {"title": "Editable", "text": "Content in Studio."}]}}
154
155
  ]