@contentrain/astro-kit 0.33.1 → 0.34.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.
package/README.md CHANGED
@@ -17,7 +17,7 @@ They read its design tokens and pass its gates: `astro check` with the
17
17
  | `header` | Brand, navigation, optional call to action | none |
18
18
  | `nav` | Inline menu with submenus; popover drawer or `<details>` on phones | none |
19
19
  | `footer` | Brand, link columns or a link row, social links, copyright | none |
20
- | `hero` | Page opener: heading, text, actions, image | none |
20
+ | `hero` | Page opener: heading, text, points, actions, image | none |
21
21
  | `card-grid` | Grid of cards (features, services, team) | none |
22
22
  | `post-card` | One post in a list | none |
23
23
  | `cta` | Call-to-action band | none |
package/catalog.json CHANGED
@@ -611,6 +611,11 @@
611
611
  "label": "Link",
612
612
  "description": "Makes the whole card a link."
613
613
  },
614
+ "linkLabel": {
615
+ "type": "string",
616
+ "label": "Link text",
617
+ "description": "The link's own text, shown under the card (\"Get a quote\"); without it the whole card is the link."
618
+ },
614
619
  "image": {
615
620
  "type": "image",
616
621
  "label": "Image",
@@ -783,6 +788,7 @@
783
788
  "items[].title": "items[].title",
784
789
  "items[].text": "items[].text",
785
790
  "items[].href": "items[].href",
791
+ "items[].linkLabel": "items[].link_label",
786
792
  "items[].image": "items[].image",
787
793
  "items[].imageAlt": "items[].image_alt",
788
794
  "items[].eyebrow": "items[].eyebrow"
@@ -2672,7 +2678,7 @@
2672
2678
  },
2673
2679
  "tagline": {
2674
2680
  "type": "string",
2675
- "description": "One line under the name (site.tagline)."
2681
+ "description": "A line under the name (site.tagline, or the source footer's about text); line breaks are kept, so a credit under it stays its own line."
2676
2682
  },
2677
2683
  "columns": {
2678
2684
  "type": "array",
@@ -2743,6 +2749,29 @@
2743
2749
  "email": {
2744
2750
  "type": "email",
2745
2751
  "description": "Email address, linked as mailto:."
2752
+ },
2753
+ "labels": {
2754
+ "type": "object",
2755
+ "description": "The words the source prints before each detail (\"Telefon:\", \"Email:\"); each optional.",
2756
+ "fields": {
2757
+ "address": {
2758
+ "type": "string",
2759
+ "description": "Before the address."
2760
+ },
2761
+ "phone": {
2762
+ "type": "string",
2763
+ "description": "Before the phone number."
2764
+ },
2765
+ "email": {
2766
+ "type": "string",
2767
+ "description": "Before the email address."
2768
+ }
2769
+ }
2770
+ },
2771
+ "order": {
2772
+ "type": "array",
2773
+ "items": "string",
2774
+ "description": "The details' order as the source prints them: address, phone and email, each once; absent, that order. A detail left out is printed after the listed ones."
2746
2775
  }
2747
2776
  },
2748
2777
  "description": "Contact details printed in an address block under the tagline; the block is left out when none is set."
@@ -3439,6 +3468,27 @@
3439
3468
  "label": "Lead",
3440
3469
  "description": "Paragraph under the heading."
3441
3470
  },
3471
+ "items": {
3472
+ "type": "array",
3473
+ "label": "Points",
3474
+ "items": {
3475
+ "type": "object",
3476
+ "fields": {
3477
+ "icon": {
3478
+ "type": "image",
3479
+ "label": "Icon",
3480
+ "description": "A small image before the text (decoration)."
3481
+ },
3482
+ "title": {
3483
+ "type": "string",
3484
+ "label": "Text",
3485
+ "required": true,
3486
+ "description": "A short promise or feature (\"Fast, easy and painless\")."
3487
+ }
3488
+ }
3489
+ },
3490
+ "description": "Short points under the lead, before the buttons, in order: a hero's feature or promise list."
3491
+ },
3442
3492
  "actions": {
3443
3493
  "type": "array",
3444
3494
  "label": "Buttons",
@@ -3652,6 +3702,9 @@
3652
3702
  "heading": "heading",
3653
3703
  "eyebrow": "eyebrow",
3654
3704
  "lead": "lead",
3705
+ "items": "items",
3706
+ "items[].icon": "items[].icon",
3707
+ "items[].title": "items[].title",
3655
3708
  "actions": "actions",
3656
3709
  "actions[].label": "actions[].label",
3657
3710
  "actions[].href": "actions[].href",
@@ -5969,7 +6022,6 @@
5969
6022
  "heading": {
5970
6023
  "type": "string",
5971
6024
  "label": "Heading",
5972
- "required": true,
5973
6025
  "description": "The section heading."
5974
6026
  },
5975
6027
  "headingLevel": {
@@ -37,6 +37,8 @@ interface Item {
37
37
  title: string
38
38
  text?: string | undefined
39
39
  href?: string | undefined
40
+ /** The link's own text, shown under the card ("Get a quote"); without it the whole card is the link. */
41
+ linkLabel?: string | undefined
40
42
  /** The image's address (or, until 0.7, an `ImageInput`). */
41
43
  image?: ImageSource | undefined
42
44
  imageAlt?: string | undefined
@@ -79,11 +81,12 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
79
81
  {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>}
80
82
  {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
81
83
  <CardHeading class={cardTitle()}>
82
- {item.href
84
+ {item.href && !item.linkLabel
83
85
  ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
84
86
  : item.title}
85
87
  </CardHeading>
86
88
  {item.text && <p class={text({ class: dim })} data-kit-text>{item.text}</p>}
89
+ {item.href && item.linkLabel && <a href={safeHref(item.href)} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
87
90
  </li>
88
91
  ))}
89
92
  </ul>
@@ -3,5 +3,6 @@
3
3
  { "name": "two-bordered-center", "props": { "columns": "2", "style": "bordered", "align": "center", "tone": "muted", "title": "Plans", "items": [{ "eyebrow": "Starter", "title": "One site", "text": "Everything you need for a single site." }, { "eyebrow": "Team", "title": "Many sites", "text": "Shared components across a portfolio." }] } },
4
4
  { "name": "four-elevated-inverse", "props": { "columns": "4", "style": "elevated", "tone": "inverse", "headingLevel": 3, "title": "Team", "items": [{ "title": "Mira Kovač", "text": "Editor", "image": { "src": "/kit-fixtures/avatar-1.svg", "alt": "Mira Kovač", "width": 96, "height": 96 } }, { "title": "Jon Ade", "text": "Engineer", "image": { "src": "/kit-fixtures/avatar-2.svg", "alt": "Jon Ade", "width": 96, "height": 96 } }, { "title": "Lea Park", "text": "Designer", "image": { "src": "/kit-fixtures/avatar-3.svg", "alt": "Lea Park", "width": 96, "height": 96 } }, { "title": "Sam Ortiz", "text": "Producer" }] } },
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
- {"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."}]}}
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
+ {"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"}]}}
7
8
  ]
@@ -84,6 +84,11 @@
84
84
  "label": "Link",
85
85
  "description": "Makes the whole card a link."
86
86
  },
87
+ "linkLabel": {
88
+ "type": "string",
89
+ "label": "Link text",
90
+ "description": "The link's own text, shown under the card (\"Get a quote\"); without it the whole card is the link."
91
+ },
87
92
  "image": {
88
93
  "type": "image",
89
94
  "label": "Image",
@@ -3,7 +3,7 @@
3
3
  // links and the copyright line.
4
4
  import { tv } from 'tailwind-variants'
5
5
  import type { LinkInput } from '../_shared/types'
6
- import { footerLinkSizeClasses, footerVars } from './footer'
6
+ import { contactKeys, footerLinkSizeClasses, footerVars, type ContactInput } from './footer'
7
7
  import { safeHref } from '../_shared/safe-url'
8
8
 
9
9
  const footer = tv({
@@ -50,8 +50,12 @@ interface Props {
50
50
  /** A single row of links (the footer menu). */
51
51
  links?: LinkInput[] | undefined
52
52
  social?: LinkInput[] | undefined
53
- /** Contact details printed in an `<address>` block; each is optional, and the block is left out when none is set. */
54
- contact?: { address?: string | undefined, phone?: string | undefined, email?: string | undefined } | undefined
53
+ /**
54
+ * Contact details printed in an `<address>` block; each is optional, and the block is left out when none is set.
55
+ * `labels`: the words the source prints before a detail ("Telefon:", "Email:"), each optional. `order`: the details'
56
+ * order as the source prints them; absent, address, phone, email. A detail the order leaves out is printed after it.
57
+ */
58
+ contact?: ContactInput | undefined
55
59
  /** The copyright line, e.g. "© 2026 Northwind Studio"; `{year}` is the current year; `false` prints none. Absent: "© year Site". */
56
60
  copyright?: string | false | undefined
57
61
  /** 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. */
@@ -84,6 +88,7 @@ const vars = footerVars({ layout, columnCount: columns.length, pack, narrow, pac
84
88
  const year = String(new Date().getFullYear())
85
89
  const phoneHref = contact?.phone?.replace(/[^\d+]/g, '')
86
90
  const hasContact = Boolean(contact?.address || contact?.phone || contact?.email)
91
+ const contactOrder = contactKeys(contact?.order)
87
92
  const line = copyright === false ? undefined : copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
88
93
  // The line and the cookie settings control share one paragraph; neither: no paragraph.
89
94
  const hasBottom = Boolean(line || consentLabel)
@@ -93,12 +98,15 @@ const hasBottom = Boolean(line || consentLabel)
93
98
  <div class:list={[inner(), ...footerLinkSizeClasses({ linkSize, linkSizeNarrow })]} data-kit-footer style={vars || undefined}>
94
99
  <div class="flex flex-col gap-3">
95
100
  <p class="text-base font-semibold" data-kit-brand>{siteName}</p>
96
- {tagline && <p class={muted()} data-kit-tagline>{tagline}</p>}
101
+ {/* Line breaks are kept: an about line and the designer's credit under it ("Web Tasarım: …") stay two lines. */}
102
+ {tagline && <p class={`${muted()} whitespace-pre-line`} data-kit-tagline>{tagline}</p>}
97
103
  {hasContact && (
98
104
  <address class={`${muted()} flex flex-col gap-1 not-italic`} data-kit-contact>
99
- {contact?.address && <span class="whitespace-pre-line">{contact.address}</span>}
100
- {contact?.phone && (phoneHref ? <a href={`tel:${phoneHref}`} class={link()}>{contact.phone}</a> : <span>{contact.phone}</span>)}
101
- {contact?.email && <a href={`mailto:${contact.email}`} class={link()}>{contact.email}</a>}
105
+ {contactOrder.map(key => key === 'address'
106
+ ? contact?.address && <span class="whitespace-pre-line">{contact.labels?.address && <>{contact.labels.address} </>}{contact.address}</span>
107
+ : key === 'phone'
108
+ ? contact?.phone && <span>{contact.labels?.phone && <>{contact.labels.phone} </>}{phoneHref ? <a href={`tel:${phoneHref}`} class={link()}>{contact.phone}</a> : contact.phone}</span>
109
+ : contact?.email && <span>{contact.labels?.email && <>{contact.labels.email} </>}<a href={`mailto:${contact.email}`} class={link()}>{contact.email}</a></span>)}
102
110
  </address>
103
111
  )}
104
112
  {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>}
@@ -7,5 +7,6 @@
7
7
  { "name": "columns-end", "props": { "siteName": "Golden Works", "tagline": "Websites for makers", "layout": "columns", "pack": "end", "packGap": "128px", "linkSize": "17.88px", "copyright": "© 2026 Golden Works", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }, { "label": "Press", "href": "/press/" }] }] } },
8
8
  { "name": "columns-end-row", "props": { "siteName": "Golden Works", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "48px", "linkSize": "17.88px", "copyright": "© 2026 Golden Works", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
9
9
  { "name": "columns-end-row-narrow-size", "props": { "siteName": "Golden Works", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "48px", "linkSize": "17.88px", "linkSizeNarrow": "16.4px", "copyright": "© 2026 Golden Works", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
10
- { "name": "columns-end-row-long-brand", "props": { "siteName": "Golden Works Engineering Consultancy", "tagline": "Websites, systems and signalling for the people who keep cities moving every single day", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "70px", "linkSize": "17.88px", "linkSizeNarrow": "16.14px", "copyright": "© 2026 Golden Works Engineering Consultancy, all rights reserved", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } }
10
+ { "name": "columns-end-row-long-brand", "props": { "siteName": "Golden Works Engineering Consultancy", "tagline": "Websites, systems and signalling for the people who keep cities moving every single day", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "70px", "linkSize": "17.88px", "linkSizeNarrow": "16.14px", "copyright": "© 2026 Golden Works Engineering Consultancy, all rights reserved", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
11
+ { "name": "contact-labels", "props": { "siteName": "Private Sigorta", "tagline": "Private Sigorta, yenilikçi yönetim ve ileri teknolojiyi geçmişin tecrübeleriyle birleştiren bir acentedir. Web Tasarım: Farkol Digital", "contact": { "address": "G.O.P. Mah. Barbaros Cad. No: 2 D: 403 Çerkezköy/Tekirdağ", "phone": "+90 (282) 725 1400", "email": "info@privatesigorta.com.tr", "labels": { "email": "Email:", "phone": "Telefon:" }, "order": ["email", "phone", "address"] }, "links": [{ "label": "Hakkımızda", "href": "/hakkimizda/" }, { "label": "İletişim", "href": "/iletisim/" }] } }
11
12
  ]
@@ -1,3 +1,22 @@
1
+ export type ContactKey = 'address' | 'phone' | 'email'
2
+
3
+ /** The footer's contact details, with the words the source prints before each and the order it prints them in. */
4
+ export interface ContactInput {
5
+ address?: string | undefined
6
+ phone?: string | undefined
7
+ email?: string | undefined
8
+ labels?: { address?: string | undefined, phone?: string | undefined, email?: string | undefined } | undefined
9
+ order?: readonly ContactKey[] | undefined
10
+ }
11
+
12
+ const CONTACT_KEYS: readonly ContactKey[] = ['address', 'phone', 'email']
13
+
14
+ /** The details in the order given (unknown and repeated keys dropped), then the ones it left out in the default order. */
15
+ export function contactKeys(order: readonly string[] | undefined): ContactKey[] {
16
+ const given = [...new Set((order ?? []).filter((key): key is ContactKey => (CONTACT_KEYS as readonly string[]).includes(key)))]
17
+ return [...given, ...CONTACT_KEYS.filter(key => !given.includes(key))]
18
+ }
19
+
1
20
  export interface FooterVarsInput {
2
21
  layout: 'simple' | 'columns'
3
22
  columnCount: number
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "tagline": {
61
61
  "type": "string",
62
- "description": "One line under the name (site.tagline)."
62
+ "description": "A line under the name (site.tagline, or the source footer's about text); line breaks are kept, so a credit under it stays its own line."
63
63
  },
64
64
  "columns": {
65
65
  "type": "array",
@@ -121,7 +121,17 @@
121
121
  "fields": {
122
122
  "address": { "type": "text", "description": "Postal address; line breaks are kept." },
123
123
  "phone": { "type": "string", "description": "Phone number, linked as tel: when it has digits." },
124
- "email": { "type": "email", "description": "Email address, linked as mailto:." }
124
+ "email": { "type": "email", "description": "Email address, linked as mailto:." },
125
+ "labels": {
126
+ "type": "object",
127
+ "description": "The words the source prints before each detail (\"Telefon:\", \"Email:\"); each optional.",
128
+ "fields": {
129
+ "address": { "type": "string", "description": "Before the address." },
130
+ "phone": { "type": "string", "description": "Before the phone number." },
131
+ "email": { "type": "string", "description": "Before the email address." }
132
+ }
133
+ },
134
+ "order": { "type": "array", "items": "string", "description": "The details' order as the source prints them: address, phone and email, each once; absent, that order. A detail left out is printed after the listed ones." }
125
135
  },
126
136
  "description": "Contact details printed in an address block under the tagline; the block is left out when none is set."
127
137
  },
@@ -1,5 +1,5 @@
1
1
  ---
2
- // The page's opening section: heading, lead, actions and an image — beside the
2
+ // The page's opening section: heading, lead, points, actions and an image — beside the
3
3
  // text, below it, or behind it as a cover with the text over a scrim.
4
4
  import { tv } from 'tailwind-variants'
5
5
  import Button from '../_shared/Button.astro'
@@ -16,13 +16,16 @@ const hero = tv({
16
16
  eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
17
17
  heading: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-5xl lg:text-6xl',
18
18
  lead: 'max-w-2xl text-lg text-pretty opacity-85 md:text-xl',
19
+ // A hero's short promise list ("Fast, easy and painless"), each with its icon when it has one.
20
+ points: 'flex flex-wrap gap-x-6 gap-y-2 text-base font-medium md:text-lg',
21
+ point: 'flex items-center gap-2',
19
22
  actions: 'flex flex-wrap gap-3 pt-2',
20
23
  media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
21
24
  },
22
25
  variants: {
23
26
  layout: {
24
27
  split: { grid: 'md:grid-cols-2' },
25
- centered: { copy: 'items-center text-center', lead: 'mx-auto', actions: 'justify-center', media: 'mx-auto w-full max-w-4xl' },
28
+ centered: { copy: 'items-center text-center', lead: 'mx-auto', points: 'justify-center', actions: 'justify-center', media: 'mx-auto w-full max-w-4xl' },
26
29
  cover: { grid: 'relative z-10 min-h-[28rem] md:min-h-[36rem]', copy: 'max-w-3xl' },
27
30
  },
28
31
  // 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.
@@ -46,7 +49,7 @@ const hero = tv({
46
49
  compoundVariants: [
47
50
  // After the layout's own heights, so a boxed cover keeps WordPress's 430px default.
48
51
  { layout: 'cover', width: 'content', class: { grid: 'min-h-[20rem] md:min-h-[26.875rem]' } },
49
- { layout: 'cover', align: 'center', class: { grid: 'justify-items-center', copy: 'mx-auto items-center text-center', lead: 'mx-auto', actions: 'justify-center' } },
52
+ { layout: 'cover', align: 'center', class: { grid: 'justify-items-center', copy: 'mx-auto items-center text-center', lead: 'mx-auto', points: 'justify-center', actions: 'justify-center' } },
50
53
  // After the centring rules, so the lead leaves them.
51
54
  { leadAlign: 'start', class: { lead: 'mx-0 max-w-none self-stretch text-start' } },
52
55
  { actionsAlign: 'start', class: { actions: 'justify-start self-stretch' } },
@@ -60,6 +63,8 @@ interface Props {
60
63
  level?: 1 | 2 | undefined
61
64
  eyebrow?: string | undefined
62
65
  lead?: string | undefined
66
+ /** Short points under the lead, before the buttons: a promise or feature list, each with an optional icon (an image address). */
67
+ items?: Array<{ title: string, icon?: string | undefined }> | undefined
63
68
  actions?: ActionInput[] | undefined
64
69
  image?: ImageInput | undefined
65
70
  layout?: 'split' | 'centered' | 'cover' | undefined
@@ -84,7 +89,7 @@ interface Props {
84
89
  }
85
90
 
86
91
 
87
- const { heading, level = 1, eyebrow, lead, actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id, minHeight, headingScale, headingSize, measured } = Astro.props
92
+ const { heading, level = 1, eyebrow, lead, items = [], actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id, minHeight, headingScale, headingSize, measured } = Astro.props
88
93
  const s = hero({ layout, align, leadAlign, actionsAlign, width, tone, headingScale, headingSize })
89
94
  const Heading = level === 1 ? 'h1' : 'h2'
90
95
  const headingId = id ? `${id}-title` : undefined
@@ -107,6 +112,7 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
107
112
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
108
113
  <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
109
114
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
115
+ {items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
110
116
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
111
117
  </div>
112
118
  </div>
@@ -125,6 +131,7 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
125
131
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
126
132
  <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
127
133
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
134
+ {items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
128
135
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
129
136
  </div>
130
137
  </div>
@@ -139,6 +146,7 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
139
146
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
140
147
  <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
141
148
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
149
+ {items.length > 0 && <ul class={s.points()}>{items.map(item => <li class={s.point()}>{item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-6 w-6 shrink-0 object-contain" />}<span>{item.title}</span></li>)}</ul>}
142
150
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
143
151
  </div>
144
152
  {image && (
@@ -240,5 +240,6 @@
240
240
  "headingSize": "h2"
241
241
  }
242
242
  },
243
- {"name": "cover-measured-overlay", "props": {"heading": "Fresh eggs, every morning", "lead": "A family farm since 1962.", "layout": "cover", "image": {"src": "/kit-fixtures/photo-4.svg", "alt": "", "width": 1600, "height": 900}, "actions": [{"label": "Visit us", "href": "/visit/"}], "measured": {"padY": 140, "minHeight": 560, "tone": "dark", "bg": {"image": "/kit-fixtures/photo-4.svg", "overlay": "rgba(20, 40, 10, 0.45)"}}}}
243
+ {"name": "cover-measured-overlay", "props": {"heading": "Fresh eggs, every morning", "lead": "A family farm since 1962.", "layout": "cover", "image": {"src": "/kit-fixtures/photo-4.svg", "alt": "", "width": 1600, "height": 900}, "actions": [{"label": "Visit us", "href": "/visit/"}], "measured": {"padY": 140, "minHeight": 560, "tone": "dark", "bg": {"image": "/kit-fixtures/photo-4.svg", "overlay": "rgba(20, 40, 10, 0.45)"}}}},
244
+ {"name": "points", "props": {"heading": "Private Sigorta'ya Hoşgeldiniz!", "items": [{"icon": "/kit-fixtures/logo.svg", "title": "Hızlı, Kolay & Sorunsuz"}, {"title": "En Uygun Fiyatlar"}], "actions": [{"label": "Ürünlerimiz", "href": "/urunlerimiz/"}], "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}}}
244
245
  ]
@@ -109,6 +109,27 @@
109
109
  "label": "Lead",
110
110
  "description": "Paragraph under the heading."
111
111
  },
112
+ "items": {
113
+ "type": "array",
114
+ "label": "Points",
115
+ "items": {
116
+ "type": "object",
117
+ "fields": {
118
+ "icon": {
119
+ "type": "image",
120
+ "label": "Icon",
121
+ "description": "A small image before the text (decoration)."
122
+ },
123
+ "title": {
124
+ "type": "string",
125
+ "label": "Text",
126
+ "required": true,
127
+ "description": "A short promise or feature (\"Fast, easy and painless\")."
128
+ }
129
+ }
130
+ },
131
+ "description": "Short points under the lead, before the buttons, in order: a hero's feature or promise list."
132
+ },
112
133
  "actions": {
113
134
  "type": "array",
114
135
  "label": "Buttons",
@@ -67,7 +67,8 @@ interface Feature {
67
67
  }
68
68
 
69
69
  interface Props {
70
- heading: string
70
+ /** The section heading; absent when the source's text and image stand without one. */
71
+ heading?: string | undefined
71
72
  /** The heading's level: 2 under the page title; 3 or 4 when the section sits inside another. */
72
73
  headingLevel?: 2 | 3 | 4 | undefined
73
74
  eyebrow?: string | undefined
@@ -103,14 +104,14 @@ const Heading = `h${headingLevel}` as 'h2'
103
104
  const columns = COLUMNS[imageSide === 'start' ? 'image' : 'text'][ratio]
104
105
  const bleed = imageBleed === 'edge' ? BLEED[imageSide === 'start' ? 'start' : 'end'] : undefined
105
106
  const video = mediaKind === 'video' && videoUrl
106
- const headingId = id ? `${id}-title` : undefined
107
+ const headingId = id && heading ? `${id}-title` : undefined
107
108
  const html = await renderMarkdown(body)
108
109
  ---
109
110
  <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId} class={bleed ? 'overflow-x-clip' : undefined}>
110
111
  <div class:list={[s.grid(), columns]}>
111
112
  <div class={s.copy()}>
112
113
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
113
- <Heading id={headingId} class={s.heading()}>{heading}</Heading>
114
+ {heading && <Heading id={headingId} class={s.heading()}>{heading}</Heading>}
114
115
  {html && <div class={s.body()} data-kit-text set:html={html} />}
115
116
  {features.length > 0 && <FeatureList items={features} columns="2" style={featureStyle} tone={tone === 'brand' || tone === 'none' ? undefined : tone} bare titleLevel={Math.min(5, headingLevel + 1) as 3 | 4 | 5} />}
116
117
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
@@ -9,5 +9,6 @@
9
9
  { "name": "bleed-edge-end", "props": { "heading": "A wider picture", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "imageBleed": "edge", "image": { "src": "/kit-fixtures/photo-4.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "tone": "brand" } },
10
10
  { "name": "bleed-edge-start-none", "props": { "heading": "From the other side", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "imageBleed": "edge", "imageSide": "start", "image": { "src": "/kit-fixtures/photo-5.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "tone": "none" } },
11
11
  { "name": "video-sticky-check", "props": { "heading": "See how it works", "headingLevel": 3, "mediaKind": "video", "videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "playLabel": "Play video", "sticky": true, "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.\n\nWe have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.\n\nWe have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "features": [{ "title": "Fast", "text": "Static pages, one stylesheet.", "icon": "bolt" }, { "title": "Safe", "text": "Your repository stays yours.", "icon": "shield" }], "image": { "src": "/kit-fixtures/photo-6.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 } } },
12
- { "name": "measured-frame", "props": { "eyebrow": "Our studio", "heading": "Small team, careful work", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "actions": [{ "label": "Meet the team", "href": "/team/" }, { "label": "Our process", "href": "/process/", "style": "ghost" }], "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "measured": { "containerPx": 1000, "padY": 80, "bg": { "color": "#eef2ff" }, "tone": "light" } } }
12
+ { "name": "measured-frame", "props": { "eyebrow": "Our studio", "heading": "Small team, careful work", "body": "We have built websites for independent publishers since 2012.\n\nEvery project gets **one** designer and one engineer from start to launch.", "actions": [{ "label": "Meet the team", "href": "/team/" }, { "label": "Our process", "href": "/process/", "style": "ghost" }], "image": { "src": "/kit-fixtures/photo-2.svg", "alt": "The studio's workshop", "width": 1200, "height": 800 }, "measured": { "containerPx": 1000, "padY": 80, "bg": { "color": "#eef2ff" }, "tone": "light" } } },
13
+ { "name": "no-heading", "props": { "body": "Private Sigorta günümüz gelişen sigortacılık sektörünün bir adım önünde olmayı hedeflemiş bir sigorta acentesidir.\n\nÖzel sağlık sigortası alanında, bireysel ve kurumsal müşterilere yönelik çözümler sunuyoruz.", "image": { "src": "/kit-fixtures/photo-3.svg", "alt": "", "width": 1200, "height": 800 } } }
13
14
  ]
@@ -107,7 +107,6 @@
107
107
  "heading": {
108
108
  "type": "string",
109
109
  "label": "Heading",
110
- "required": true,
111
110
  "description": "The section heading."
112
111
  },
113
112
  "headingLevel": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "license": "MIT",
5
5
  "description": "Copy-in Astro components for Contentrain sites (shadcn model): typed props, tailwind-variants, zero JavaScript by default — with a machine-readable catalog",
6
6
  "type": "module",
@@ -42,7 +42,7 @@
42
42
  "mapping"
43
43
  ],
44
44
  "dependencies": {
45
- "@contentrain/types": "1.60.0"
45
+ "@contentrain/types": "1.61.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.10.0",