@contentrain/astro-kit 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/catalog.json CHANGED
@@ -785,6 +785,14 @@
785
785
  "default": "start",
786
786
  "description": "Text alignment inside cards and of the header."
787
787
  },
788
+ "layout": {
789
+ "options": [
790
+ "grid",
791
+ "rows"
792
+ ],
793
+ "default": "grid",
794
+ "description": "Cards in a grid, or one item per row: title, text and link on one axis, a hairline between rows, no card box (columns and style do not apply)."
795
+ },
788
796
  "tone": {
789
797
  "options": [
790
798
  "default",
@@ -859,6 +867,12 @@
859
867
  },
860
868
  "description": "The cards. image is an ImageInput ({ src, alt, width?, height? })."
861
869
  },
870
+ "index": {
871
+ "type": "boolean",
872
+ "default": false,
873
+ "content": false,
874
+ "description": "With layout rows: a numbered list, each row led by its number (01, 02, …) in the muted role."
875
+ },
862
876
  "headingLevel": {
863
877
  "type": "integer",
864
878
  "min": 2,
@@ -3652,7 +3666,9 @@
3652
3666
  "shared": [
3653
3667
  "Button.astro",
3654
3668
  "KitImage.astro",
3669
+ "Lines.astro",
3655
3670
  "Section.astro",
3671
+ "bleed.ts",
3656
3672
  "instance-id.ts",
3657
3673
  "measured.ts",
3658
3674
  "muted.ts",
@@ -3727,6 +3743,31 @@
3727
3743
  ],
3728
3744
  "default": "own",
3729
3745
  "description": "The level whose site size the heading takes when it is not the level it is written at: h1 on an h2 heading keeps a source's second h1 at the h1 size under a page that keeps one h1. own: the heading's own level under headingScale theme, nothing otherwise. Any other value than own sets the size by itself."
3746
+ },
3747
+ "headingLines": {
3748
+ "options": [
3749
+ "flow",
3750
+ "lines"
3751
+ ],
3752
+ "default": "flow",
3753
+ "description": "flow: the heading is one run of text. lines: each line the author broke it into (a line break, \\n, in the heading) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one heading, read as one sentence."
3754
+ },
3755
+ "stackOrder": {
3756
+ "options": [
3757
+ "text-first",
3758
+ "media-first"
3759
+ ],
3760
+ "default": "text-first",
3761
+ "description": "Split and centered: the image before the text, on phones and on the start side (split), or above the heading (centered). A cover ignores it."
3762
+ },
3763
+ "imageBleed": {
3764
+ "options": [
3765
+ "none",
3766
+ "edge",
3767
+ "overlap"
3768
+ ],
3769
+ "default": "none",
3770
+ "description": "Split and centered: edge runs the image on to the window's edge on its side (both sides when centered); overlap also lets it cross the section's bottom edge by --hero-overlap (3rem), over the next section's top padding, when the image comes last: with stackOrder media-first it draws as edge, so it never covers the text. A cover ignores it."
3730
3771
  }
3731
3772
  },
3732
3773
  "props": {
@@ -6256,6 +6297,7 @@
6256
6297
  "Button.astro",
6257
6298
  "KitImage.astro",
6258
6299
  "Section.astro",
6300
+ "bleed.ts",
6259
6301
  "instance-id.ts",
6260
6302
  "measured.ts",
6261
6303
  "muted.ts",
@@ -6292,10 +6334,13 @@
6292
6334
  "options": [
6293
6335
  "none",
6294
6336
  "rounded",
6295
- "circle"
6337
+ "circle",
6338
+ "arch",
6339
+ "chamfer",
6340
+ "blob"
6296
6341
  ],
6297
6342
  "default": "rounded",
6298
- "description": "The image's corners: square, the theme's rounding, or a circle."
6343
+ "description": "The image's outline: square, the theme's rounding, a circle, or a fixed mask: an arch over the image, a cut corner (chamfer), a soft blob."
6299
6344
  },
6300
6345
  "imageBleed": {
6301
6346
  "options": [
@@ -0,0 +1,12 @@
1
+ ---
2
+ // Text the author broke into lines (`\n`), one block per line, each marked `data-kit-line="<n>"` so a theme or a
3
+ // design layer can give a line its own ink, indent or size. The lines stay one text: a heading made of them is
4
+ // still one heading, read as one sentence (a space between the blocks keeps the words apart when it is read out).
5
+ // Without a line break the text is printed as it is.
6
+ interface Props {
7
+ text: string
8
+ }
9
+
10
+ const lines = Astro.props.text.split('\n').map(line => line.trim()).filter(Boolean)
11
+ ---
12
+ {lines.length > 1 ? lines.map((line, i) => <>{i > 0 && ' '}<span class="block" data-kit-line={i + 1}>{line}</span></>) : Astro.props.text}
@@ -0,0 +1,8 @@
1
+ // The viewport's edge, from the content's edge: what the page frame leaves of the window, halved, plus the frame's own
2
+ // gutter (`container-page` caps its width with the gutter inside it). A section whose image runs on to the window's
3
+ // edge (Split, Hero `imageBleed`) pulls it out by this much on its side, on wide screens. Literal class names, so the
4
+ // stylesheet builder sees them.
5
+ export const BLEED = {
6
+ end: 'md:-me-[calc((100vw-min(var(--container-page)+2*var(--container-inset,0px),100vw))/2+var(--spacing-gutter,2rem))]',
7
+ start: 'md:-ms-[calc((100vw-min(var(--container-page)+2*var(--container-inset,0px),100vw))/2+var(--spacing-gutter,2rem))]',
8
+ } as const
@@ -35,6 +35,29 @@ const grid = tv({
35
35
  defaultVariants: { columns: '3', style: 'plain', align: 'start' },
36
36
  })
37
37
 
38
+ // `layout: 'rows'`: one item per row, its title, text and link on one axis (an image sits at the row's top, out of the
39
+ // baseline: a head led by an icon too, else the icon's foot would be the baseline and push the text down by its
40
+ // height), a hairline between rows and no card box.
41
+ // With `index`, a numbered list whose numbers (01, 02, …) are drawn in the muted role, tabular; the list itself
42
+ // carries the order for screen readers, so the drawn number is hidden from them.
43
+ const rows = tv({
44
+ slots: {
45
+ list: 'flex flex-col divide-y divide-line border-y border-line',
46
+ row: 'group relative grid gap-x-8 gap-y-2 py-6 md:items-baseline',
47
+ number: `text-sm tabular-nums ${muted}`,
48
+ head: 'flex flex-col gap-2',
49
+ end: 'flex flex-col gap-3 md:items-end',
50
+ media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))] md:w-48',
51
+ title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
52
+ text: muted,
53
+ link: 'font-medium text-accent-text underline-offset-4 hover:underline',
54
+ },
55
+ variants: {
56
+ index: { true: { row: 'md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,2fr)_auto]' }, false: { row: 'md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]' } },
57
+ },
58
+ defaultVariants: { index: false },
59
+ })
60
+
38
61
  interface Item {
39
62
  title: string
40
63
  text?: string | undefined
@@ -56,6 +79,10 @@ interface Props {
56
79
  /** Heading level of the section title; card titles are one level below, or at this level when there is no title. */
57
80
  headingLevel?: 2 | 3 | undefined
58
81
  columns?: '2' | '3' | '4' | undefined
82
+ /** `rows`: one item per row (title, text and link on one axis, a hairline between rows, no card box); `columns`, `style` and the measured columns do not apply. `grid` (absent): cards, as before. */
83
+ layout?: 'grid' | 'rows' | undefined
84
+ /** With `layout: 'rows'`: a numbered list, each row led by its number (01, 02, …) in the muted role. */
85
+ index?: boolean | undefined
59
86
  style?: 'plain' | 'bordered' | 'elevated' | undefined
60
87
  align?: 'start' | 'center' | undefined
61
88
  tone?: Tone | undefined
@@ -65,7 +92,7 @@ interface Props {
65
92
  id?: string | undefined
66
93
  }
67
94
 
68
- const { title, intro, items, headingLevel = 2, columns, style, align, tone, measured, id: givenId } = Astro.props
95
+ const { title, intro, items, headingLevel = 2, columns, style, align, tone, measured, id: givenId, layout, index = false } = Astro.props
69
96
  // Every section has an id, so a theme or a design layer can address this one: the page's, or `card-grid`, `card-grid-2`, … in render order.
70
97
  const id = instanceId(Astro.locals, givenId, 'card-grid')
71
98
  const { list, card, media, title: cardTitle, text } = grid({ columns, style, align })
@@ -73,6 +100,9 @@ const Heading = `h${headingLevel}` as 'h2' | 'h3'
73
100
  // One level under the section title, or the title's level when there is none.
74
101
  const CardHeading = `h${title ? headingLevel + 1 : headingLevel}` as 'h2' | 'h3' | 'h4'
75
102
  const headingId = title ? `${id}-title` : undefined
103
+ const r = rows({ index })
104
+ const List = index ? 'ol' : 'ul'
105
+ const number = (i: number) => String(i + 1).padStart(2, '0')
76
106
  ---
77
107
  <Section kit="card-grid" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
78
108
  {(title || intro) && (
@@ -81,20 +111,46 @@ const headingId = title ? `${id}-title` : undefined
81
111
  {intro && <p class:list={['text-lg', muted]}>{intro}</p>}
82
112
  </header>
83
113
  )}
84
- <ul class={list({ class: measuredGrid(measured) })}>
85
- {items.map(item => (
86
- <li class={card()}>
87
- {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>}
88
- {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
89
- {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
90
- <CardHeading class={cardTitle()}>
91
- {item.href && !item.linkLabel
92
- ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
93
- : item.title}
94
- </CardHeading>
95
- {item.text && <p class={text()} data-kit-text>{item.text}</p>}
96
- {item.href && item.linkLabel && <a href={safeHref(item.href)} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
97
- </li>
98
- ))}
99
- </ul>
114
+ {layout === 'rows' ? (
115
+ <List class={r.list()} role="list">
116
+ {items.map((item, i) => (
117
+ <li class={r.row()}>
118
+ {index && <span class={r.number()} aria-hidden="true">{number(i)}</span>}
119
+ <div class={r.head({ class: item.icon ? 'md:self-start' : undefined })}>
120
+ {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-8 w-8 object-contain" />}
121
+ {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
122
+ <CardHeading class={r.title()}>
123
+ {item.href && !item.linkLabel
124
+ ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
125
+ : item.title}
126
+ </CardHeading>
127
+ </div>
128
+ {item.text ? <p class={r.text()} data-kit-text>{item.text}</p> : <span aria-hidden="true" />}
129
+ {(imageOf(item.image, item.imageAlt) || (item.href && item.linkLabel)) && (
130
+ <div class={r.end({ class: imageOf(item.image, item.imageAlt) ? 'md:self-start' : undefined })}>
131
+ {imageOf(item.image, item.imageAlt) && <div class={r.media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 768px) 12rem, 100vw" class="aspect-[3/2] w-full object-cover" /></div>}
132
+ {item.href && item.linkLabel && <a href={safeHref(item.href)} class={r.link()}>{item.linkLabel}</a>}
133
+ </div>
134
+ )}
135
+ </li>
136
+ ))}
137
+ </List>
138
+ ) : (
139
+ <ul class={list({ class: measuredGrid(measured) })}>
140
+ {items.map(item => (
141
+ <li class={card()}>
142
+ {imageOf(item.image, item.imageAlt) && <div class={media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
143
+ {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
144
+ {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
145
+ <CardHeading class={cardTitle()}>
146
+ {item.href && !item.linkLabel
147
+ ? <a href={safeHref(item.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{item.title}</a>
148
+ : item.title}
149
+ </CardHeading>
150
+ {item.text && <p class={text()} data-kit-text>{item.text}</p>}
151
+ {item.href && item.linkLabel && <a href={safeHref(item.href)} class="font-medium text-accent-text underline-offset-4 hover:underline">{item.linkLabel}</a>}
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ )}
100
156
  </Section>
@@ -5,5 +5,8 @@
5
5
  { "name": "accent", "props": { "tone": "accent", "columns": "3", "items": [{ "title": "Fast", "text": "Static HTML, one stylesheet." }, { "title": "Editable", "text": "Content in Contentrain Studio." }, { "title": "Kept", "text": "Every address survives." }] } },
6
6
  {"name": "measured-columns", "props": {"title": "Services", "measured": {"containerPx": 1140, "padY": 80, "columns": {"390": 1, "768": 2, "1280": 4}, "gap": 30, "radius": 4, "bg": {"color": "#f4f1ea"}}, "items": [{"title": "Audits", "text": "Where a site loses readers."}, {"title": "Builds", "text": "Static and fast."}, {"title": "Hosting", "text": "Managed for you."}, {"title": "Care", "text": "Kept up to date."}]}},
7
7
  {"name": "icon-boxes", "props": {"columns": "3", "align": "center", "items": [{"title": "Design", "text": "Layouts built around your products.", "icon": "/kit-fixtures/icon-1.svg"}, {"title": "Build", "text": "Fast pages that stay fast.", "icon": "/kit-fixtures/icon-1.svg"}, {"title": "Grow", "text": "Monthly reports you can read in five minutes.", "icon": "/kit-fixtures/icon-1.svg"}]}},
8
- {"name": "link-labels", "props": {"columns": "4", "align": "center", "items": [{"title": "Kasko", "href": "/urunlerimiz/kasko/", "linkLabel": "Teklif Al"}, {"title": "Trafik Sigortası", "href": "/urunlerimiz/trafik-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Konut Sigortası", "href": "/urunlerimiz/konut-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Tamamlayıcı Sağlık"}]}}
8
+ {"name": "link-labels", "props": {"columns": "4", "align": "center", "items": [{"title": "Kasko", "href": "/urunlerimiz/kasko/", "linkLabel": "Teklif Al"}, {"title": "Trafik Sigortası", "href": "/urunlerimiz/trafik-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Konut Sigortası", "href": "/urunlerimiz/konut-sigortasi/", "linkLabel": "Teklif Al"}, {"title": "Tamamlayıcı Sağlık"}]}},
9
+ {"name": "rows-index", "props": {"layout": "rows", "index": true, "title": "How it works", "intro": "Three steps, in this order.", "items": [{"title": "Discovery", "text": "We read every page of the site you have, and write down what it holds.", "href": "/how/discovery/"}, {"title": "An offer", "text": "One price for the whole move, with what it includes and what it leaves out.", "href": "/how/offer/"}, {"title": "The move", "text": "Content, addresses and forms carried over, each one checked against the original.", "href": "/how/move/"}]}},
10
+ {"name": "rows-links-muted", "props": {"layout": "rows", "tone": "muted", "title": "What we do", "items": [{"eyebrow": "Studio", "title": "Design systems", "text": "Tokens, components and documentation your team keeps using.", "href": "/services/design/", "linkLabel": "Read more", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "", "width": 1200, "height": 800}}, {"eyebrow": "Studio", "title": "Migrations", "text": "WordPress to Astro without losing an address or a ranking.", "href": "/services/migrations/", "linkLabel": "Read more", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "", "width": 1200, "height": 800}}, {"eyebrow": "Studio", "title": "Performance", "href": "/services/performance/", "linkLabel": "Read more"}]}},
11
+ {"name": "rows-icons", "props": {"layout": "rows", "title": "What the move keeps", "items": [{"icon": "/kit-fixtures/icon-1.svg", "title": "Addresses", "text": "Every old address answers, or sends the visitor to its new one.", "href": "/keeps/addresses/"}, {"icon": "/kit-fixtures/icon-1.svg", "eyebrow": "Forms", "title": "Contact forms", "text": "Each form posts where it did, with the same fields.", "href": "/keeps/forms/", "linkLabel": "How"}, {"title": "Rankings", "text": "Titles, descriptions and structured data carried over as they were."}]}}
9
12
  ]
@@ -41,6 +41,14 @@
41
41
  "default": "start",
42
42
  "description": "Text alignment inside cards and of the header."
43
43
  },
44
+ "layout": {
45
+ "options": [
46
+ "grid",
47
+ "rows"
48
+ ],
49
+ "default": "grid",
50
+ "description": "Cards in a grid, or one item per row: title, text and link on one axis, a hairline between rows, no card box (columns and style do not apply)."
51
+ },
44
52
  "tone": {
45
53
  "options": [
46
54
  "default",
@@ -115,6 +123,12 @@
115
123
  },
116
124
  "description": "The cards. image is an ImageInput ({ src, alt, width?, height? })."
117
125
  },
126
+ "index": {
127
+ "type": "boolean",
128
+ "default": false,
129
+ "content": false,
130
+ "description": "With layout rows: a numbered list, each row led by its number (01, 02, …) in the muted role."
131
+ },
118
132
  "headingLevel": {
119
133
  "type": "integer",
120
134
  "min": 2,
@@ -5,11 +5,13 @@ import { tv } from 'tailwind-variants'
5
5
  import Button from '../_shared/Button.astro'
6
6
  import KitImage from '../_shared/KitImage.astro'
7
7
  import Section from '../_shared/Section.astro'
8
+ import Lines from '../_shared/Lines.astro'
8
9
  import { instanceId } from '../_shared/instance-id'
9
10
  import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
10
11
  import { measuredFrame } from '../_shared/measured'
11
12
  import { coverHeight as heightOf, headingStyle as styleOf } from './hero'
12
13
  import { muted, mutedInverse } from '../_shared/muted'
14
+ import { BLEED } from '../_shared/bleed'
13
15
 
14
16
  const hero = tv({
15
17
  slots: {
@@ -47,6 +49,18 @@ const hero = tv({
47
49
  headingScale: { default: {}, theme: {} },
48
50
  // The level whose site size the heading takes when it is not the level it is written at (set inline, see ./hero).
49
51
  headingSize: { own: {}, h1: {}, h2: {} },
52
+ // The heading as the author broke it: `lines` prints each `\n`-separated line as its own block (`data-kit-line`),
53
+ // `flow` (the default) as one run of text.
54
+ headingLines: { flow: {}, lines: {} },
55
+ // Split and centered (a cover ignores both): the image before the text, on phones and on the start side (split),
56
+ // or above the heading (centered).
57
+ stackOrder: { 'text-first': {}, 'media-first': { media: 'order-first' } },
58
+ // edge: the image runs on to the window's edge on its side (wide screens), or across the whole width when centered.
59
+ // overlap: it also crosses the section's bottom edge by `--hero-overlap` (3rem), drawn over the next section's top
60
+ // padding: set at the grid's bottom and moved past the hero's own bottom padding (`spacing="lg"`: 4rem, 7rem from md),
61
+ // not laid out there, so the next section stays where it was. Only an image that comes last can cross the bottom: one
62
+ // that comes first (`media-first`) would be moved down over the text, so there overlap draws as edge.
63
+ imageBleed: { none: {}, edge: { media: 'rounded-none' }, overlap: { media: 'relative z-10 self-end translate-y-[calc(4rem+var(--hero-overlap,3rem))] rounded-none md:translate-y-[calc(7rem+var(--hero-overlap,3rem))]' } },
50
64
  },
51
65
  compoundVariants: [
52
66
  // After the layout's own heights, so a boxed cover keeps WordPress's 430px default.
@@ -56,7 +70,7 @@ const hero = tv({
56
70
  { leadAlign: 'start', class: { lead: 'mx-0 max-w-none self-stretch text-start' } },
57
71
  { actionsAlign: 'start', class: { actions: 'justify-start self-stretch' } },
58
72
  ],
59
- defaultVariants: { layout: 'split', tone: 'default', align: 'center', leadAlign: 'inherit', actionsAlign: 'inherit', width: 'full', headingScale: 'default', headingSize: 'own' },
73
+ defaultVariants: { layout: 'split', tone: 'default', align: 'center', leadAlign: 'inherit', actionsAlign: 'inherit', width: 'full', headingScale: 'default', headingSize: 'own', headingLines: 'flow', stackOrder: 'text-first', imageBleed: 'none' },
60
74
  })
61
75
 
62
76
  interface Props {
@@ -86,15 +100,23 @@ interface Props {
86
100
  headingScale?: 'default' | 'theme' | undefined
87
101
  /** The level whose site size the heading takes, when it is not the level it is written at (`h1` size on an `h2`: a page keeps one `h1`). `own` (absent): the heading's own level under `theme`, as before. */
88
102
  headingSize?: 'own' | 'h1' | 'h2' | undefined
103
+ /** `lines`: each line of the heading (split at `\n`) is a block of its own, `data-kit-line="<n>"`, for a theme to set per line. `flow` (absent): one run of text, as before. */
104
+ headingLines?: 'flow' | 'lines' | undefined
105
+ /** Split and centered: `media-first` puts the image before the text (phones and the start side in split, above the heading in centered). `text-first` (absent): as before. */
106
+ stackOrder?: 'text-first' | 'media-first' | undefined
107
+ /** Split and centered: `edge` runs the image on to the window's edge on its side (both sides when centered); `overlap` also lets it cross the section's bottom edge by `--hero-overlap` (3rem), when the image comes last (with `media-first` it draws as `edge`). `none` (absent): as before. */
108
+ imageBleed?: 'none' | 'edge' | 'overlap' | undefined
89
109
  /** 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. */
90
110
  measured?: MeasuredStyle | undefined
91
111
  }
92
112
 
93
113
 
94
- const { heading, level = 1, eyebrow, lead, items = [], actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id: givenId, minHeight, headingScale, headingSize, measured } = Astro.props
114
+ const { heading, level = 1, eyebrow, lead, items = [], actions = [], image, layout = 'split', align, leadAlign, actionsAlign, width = 'full', tone, id: givenId, minHeight, headingScale, headingSize, headingLines, stackOrder, imageBleed, measured } = Astro.props
95
115
  // Every section has an id, so a theme or a design layer can address this one: the page's, or `hero`, `hero-2`, … in render order.
96
116
  const id = instanceId(Astro.locals, givenId, 'hero')
97
- const s = hero({ layout, align, leadAlign, actionsAlign, width, tone, headingScale, headingSize })
117
+ // Overlap moves the image down past the section's bottom, so only an image that comes last may: first, it is edge.
118
+ const bleedAs = imageBleed === 'overlap' && stackOrder === 'media-first' ? 'edge' : imageBleed
119
+ const s = hero({ layout, align, leadAlign, actionsAlign, width, tone, headingScale, headingSize, headingLines, stackOrder, imageBleed: bleedAs })
98
120
  const Heading = level === 1 ? 'h1' : 'h2'
99
121
  const headingId = `${id}-title`
100
122
  const cover = layout === 'cover' && image
@@ -103,6 +125,9 @@ const headingStyle = styleOf(headingScale, level, headingSize)
103
125
  // A cover draws its own image: the measured background image is that image, and the measured overlay is its scrim.
104
126
  const sectionMeasured = cover && measured ? { ...measured, bg: measured.bg ? { ...measured.bg, image: undefined, overlay: undefined } : undefined } : measured
105
127
  const scrim = cover ? measuredFrame(measured).overlay : undefined
128
+ // The window's edge for a bleeding image: its own side in split (the start side when it comes first), both when centered.
129
+ const bleeds = !cover && image && imageBleed !== undefined && imageBleed !== 'none'
130
+ const bleed = bleeds ? (layout === 'centered' ? `max-w-none md:w-auto ${BLEED.start} ${BLEED.end}` : BLEED[stackOrder === 'media-first' ? 'start' : 'end']) : undefined
106
131
  ---
107
132
  {cover && width === 'content'
108
133
  ? (
@@ -114,7 +139,7 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
114
139
  <div class={s.grid()} style={coverHeight ? `min-height: ${coverHeight}` : undefined}>
115
140
  <div class={s.copy()}>
116
141
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
117
- <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
142
+ <Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
118
143
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
119
144
  {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>}
120
145
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
@@ -133,7 +158,7 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
133
158
  <div class={s.grid()} style={coverHeight ? `min-height: ${coverHeight}` : undefined}>
134
159
  <div class={s.copy()}>
135
160
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
136
- <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
161
+ <Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
137
162
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
138
163
  {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>}
139
164
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
@@ -143,18 +168,18 @@ const scrim = cover ? measuredFrame(measured).overlay : undefined
143
168
  </Section>
144
169
  )
145
170
  : (
146
- <Section kit="hero" measured={sectionMeasured} tone={tone} spacing="lg" id={id} aria-labelledby={headingId}>
171
+ <Section kit="hero" measured={sectionMeasured} tone={tone} spacing="lg" id={id} aria-labelledby={headingId} class={bleeds ? 'overflow-x-clip' : undefined}>
147
172
  {/* Without an image a split has nothing beside the text: the text takes the whole row, not half of it. */}
148
173
  <div class={s.grid({ class: image ? undefined : 'md:grid-cols-1' })}>
149
174
  <div class={s.copy()}>
150
175
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
151
- <Heading id={headingId} class={s.heading()} style={headingStyle}>{heading}</Heading>
176
+ <Heading id={headingId} class={s.heading()} style={headingStyle}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>
152
177
  {lead && <p class={s.lead()} data-kit-text>{lead}</p>}
153
178
  {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>}
154
179
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
155
180
  </div>
156
181
  {image && (
157
- <div class={s.media()}>
182
+ <div class:list={[s.media(), bleed]}>
158
183
  <KitImage image={image} priority sizes={layout === 'split' ? '(min-width: 768px) 50vw, 100vw' : '(min-width: 1024px) 56rem, 100vw'} class="h-auto w-full object-cover" />
159
184
  </div>
160
185
  )}
@@ -241,5 +241,11 @@
241
241
  }
242
242
  },
243
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
+ {"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}}},
245
+ {"name": "heading-lines", "props": {"layout": "centered", "tone": "accent", "headingLines": "lines", "eyebrow": "Northwind Journal", "heading": "Slow travel.\nGood coffee.\nTwelve years on the road.", "lead": "Essays, guides and photographs, now in one place.", "actions": [{"label": "Start reading", "href": "/journal/"}]}},
246
+ {"name": "split-media-first", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}, "actions": [{"label": "Start", "href": "/start/"}]}},
247
+ {"name": "centered-media-first-edge", "props": {"layout": "centered", "heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "edge", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
248
+ {"name": "split-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
249
+ {"name": "centered-media-first-overlap", "props": {"layout": "centered", "heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
250
+ {"name": "split-media-first-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}}
245
251
  ]
@@ -7,7 +7,9 @@
7
7
  "shared": [
8
8
  "Button.astro",
9
9
  "KitImage.astro",
10
+ "Lines.astro",
10
11
  "Section.astro",
12
+ "bleed.ts",
11
13
  "instance-id.ts",
12
14
  "measured.ts",
13
15
  "muted.ts",
@@ -82,6 +84,31 @@
82
84
  ],
83
85
  "default": "own",
84
86
  "description": "The level whose site size the heading takes when it is not the level it is written at: h1 on an h2 heading keeps a source's second h1 at the h1 size under a page that keeps one h1. own: the heading's own level under headingScale theme, nothing otherwise. Any other value than own sets the size by itself."
87
+ },
88
+ "headingLines": {
89
+ "options": [
90
+ "flow",
91
+ "lines"
92
+ ],
93
+ "default": "flow",
94
+ "description": "flow: the heading is one run of text. lines: each line the author broke it into (a line break, \\n, in the heading) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one heading, read as one sentence."
95
+ },
96
+ "stackOrder": {
97
+ "options": [
98
+ "text-first",
99
+ "media-first"
100
+ ],
101
+ "default": "text-first",
102
+ "description": "Split and centered: the image before the text, on phones and on the start side (split), or above the heading (centered). A cover ignores it."
103
+ },
104
+ "imageBleed": {
105
+ "options": [
106
+ "none",
107
+ "edge",
108
+ "overlap"
109
+ ],
110
+ "default": "none",
111
+ "description": "Split and centered: edge runs the image on to the window's edge on its side (both sides when centered); overlap also lets it cross the section's bottom edge by --hero-overlap (3rem), over the next section's top padding, when the image comes last: with stackOrder media-first it draws as edge, so it never covers the text. A cover ignores it."
85
112
  }
86
113
  },
87
114
  "props": {
@@ -12,6 +12,7 @@ import Section from '../_shared/Section.astro'
12
12
  import { instanceId } from '../_shared/instance-id'
13
13
  import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
14
14
  import { muted } from '../_shared/muted'
15
+ import { BLEED } from '../_shared/bleed'
15
16
 
16
17
  const split = tv({
17
18
  slots: {
@@ -35,6 +36,10 @@ const split = tv({
35
36
  none: { media: 'rounded-none' },
36
37
  rounded: { media: 'rounded-[var(--radius-image,var(--radius-card))]' },
37
38
  circle: { media: 'mx-auto aspect-square w-full max-w-lg rounded-full', image: 'h-full' },
39
+ // A fixed set of masks (never a value from content): an arch over the image, a cut corner, a soft blob.
40
+ arch: { media: 'rounded-t-full', image: 'h-full' },
41
+ chamfer: { media: '[clip-path:polygon(0_0,82%_0,100%_18%,100%_100%,0_100%)]' },
42
+ blob: { media: 'rounded-[38%_62%_55%_45%/48%_42%_58%_52%]' },
38
43
  },
39
44
  // edge: the image runs on to the viewport's edge on its side (wide screens).
40
45
  imageBleed: { none: {}, edge: { media: 'rounded-none' } },
@@ -55,11 +60,6 @@ const COLUMNS = {
55
60
  text: { '1:1': 'md:grid-cols-2', '5:7': 'md:grid-cols-[5fr_7fr]', '7:5': 'md:grid-cols-[7fr_5fr]' },
56
61
  image: { '1:1': 'md:grid-cols-2', '5:7': 'md:grid-cols-[7fr_5fr]', '7:5': 'md:grid-cols-[5fr_7fr]' },
57
62
  } as const
58
- // The viewport's edge, from the content's edge: what is left of the page's width, halved.
59
- const BLEED = {
60
- end: 'md:-me-[calc((100vw-min(var(--container-page),100vw-2*var(--spacing-gutter,2rem)))/2)]',
61
- start: 'md:-ms-[calc((100vw-min(var(--container-page),100vw-2*var(--spacing-gutter,2rem)))/2)]',
62
- } as const
63
63
 
64
64
  interface Feature {
65
65
  title: string
@@ -87,7 +87,8 @@ interface Props {
87
87
  imageSide?: 'start' | 'end' | undefined
88
88
  ratio?: '1:1' | '5:7' | '7:5' | undefined
89
89
  imageFit?: 'contain' | 'cover' | undefined
90
- imageShape?: 'none' | 'rounded' | 'circle' | undefined
90
+ /** The image's outline: none, rounded (the default), a circle, or one of a fixed set of masks: an arch, a cut corner (`chamfer`), a soft `blob`. */
91
+ imageShape?: 'none' | 'rounded' | 'circle' | 'arch' | 'chamfer' | 'blob' | undefined
91
92
  imageBleed?: 'none' | 'edge' | undefined
92
93
  mediaKind?: 'image' | 'video' | undefined
93
94
  featureStyle?: 'check' | 'icon' | undefined
@@ -10,5 +10,8 @@
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
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
+ { "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 } } },
14
+ {"name": "shape-arch", "props": {"heading": "Made to be edited", "body": "Every page lands as content you can change, in the editor you choose.", "imageShape": "arch", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "A shelf of books", "width": 1200, "height": 800}}},
15
+ {"name": "shape-chamfer", "props": {"heading": "Made to be edited", "body": "Every page lands as content you can change, in the editor you choose.", "imageShape": "chamfer", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "A shelf of books", "width": 1200, "height": 800}}},
16
+ {"name": "shape-blob", "props": {"heading": "Made to be edited", "body": "Every page lands as content you can change, in the editor you choose.", "imageShape": "blob", "image": {"src": "/kit-fixtures/photo-2.svg", "alt": "A shelf of books", "width": 1200, "height": 800}}}
14
17
  ]
@@ -11,6 +11,7 @@
11
11
  "Button.astro",
12
12
  "KitImage.astro",
13
13
  "Section.astro",
14
+ "bleed.ts",
14
15
  "instance-id.ts",
15
16
  "measured.ts",
16
17
  "muted.ts",
@@ -47,10 +48,13 @@
47
48
  "options": [
48
49
  "none",
49
50
  "rounded",
50
- "circle"
51
+ "circle",
52
+ "arch",
53
+ "chamfer",
54
+ "blob"
51
55
  ],
52
56
  "default": "rounded",
53
- "description": "The image's corners: square, the theme's rounding, or a circle."
57
+ "description": "The image's outline: square, the theme's rounding, a circle, or a fixed mask: an arch over the image, a cut corner (chamfer), a soft blob."
54
58
  },
55
59
  "imageBleed": {
56
60
  "options": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.41.0",
3
+ "version": "0.42.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",