@contentrain/astro-kit 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +16 -0
  2. package/catalog.json +123 -15
  3. package/components/_shared/Lines.astro +12 -0
  4. package/components/_shared/Section.astro +13 -9
  5. package/components/_shared/SectionHeader.astro +3 -2
  6. package/components/_shared/bleed.ts +8 -0
  7. package/components/_shared/instance-id.ts +24 -0
  8. package/components/_shared/muted.ts +19 -0
  9. package/components/band/Band.astro +10 -7
  10. package/components/band/meta.json +2 -0
  11. package/components/behavior/Behavior.astro +7 -2
  12. package/components/behavior/fixtures.json +2 -2
  13. package/components/behavior/meta.json +7 -0
  14. package/components/card-grid/CardGrid.astro +84 -24
  15. package/components/card-grid/fixtures.json +4 -1
  16. package/components/card-grid/meta.json +21 -0
  17. package/components/contact-details/ContactDetails.astro +7 -3
  18. package/components/contact-details/meta.json +2 -0
  19. package/components/contact-form/ContactForm.astro +11 -7
  20. package/components/contact-form/meta.json +2 -0
  21. package/components/cta/Cta.astro +14 -10
  22. package/components/cta/meta.json +2 -2
  23. package/components/dialog/Dialog.astro +6 -3
  24. package/components/dialog/meta.json +2 -0
  25. package/components/faq/Faq.astro +8 -4
  26. package/components/faq/meta.json +1 -1
  27. package/components/feature-list/FeatureList.astro +10 -6
  28. package/components/feature-list/meta.json +3 -1
  29. package/components/footer/Footer.astro +4 -3
  30. package/components/footer/meta.json +1 -0
  31. package/components/gallery/Gallery.astro +8 -4
  32. package/components/gallery/meta.json +2 -0
  33. package/components/header/Header.astro +8 -4
  34. package/components/header/meta.json +1 -1
  35. package/components/hero/Hero.astro +44 -15
  36. package/components/hero/fixtures.json +7 -1
  37. package/components/hero/meta.json +30 -1
  38. package/components/language-switcher/LanguageSwitcher.astro +3 -2
  39. package/components/language-switcher/meta.json +1 -1
  40. package/components/logo-cloud/LogoCloud.astro +9 -5
  41. package/components/logo-cloud/meta.json +3 -1
  42. package/components/nav/Nav.astro +10 -6
  43. package/components/nav/meta.json +2 -0
  44. package/components/newsletter/Newsletter.astro +9 -5
  45. package/components/newsletter/meta.json +2 -0
  46. package/components/post-list/PostList.astro +7 -3
  47. package/components/post-list/meta.json +2 -0
  48. package/components/pricing/Pricing.astro +15 -11
  49. package/components/pricing/meta.json +3 -1
  50. package/components/search-box/SearchBox.astro +4 -1
  51. package/components/search-box/meta.json +1 -1
  52. package/components/slider/Slider.astro +4 -1
  53. package/components/slider/SliderTrack.astro +11 -5
  54. package/components/slider/meta.json +1 -1
  55. package/components/social-links/SocialLinks.astro +2 -1
  56. package/components/social-links/meta.json +3 -2
  57. package/components/split/Split.astro +16 -11
  58. package/components/split/fixtures.json +4 -1
  59. package/components/split/meta.json +9 -3
  60. package/components/stats/Stats.astro +11 -7
  61. package/components/stats/meta.json +3 -1
  62. package/components/steps/Steps.astro +9 -5
  63. package/components/steps/meta.json +3 -1
  64. package/components/tabs/Tabs.astro +8 -4
  65. package/components/tabs/meta.json +1 -1
  66. package/components/team/Team.astro +10 -6
  67. package/components/team/meta.json +4 -2
  68. package/components/testimonial/Testimonial.astro +9 -5
  69. package/components/testimonial/fixtures.json +1 -1
  70. package/components/testimonial/meta.json +3 -1
  71. package/package.json +1 -1
@@ -5,14 +5,16 @@
5
5
  import { tv } from 'tailwind-variants'
6
6
  import KitImage from '../_shared/KitImage.astro'
7
7
  import Section, { type Tone } from '../_shared/Section.astro'
8
+ import { instanceId } from '../_shared/instance-id'
8
9
  import { imageOf, type ImageSource } from '../_shared/image'
9
10
  import { measuredGrid } from '../_shared/measured'
10
11
  import type { MeasuredStyle } from '../_shared/types'
11
12
  import { safeHref } from '../_shared/safe-url'
13
+ import { muted } from '../_shared/muted'
12
14
 
13
15
  const cloud = tv({
14
16
  slots: {
15
- heading: 'mb-8 text-sm font-semibold tracking-wide uppercase opacity-80',
17
+ heading: `mb-8 text-sm font-semibold tracking-wide uppercase ${muted}`,
16
18
  list: 'grid grid-cols-2 items-center gap-x-8 gap-y-10 sm:grid-cols-3',
17
19
  item: 'flex items-center justify-center',
18
20
  logo: 'h-10 w-auto max-w-full object-contain transition duration-200',
@@ -46,7 +48,7 @@ const cloud = tv({
46
48
  'mono-light': { logo: 'brightness-0 invert' },
47
49
  },
48
50
  align: { center: { heading: 'text-center' }, start: { heading: 'text-start' } },
49
- tone: { default: {}, muted: {}, inverse: {}, accent: { heading: 'opacity-100' } },
51
+ tone: { default: {}, muted: {}, inverse: {}, accent: {} },
50
52
  },
51
53
  compoundVariants: [
52
54
  { layout: 'row', align: 'center', class: { list: 'justify-center' } },
@@ -78,11 +80,13 @@ interface Props {
78
80
  id?: string | undefined
79
81
  }
80
82
 
81
- const { heading, items, layout, columns, logoHeight, style, align, tone, measured, id } = Astro.props
83
+ const { heading, items, layout, columns, logoHeight, style, align, tone, measured, id: givenId } = Astro.props
84
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `logo-cloud`, `logo-cloud-2`, … in render order.
85
+ const id = instanceId(Astro.locals, givenId, 'logo-cloud')
82
86
  const s = cloud({ layout, columns, logoHeight, style, align, tone })
83
87
  // Measured columns and gap shape the grid layout only; a row or a strip keeps its own flow.
84
88
  const grid = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
85
- const headingId = heading && id ? `${id}-title` : undefined
89
+ const headingId = heading ? `${id}-title` : undefined
86
90
  const marquee = layout === 'marquee'
87
91
  // A linked logo with no name is read as the site it goes to, never as a made-up word.
88
92
  const hostOf = (href: string) => {
@@ -95,7 +99,7 @@ const shown = items.flatMap((item) => {
95
99
  // About four seconds a logo: the strip reads at a pace, whatever its length.
96
100
  const duration = `${Math.max(20, shown.length * 4)}s`
97
101
  ---
98
- <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
102
+ <Section kit="logo-cloud" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
99
103
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
100
104
  <div class:list={[marquee && 'kit-marquee overflow-hidden']} style={marquee ? `--kit-marquee-duration:${duration}` : undefined}>
101
105
  <ul class={s.list({ class: grid })}>
@@ -8,7 +8,9 @@
8
8
  "KitImage.astro",
9
9
  "Section.astro",
10
10
  "image.ts",
11
+ "instance-id.ts",
11
12
  "measured.ts",
13
+ "muted.ts",
12
14
  "safe-url.ts",
13
15
  "types.ts"
14
16
  ],
@@ -217,7 +219,7 @@
217
219
  "id": {
218
220
  "type": "string",
219
221
  "content": false,
220
- "description": "Anchor id of the section; the heading gets `<id>-title`."
222
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `logo-cloud`, `logo-cloud-2`, … in render order, so every section on a page has its own."
221
223
  }
222
224
  },
223
225
  "slots": {},
@@ -8,13 +8,15 @@ import { tv } from 'tailwind-variants'
8
8
  import NavTree from './NavTree.astro'
9
9
  import type { NavItem } from '../_shared/types'
10
10
  import { safeHref } from '../_shared/safe-url'
11
+ import { instanceId } from '../_shared/instance-id'
12
+ import { muted, mutedReset } from '../_shared/muted'
11
13
 
12
14
  const nav = tv({
13
15
  slots: {
14
16
  list: 'hidden items-center md:flex',
15
17
  link: 'no-underline aria-[current=page]:font-semibold',
16
18
  // hidden, not invisible: a closed submenu takes no space, so one under the last item cannot widen the page.
17
- sub: 'absolute top-full z-30 hidden min-w-48 rounded-lg border border-line bg-surface p-2 text-ink shadow-lg group-hover:block group-focus-within:block group-data-[closed]:hidden!',
19
+ sub: `absolute top-full z-30 hidden min-w-48 rounded-lg border border-line bg-surface p-2 text-ink shadow-lg group-hover:block group-focus-within:block group-data-[closed]:hidden! ${mutedReset}`,
18
20
  toggle: 'inline-flex cursor-pointer list-none items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-current/10 md:hidden [&::-webkit-details-marker]:hidden',
19
21
  },
20
22
  variants: {
@@ -23,10 +25,10 @@ const nav = tv({
23
25
  // flyout: a list under the item. mega: a wide panel, one column per child with the grandchildren listed under it.
24
26
  menu: { flyout: {}, mega: { sub: 'w-max max-w-[min(64rem,90vw)] p-5' } },
25
27
  density: { compact: { list: 'gap-5 text-sm' }, comfortable: { list: 'gap-8 text-base' } },
26
- // current: the surrounding text, dimmed until hovered. link: the site's link colour at full strength, as themes that
28
+ // current: the surrounding text's muted role until hovered. link: the site's link colour at full strength, as themes that
27
29
  // colour every link draw their menu; the toggle keeps the surrounding text.
28
30
  color: {
29
- current: { link: 'text-current opacity-80 hover:opacity-100 aria-[current=page]:opacity-100' },
31
+ current: { link: `${muted} hover:text-current aria-[current=page]:text-current` },
30
32
  link: { link: 'text-[color:var(--color-link,var(--color-accent))]' },
31
33
  },
32
34
  },
@@ -52,7 +54,9 @@ interface Props {
52
54
  part?: string | undefined
53
55
  }
54
56
 
55
- const { items, label = 'Main', menuLabel = 'Menu', closeLabel = 'Close', mobile = 'popover', menu, align, density, color, id = 'site-nav', part } = Astro.props
57
+ const { items, label = 'Main', menuLabel = 'Menu', closeLabel = 'Close', mobile = 'popover', menu, align, density, color, id: givenId, part } = Astro.props
58
+ // Two on one page never share an id: their labels and controls would point into each other.
59
+ const id = instanceId(Astro.locals, givenId, 'site-nav')
56
60
  const { list, link, sub, toggle } = nav({ menu, align, density, color })
57
61
  const current = Astro.url.pathname
58
62
  const drawer = `${id}-drawer`
@@ -99,7 +103,7 @@ const real = (href: string | undefined) => !!href && href !== '#'
99
103
  <svg aria-hidden="true" viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
100
104
  {menuLabel}
101
105
  </button>
102
- <div id={drawer} popover class="m-0 ms-auto h-dvh max-h-none w-80 max-w-[85vw] border-s border-line bg-surface p-6 text-ink shadow-xl backdrop:bg-ink/40">
106
+ <div id={drawer} popover class={`m-0 ms-auto h-dvh max-h-none w-80 max-w-[85vw] border-s border-line bg-surface p-6 text-ink shadow-xl backdrop:bg-ink/40 ${mutedReset}`}>
103
107
  <button type="button" popovertarget={drawer} popovertargetaction="hide" class="mb-6 ms-auto flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-surface-muted">
104
108
  <svg aria-hidden="true" viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18" /></svg>
105
109
  {closeLabel}
@@ -123,7 +127,7 @@ const real = (href: string | undefined) => !!href && href !== '#'
123
127
  <svg aria-hidden="true" viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
124
128
  {menuLabel}
125
129
  </summary>
126
- <ul class="absolute end-0 z-30 mt-2 flex w-64 flex-col gap-1 rounded-lg border border-line bg-surface p-3 text-ink shadow-lg">
130
+ <ul class={`absolute end-0 z-30 mt-2 flex w-64 flex-col gap-1 rounded-lg border border-line bg-surface p-3 text-ink shadow-lg ${mutedReset}`}>
127
131
  {items.map(item => (
128
132
  <li>
129
133
  {real(item.href)
@@ -5,6 +5,8 @@
5
5
  "category": "chrome",
6
6
  "uses": [],
7
7
  "shared": [
8
+ "instance-id.ts",
9
+ "muted.ts",
8
10
  "safe-url.ts",
9
11
  "types.ts"
10
12
  ],
@@ -13,19 +13,21 @@ import Section from '../_shared/Section.astro'
13
13
  import type { MeasuredStyle } from '../_shared/types'
14
14
  import { newsletterForm } from './newsletter'
15
15
  import { safeUrl } from '../_shared/safe-url'
16
+ import { instanceId } from '../_shared/instance-id'
17
+ import { muted } from '../_shared/muted'
16
18
 
17
19
  const newsletter = tv({
18
20
  slots: {
19
21
  box: 'flex flex-col gap-6',
20
22
  copy: 'flex flex-col gap-3',
21
23
  heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
22
- intro: 'max-w-xl text-pretty opacity-85',
24
+ intro: `max-w-xl text-pretty ${muted}`,
23
25
  form: 'flex flex-col gap-3',
24
26
  field: 'flex min-w-0 flex-col gap-1.5',
25
27
  label: 'text-sm font-semibold',
26
28
  input: 'min-h-11 w-full rounded-[var(--radius-control,var(--radius-md))] border border-line bg-surface px-3 py-2 text-base text-ink placeholder:text-ink-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
27
29
  submit: 'inline-flex min-h-11 items-center justify-center rounded-[var(--radius-control,var(--radius-md))] px-5 py-2.5 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 bg-accent text-accent-ink hover:bg-ink hover:text-surface',
28
- note: 'text-sm opacity-75',
30
+ note: `text-sm ${muted}`,
29
31
  },
30
32
  variants: {
31
33
  layout: {
@@ -37,7 +39,7 @@ const newsletter = tv({
37
39
  default: {},
38
40
  muted: {},
39
41
  inverse: { submit: 'hover:bg-surface hover:text-ink' },
40
- accent: { intro: 'opacity-100', note: 'opacity-100', submit: 'bg-ink text-surface hover:bg-surface hover:text-ink' },
42
+ accent: { submit: 'bg-ink text-surface hover:bg-surface hover:text-ink' },
41
43
  },
42
44
  },
43
45
  defaultVariants: { layout: 'inline', tone: 'muted' },
@@ -73,9 +75,11 @@ interface Props {
73
75
  const {
74
76
  action, provider = 'mailchimp', emailName, askName = false, heading, intro, note,
75
77
  emailLabel = 'Email address', nameLabel = 'First name', submitLabel = 'Subscribe', newTab = true,
76
- layout, tone = 'muted', id = 'newsletter',
78
+ layout, tone = 'muted', id: givenId,
77
79
  measured,
78
80
  } = Astro.props
81
+ // Two on one page never share an id: their labels and controls would point into each other.
82
+ const id = instanceId(Astro.locals, givenId, 'newsletter')
79
83
  const form = newsletterForm(provider, action, { email: emailName }, Astro.site)
80
84
  const s = newsletter({ layout, tone })
81
85
  const headingId = heading ? `${id}-title` : undefined
@@ -83,7 +87,7 @@ const headingId = heading ? `${id}-title` : undefined
83
87
  const formRel: Record<string, string> = newTab ? { rel: 'noopener noreferrer' } : {}
84
88
  ---
85
89
  {form && (
86
- <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
90
+ <Section kit="newsletter" measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
87
91
  <div class={s.box()}>
88
92
  {(heading || intro) && (
89
93
  <div class={s.copy()}>
@@ -6,7 +6,9 @@
6
6
  "uses": [],
7
7
  "shared": [
8
8
  "Section.astro",
9
+ "instance-id.ts",
9
10
  "measured.ts",
11
+ "muted.ts",
10
12
  "safe-url.ts",
11
13
  "types.ts"
12
14
  ],
@@ -10,6 +10,8 @@ import type { MeasuredStyle } from '../_shared/types'
10
10
  import PostCard from '../post-card/PostCard.astro'
11
11
  import type { ImageInput, LinkInput } from '../_shared/types'
12
12
  import { safeHref } from '../_shared/safe-url'
13
+ import { instanceId } from '../_shared/instance-id'
14
+ import { muted } from '../_shared/muted'
13
15
 
14
16
  export interface PostItem {
15
17
  title: string
@@ -25,7 +27,7 @@ const list = tv({
25
27
  slots: {
26
28
  header: 'mb-10 flex max-w-2xl flex-col gap-3',
27
29
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
28
- intro: 'text-lg opacity-80',
30
+ intro: `text-lg ${muted}`,
29
31
  items: 'grid gap-8',
30
32
  more: 'mt-10',
31
33
  },
@@ -74,7 +76,9 @@ interface Props {
74
76
  id?: string | undefined
75
77
  }
76
78
 
77
- const { heading, intro, posts, layout = 'grid', columns, showImage = true, showExcerpt = true, showAuthor = true, showCategory = true, excerptLines, locale, timeZone, surface, moreHref, moreLabel, tone, measured, id = 'posts' } = Astro.props
79
+ const { heading, intro, posts, layout = 'grid', columns, showImage = true, showExcerpt = true, showAuthor = true, showCategory = true, excerptLines, locale, timeZone, surface, moreHref, moreLabel, tone, measured, id: givenId } = Astro.props
80
+ // Two on one page never share an id: their labels and controls would point into each other.
81
+ const id = instanceId(Astro.locals, givenId, 'posts')
78
82
  const s = list({ layout, columns })
79
83
  const headingId = heading ? `${id}-title` : undefined
80
84
  // Cards sit one heading level under the section's; with no section heading they are the page's h2s.
@@ -84,7 +88,7 @@ const sizes = layout === 'list' || layout === 'compact' ? '256px' : columns ===
84
88
  const cardLayout = (i: number) => layout === 'compact' ? 'minimal' : layout === 'list' || (layout === 'featured' && i === 0) ? 'horizontal' : 'vertical'
85
89
  ---
86
90
  {posts.length > 0 && (
87
- <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
91
+ <Section kit="post-list" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
88
92
  {(heading || intro) && (
89
93
  <header class={s.header()}>
90
94
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
@@ -8,7 +8,9 @@
8
8
  ],
9
9
  "shared": [
10
10
  "Section.astro",
11
+ "instance-id.ts",
11
12
  "measured.ts",
13
+ "muted.ts",
12
14
  "safe-url.ts",
13
15
  "types.ts"
14
16
  ],
@@ -8,15 +8,17 @@
8
8
  import { tv } from 'tailwind-variants'
9
9
  import Button from '../_shared/Button.astro'
10
10
  import Section, { type Tone } from '../_shared/Section.astro'
11
+ import { instanceId } from '../_shared/instance-id'
11
12
  import { measuredGrid } from '../_shared/measured'
12
13
  import type { MeasuredStyle } from '../_shared/types'
13
14
  import { renderMarkdown } from '../../../lib/markdown'
15
+ import { muted, mutedReset } from '../_shared/muted'
14
16
 
15
17
  const pricing = tv({
16
18
  slots: {
17
19
  header: 'mx-auto mb-10 flex max-w-2xl flex-col items-center gap-3 text-center',
18
20
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
19
- intro: 'text-lg opacity-80',
21
+ intro: `text-lg ${muted}`,
20
22
  list: 'grid items-stretch gap-6 sm:grid-cols-2',
21
23
  toggle: 'mx-auto mb-8 flex w-fit rounded-full border border-line p-1',
22
24
  toggleOption: 'rounded-full px-4 py-1.5 text-sm font-medium has-[:checked]:bg-accent has-[:checked]:text-accent-ink has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-accent',
@@ -24,13 +26,13 @@ const pricing = tv({
24
26
  table: 'w-full min-w-[32rem] border-collapse text-start',
25
27
  cell: 'border-b border-line px-4 py-3 text-center align-top',
26
28
  rowHead: 'border-b border-line px-4 py-3 text-start font-normal',
27
- plan: 'relative flex flex-col gap-5 rounded-card border border-line bg-surface p-6 text-ink md:p-8',
29
+ plan: `relative flex flex-col gap-5 rounded-card border border-line bg-surface p-6 text-ink md:p-8 ${mutedReset}`,
28
30
  badge: 'absolute -top-3 start-6 rounded-full bg-accent px-3 py-1 text-xs font-semibold text-accent-ink',
29
31
  name: 'text-lg [font-weight:var(--font-weight-heading,600)]',
30
32
  price: 'flex items-baseline gap-1',
31
33
  amount: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight',
32
- period: 'text-sm opacity-75',
33
- description: 'text-pretty opacity-80',
34
+ period: `text-sm ${muted}`,
35
+ description: `text-pretty ${muted}`,
34
36
  features: 'prose-site prose max-w-none text-current prose-ul:ps-5 prose-li:my-1',
35
37
  action: 'mt-auto pt-2',
36
38
  },
@@ -47,7 +49,7 @@ const pricing = tv({
47
49
  plain: {},
48
50
  },
49
51
  compact: { true: { list: 'gap-4', plan: 'gap-3 p-4 md:p-5', amount: 'text-3xl' }, false: {} },
50
- tone: { default: {}, muted: { plan: 'border-transparent' }, inverse: { plan: 'border-transparent' }, accent: { intro: 'opacity-100', plan: 'border-transparent', badge: 'bg-ink text-surface' } },
52
+ tone: { default: {}, muted: { plan: 'border-transparent' }, inverse: { plan: 'border-transparent' }, accent: { plan: 'border-transparent', badge: 'bg-ink text-surface' } },
51
53
  },
52
54
  defaultVariants: { columns: '3', featureStyle: 'plain', compact: false, tone: 'default' },
53
55
  })
@@ -95,22 +97,24 @@ interface Props {
95
97
  id?: string | undefined
96
98
  }
97
99
 
98
- const { heading, intro, plans, featuredLabel = 'Most popular', columns, layout = 'cards', featureStyle, compact, billingToggle = 'none', billingMainLabel = 'Monthly', billingAltLabel = 'Yearly', billingLegend = 'Billing period', includedLabel = 'Included', notIncludedLabel = 'Not included', tone, measured, id } = Astro.props
100
+ const { heading, intro, plans, featuredLabel = 'Most popular', columns, layout = 'cards', featureStyle, compact, billingToggle = 'none', billingMainLabel = 'Monthly', billingAltLabel = 'Yearly', billingLegend = 'Billing period', includedLabel = 'Included', notIncludedLabel = 'Not included', tone, measured, id: givenId } = Astro.props
101
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `pricing`, `pricing-2`, … in render order.
102
+ const id = instanceId(Astro.locals, givenId, 'pricing')
99
103
  const s = pricing({ columns, featureStyle, compact: compact ?? false, tone })
100
- // The radio group's name must be unique on the page: the section's id, or a hash of its plans when it has none.
101
- const group = id ?? `pricing-${[...JSON.stringify(plans)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`
104
+ // The radio group's name must be unique on the page: the section's id is.
105
+ const group = id
102
106
  const toggled = billingToggle === 'radio' && plans.some(plan => plan.priceAlt)
103
107
  // The cards' list items, as plain strings, for the table's feature rows (the markdown list's `- ` / `* ` lines).
104
108
  const itemsOf = (md?: string) => (md ?? '').split('\n').flatMap((line) => { const m = /^\s*[-*]\s+(.*\S)/.exec(line); return m ? [m[1]!.replace(/[*_`]/g, '')] : [] })
105
109
  const rows = layout === 'table' ? [...new Set(plans.flatMap(plan => itemsOf(plan.features)))] : []
106
110
  const included = plans.map(plan => new Set(itemsOf(plan.features)))
107
- const headingId = heading && id ? `${id}-title` : undefined
111
+ const headingId = heading ? `${id}-title` : undefined
108
112
  // One level under the section heading, or the section's heading when there is none.
109
113
  const PlanHeading = heading ? 'h3' : 'h2'
110
114
  const features = await Promise.all(plans.map(plan => renderMarkdown(plan.features)))
111
115
  ---
112
116
  {plans.length > 0 && (
113
- <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
117
+ <Section kit="pricing" measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
114
118
  {(heading || intro) && (
115
119
  <header class={s.header()}>
116
120
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
@@ -128,7 +132,7 @@ const features = await Promise.all(plans.map(plan => renderMarkdown(plan.feature
128
132
  <th scope="col" class={s.cell({ class: plan.featured ? 'bg-surface-muted' : undefined })}>
129
133
  <span class="block text-lg [font-weight:var(--font-weight-heading,600)]">{plan.name}</span>
130
134
  <span class="block text-2xl [font-weight:var(--font-weight-heading,700)]">{plan.price}</span>
131
- {plan.period && <span class="block text-sm opacity-75">{plan.period}</span>}
135
+ {plan.period && <span class:list={['block text-sm', muted]}>{plan.period}</span>}
132
136
  {(plan.badge ?? (plan.featured ? featuredLabel : undefined)) && <span class="mt-1 inline-block rounded-full bg-accent px-3 py-0.5 text-xs font-semibold text-accent-ink">{plan.badge ?? featuredLabel}</span>}
133
137
  </th>
134
138
  ))}
@@ -7,7 +7,9 @@
7
7
  "shared": [
8
8
  "Button.astro",
9
9
  "Section.astro",
10
+ "instance-id.ts",
10
11
  "measured.ts",
12
+ "muted.ts",
11
13
  "safe-url.ts",
12
14
  "types.ts"
13
15
  ],
@@ -145,7 +147,7 @@
145
147
  "id": {
146
148
  "type": "string",
147
149
  "content": false,
148
- "description": "Anchor id of the section; the heading gets `<id>-title`."
150
+ "description": "Anchor id of the section; the heading gets `<id>-title`. Without one, `pricing`, `pricing-2`, … in render order, so every section on a page has its own."
149
151
  },
150
152
  "compact": {
151
153
  "type": "boolean",
@@ -4,6 +4,7 @@
4
4
  // assistive technology unless `labelStyle` shows it.
5
5
  import { tv } from 'tailwind-variants'
6
6
  import { safeUrl } from '../_shared/safe-url'
7
+ import { instanceId } from '../_shared/instance-id'
7
8
 
8
9
  const search = tv({
9
10
  slots: {
@@ -37,7 +38,9 @@ interface Props {
37
38
  id?: string | undefined
38
39
  }
39
40
 
40
- const { action = '/search', label = 'Search', placeholder, submitLabel = label, size, width, button, labelStyle, id = 'site-search' } = Astro.props
41
+ const { action = '/search', label = 'Search', placeholder, submitLabel = label, size, width, button, labelStyle, id: givenId } = Astro.props
42
+ // Two on one page never share an id: their labels and controls would point into each other.
43
+ const id = instanceId(Astro.locals, givenId, 'site-search')
41
44
  const s = search({ size, width, button, labelStyle })
42
45
  ---
43
46
  <form role="search" method="get" action={safeUrl(action)} class={s.form()} data-cr-part="search-box">
@@ -4,7 +4,7 @@
4
4
  "description": "A search field that sends the visitor to the site's search page (`/search?q=…`): a plain GET form with a labelled role=search landmark, no JavaScript. For a header slot or inline in a page.",
5
5
  "category": "chrome",
6
6
  "uses": [],
7
- "shared": ["safe-url.ts"],
7
+ "shared": ["instance-id.ts", "safe-url.ts"],
8
8
  "variants": {
9
9
  "size": {
10
10
  "options": [
@@ -8,6 +8,7 @@ import Section from '../_shared/Section.astro'
8
8
  import type { ImageSource } from '../_shared/image'
9
9
  import type { ActionInput, MeasuredStyle } from '../_shared/types'
10
10
  import SliderTrack from './SliderTrack.astro'
11
+ import { instanceId } from '../_shared/instance-id'
11
12
 
12
13
  interface Slide {
13
14
  /** The image's address (or, until 0.7, an `ImageInput`). Optional: a text card (a quote, a testimonial) is a slide too. */
@@ -54,7 +55,9 @@ interface Props {
54
55
  }
55
56
 
56
57
  const props = Astro.props
58
+ // The section and its track share one id (the track is `<id>-track`), taken once here so the track keeps it.
59
+ const id = props.measured ? instanceId(Astro.locals, props.id, 'slider') : undefined
57
60
  ---
58
61
  {props.measured
59
- ? <Section measured={props.measured} tone="none" spacing="sm" id={props.id}><SliderTrack {...props} id={props.id ?? 'slider'} framed={false} /></Section>
62
+ ? <Section kit="slider" measured={props.measured} tone="none" spacing="sm" id={id}><SliderTrack {...props} id={id} framed={false} /></Section>
60
63
  : <SliderTrack {...props} />}
@@ -14,6 +14,8 @@ import { safeSrc } from '../_shared/safe-url'
14
14
  import { onVideoHost, videoTarget, type EmbedTarget } from '../_shared/embed-target'
15
15
  import { measuredSlides } from '../_shared/measured'
16
16
  import { type ActionInput, type ImageInput, type MeasuredStyle } from '../_shared/types'
17
+ import { instanceId } from '../_shared/instance-id'
18
+ import { muted, mutedReset } from '../_shared/muted'
17
19
 
18
20
  const slider = tv({
19
21
  slots: {
@@ -23,7 +25,7 @@ const slider = tv({
23
25
  image: 'w-full object-cover',
24
26
  caption: 'flex flex-col items-start gap-2',
25
27
  // A slide with no image: its text as a card the height of the row, never laid over an image that is not there.
26
- card: 'flex h-full flex-col items-start gap-2 rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60',
28
+ card: `flex h-full flex-col items-start gap-2 rounded-[var(--radius-card)] bg-surface p-6 text-ink ring-1 ring-line/60 ${mutedReset}`,
27
29
  },
28
30
  variants: {
29
31
  perView: {
@@ -39,7 +41,7 @@ const slider = tv({
39
41
  caption: {
40
42
  below: { image: 'rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4' },
41
43
  // Over the image on wide screens; under it on phones, where the image is too short to hold text.
42
- overlay: { image: 'rounded-[var(--radius-image,var(--radius-card))] md:rounded-none', slide: 'md:rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4 md:absolute md:inset-x-0 md:bottom-0 md:bg-gradient-to-t md:from-ink/80 md:to-transparent md:p-6 md:pt-16 md:text-surface' },
44
+ overlay: { image: 'rounded-[var(--radius-image,var(--radius-card))] md:rounded-none', slide: 'md:rounded-[var(--radius-image,var(--radius-card))]', caption: 'pt-4 md:absolute md:inset-x-0 md:bottom-0 md:bg-gradient-to-t md:from-ink/80 md:to-transparent md:p-6 md:pt-16 md:text-surface md:[--kit-muted:var(--color-ink-muted-inverse,var(--color-surface))]' },
43
45
  },
44
46
  },
45
47
  defaultVariants: { perView: '1', aspect: 'wide', caption: 'overlay' },
@@ -99,7 +101,9 @@ interface Props {
99
101
  framed?: boolean | undefined
100
102
  }
101
103
 
102
- const { slides: given, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', playLabel = 'Play', loop = false, perView = '1', aspect, caption, heading, intro, eyebrow, actions, measured, id = 'slider', framed = true } = Astro.props
104
+ const { slides: given, label, prevLabel = 'Previous slide', nextLabel = 'Next slide', slideLabel = 'Slide', ofLabel = 'of', playLabel = 'Play', loop = false, perView = '1', aspect, caption, heading, intro, eyebrow, actions, measured, id: givenId, framed = true } = Astro.props
105
+ // Two on one page never share an id: their labels and controls would point into each other.
106
+ const id = instanceId(Astro.locals, givenId, 'slider')
103
107
  // A slide is drawn when it has something to show: an image, a video (a file or another site's), or a text card (a title, a text or a link) where
104
108
  // the source carousel moved quotes or testimonials. A slide with none of them is dropped.
105
109
  const slides = given.flatMap((slide) => {
@@ -120,6 +124,8 @@ const below = slider({ perView: measuredRow ? '1' : perView, aspect, caption: 'b
120
124
  const trackId = `${id}-track`
121
125
  const sizes = measuredRow ? '(min-width: 1216px) 1152px, 100vw' : perView === '1' ? '(min-width: 1216px) 1152px, 100vw' : perView === '2' ? '(min-width: 640px) 50vw, 85vw' : '(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 85vw'
122
126
  // Arrows from sm up; on phones the carousel is swiped and the dots show the position.
127
+ // Framed, the carousel is its own section: the same hooks as every other (`id`, `data-kit-section`), its padding its own,
128
+ // so a theme's section spacing leaves it as it is.
123
129
  const Root = framed ? 'section' : 'div'
124
130
  const arrow = 'absolute top-1/2 z-10 hidden size-11 -translate-y-1/2 items-center justify-center rounded-full bg-surface/90 text-ink shadow-md ring-1 ring-line hover:bg-surface disabled:opacity-40 sm:group-data-[ready]:flex'
125
131
  // A text card has no picture for the arrows to sit on: over it they would cover its words. A carousel with any text
@@ -129,7 +135,7 @@ const cards = slides.some(slide => !slide.image && !slide.video && !slide.player
129
135
  // ratio, so the player that replaces the link moves nothing.
130
136
  const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg-surface text-ink ring-1 ring-line hover:bg-surface disabled:opacity-40'
131
137
  ---
132
- <Root class:list={['group relative', framed && 'container-page py-8']} role={framed ? undefined : 'region'} aria-roledescription="carousel" aria-label={label} data-kit-slider data-loop={loop ? '' : undefined} data-slide-label={slideLabel}>
138
+ <Root id={framed ? id : undefined} class:list={['group relative', framed && 'container-page py-8']} data-kit-section={framed ? 'slider' : undefined} data-kit-spacing={framed ? 'none' : undefined} role={framed ? undefined : 'region'} aria-roledescription="carousel" aria-label={label} data-kit-slider data-loop={loop ? '' : undefined} data-slide-label={slideLabel}>
133
139
  <SectionHeader heading={heading} intro={intro} eyebrow={eyebrow} actions={actions} />
134
140
  <div class={slots.viewport()} data-viewport tabindex="0" role="group" aria-label={label}>
135
141
  <div class:list={['flex gap-4 [&>*]:shrink-0 group-data-[ready]:[&>*]:snap-align-none', measuredRow?.track]} id={trackId} data-container>
@@ -152,7 +158,7 @@ const under = 'flex size-11 shrink-0 items-center justify-center rounded-full bg
152
158
  {(slide.title || slide.text || slide.action) && (
153
159
  <div class={slide.video || slide.player ? below.caption() : slide.image ? slots.caption() : slots.card()}>
154
160
  {slide.title && <p class="text-xl [font-weight:var(--font-weight-heading,600)] text-balance">{slide.title}</p>}
155
- {slide.text && <p class="opacity-85">{slide.text}</p>}
161
+ {slide.text && <p class={muted}>{slide.text}</p>}
156
162
  {slide.action && <Button action={slide.action} size="sm" class="mt-2" />}
157
163
  </div>
158
164
  )}
@@ -4,7 +4,7 @@
4
4
  "description": "A carousel of images, videos (a file, or a YouTube/Vimeo link loaded only on click) or text cards, with optional title, text and button per slide. Without JavaScript it is a snapping horizontal scroller; Embla adds paging, arrows and dots.",
5
5
  "category": "section",
6
6
  "uses": [],
7
- "shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "embed-frame.ts", "embed-target.ts", "image.ts", "measured.ts", "safe-url.ts", "types.ts"],
7
+ "shared": ["Button.astro", "KitImage.astro", "Section.astro", "SectionHeader.astro", "embed-frame.ts", "embed-target.ts", "image.ts", "instance-id.ts", "measured.ts", "muted.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "perView": { "options": ["1", "2", "3"], "default": "1", "description": "Slides visible at once on wide screens." },
10
10
  "aspect": { "options": ["wide", "standard", "square"], "default": "wide", "description": "Image aspect ratio: 21:9, 3:2 or 1:1." },
@@ -5,11 +5,12 @@
5
5
  import { tv } from 'tailwind-variants'
6
6
  import { addressLabel, NETWORK_MARKS, networkOf } from '../_shared/social'
7
7
  import { safeHref } from '../_shared/safe-url'
8
+ import { muted } from '../_shared/muted'
8
9
 
9
10
  const social = tv({
10
11
  slots: {
11
12
  list: 'flex flex-wrap items-center',
12
- link: 'inline-flex items-center justify-center gap-2 text-current no-underline opacity-85 hover:opacity-100 focus-visible:opacity-100',
13
+ link: `inline-flex items-center justify-center gap-2 no-underline ${muted} hover:text-current focus-visible:text-current`,
13
14
  mark: 'shrink-0',
14
15
  },
15
16
  variants: {
@@ -5,8 +5,9 @@
5
5
  "category": "chrome",
6
6
  "uses": [],
7
7
  "shared": [
8
- "social.ts",
9
- "safe-url.ts"
8
+ "muted.ts",
9
+ "safe-url.ts",
10
+ "social.ts"
10
11
  ],
11
12
  "variants": {
12
13
  "display": {
@@ -9,13 +9,16 @@ import Embed from '../embed/Embed.astro'
9
9
  import FeatureList from '../feature-list/FeatureList.astro'
10
10
  import KitImage from '../_shared/KitImage.astro'
11
11
  import Section from '../_shared/Section.astro'
12
+ import { instanceId } from '../_shared/instance-id'
12
13
  import type { ActionInput, ImageInput, MeasuredStyle } from '../_shared/types'
14
+ import { muted } from '../_shared/muted'
15
+ import { BLEED } from '../_shared/bleed'
13
16
 
14
17
  const split = tv({
15
18
  slots: {
16
19
  grid: 'grid items-center gap-10 md:gap-16',
17
20
  copy: 'flex flex-col gap-5',
18
- eyebrow: 'text-sm font-semibold tracking-wide uppercase opacity-80',
21
+ eyebrow: `text-sm font-semibold tracking-wide uppercase ${muted}`,
19
22
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
20
23
  body: 'prose prose-site max-w-none text-pretty',
21
24
  actions: 'flex flex-wrap gap-3 pt-2',
@@ -33,6 +36,10 @@ const split = tv({
33
36
  none: { media: 'rounded-none' },
34
37
  rounded: { media: 'rounded-[var(--radius-image,var(--radius-card))]' },
35
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%]' },
36
43
  },
37
44
  // edge: the image runs on to the viewport's edge on its side (wide screens).
38
45
  imageBleed: { none: {}, edge: { media: 'rounded-none' } },
@@ -42,7 +49,7 @@ const split = tv({
42
49
  sticky: { true: { media: 'md:sticky md:top-8 md:self-start' }, false: {} },
43
50
  align: { start: {}, center: { copy: 'items-center text-center', actions: 'justify-center' } },
44
51
  // 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.
45
- tone: { default: {}, muted: {}, inverse: {}, accent: { eyebrow: 'opacity-100' }, brand: { eyebrow: 'opacity-100' }, none: {} },
52
+ tone: { default: {}, muted: {}, inverse: {}, accent: {}, brand: {}, none: {} },
46
53
  },
47
54
  defaultVariants: { imageSide: 'end', ratio: '1:1', imageFit: 'contain', imageShape: 'rounded', imageBleed: 'none', stackOrder: 'text-first', sticky: false, align: 'start', tone: 'default' },
48
55
  })
@@ -53,11 +60,6 @@ const COLUMNS = {
53
60
  text: { '1:1': 'md:grid-cols-2', '5:7': 'md:grid-cols-[5fr_7fr]', '7:5': 'md:grid-cols-[7fr_5fr]' },
54
61
  image: { '1:1': 'md:grid-cols-2', '5:7': 'md:grid-cols-[7fr_5fr]', '7:5': 'md:grid-cols-[5fr_7fr]' },
55
62
  } as const
56
- // The viewport's edge, from the content's edge: what is left of the page's width, halved.
57
- const BLEED = {
58
- end: 'md:-me-[calc((100vw-min(var(--container-page),100vw-2*var(--spacing-gutter,2rem)))/2)]',
59
- start: 'md:-ms-[calc((100vw-min(var(--container-page),100vw-2*var(--spacing-gutter,2rem)))/2)]',
60
- } as const
61
63
 
62
64
  interface Feature {
63
65
  title: string
@@ -85,7 +87,8 @@ interface Props {
85
87
  imageSide?: 'start' | 'end' | undefined
86
88
  ratio?: '1:1' | '5:7' | '7:5' | undefined
87
89
  imageFit?: 'contain' | 'cover' | undefined
88
- 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
89
92
  imageBleed?: 'none' | 'edge' | undefined
90
93
  mediaKind?: 'image' | 'video' | undefined
91
94
  featureStyle?: 'check' | 'icon' | undefined
@@ -98,16 +101,18 @@ interface Props {
98
101
  id?: string | undefined
99
102
  }
100
103
 
101
- const { heading, headingLevel = 2, eyebrow, body, actions = [], image, features = [], videoUrl, playLabel, imageSide, ratio = '1:1', imageFit, imageShape, imageBleed, mediaKind, featureStyle = 'check', stackOrder, sticky = false, align, tone, measured, id } = Astro.props
104
+ const { heading, headingLevel = 2, eyebrow, body, actions = [], image, features = [], videoUrl, playLabel, imageSide, ratio = '1:1', imageFit, imageShape, imageBleed, mediaKind, featureStyle = 'check', stackOrder, sticky = false, align, tone, measured, id: givenId } = Astro.props
105
+ // Every section has an id, so a theme or a design layer can address this one: the page's, or `split`, `split-2`, … in render order.
106
+ const id = instanceId(Astro.locals, givenId, 'split')
102
107
  const s = split({ imageSide, ratio, imageFit, imageShape, imageBleed, stackOrder, sticky, align, tone })
103
108
  const Heading = `h${headingLevel}` as 'h2'
104
109
  const columns = COLUMNS[imageSide === 'start' ? 'image' : 'text'][ratio]
105
110
  const bleed = imageBleed === 'edge' ? BLEED[imageSide === 'start' ? 'start' : 'end'] : undefined
106
111
  const video = mediaKind === 'video' && videoUrl
107
- const headingId = id && heading ? `${id}-title` : undefined
112
+ const headingId = heading ? `${id}-title` : undefined
108
113
  const html = await renderMarkdown(body)
109
114
  ---
110
- <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId} class={bleed ? 'overflow-x-clip' : undefined}>
115
+ <Section kit="split" measured={measured} tone={tone} id={id} aria-labelledby={headingId} class={bleed ? 'overflow-x-clip' : undefined}>
111
116
  <div class:list={[s.grid(), columns]}>
112
117
  <div class={s.copy()}>
113
118
  {eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
@@ -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
  ]