@contentrain/astro-kit 0.35.0 → 0.37.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
@@ -32,6 +32,8 @@ They read its design tokens and pass its gates: `astro check` with the
32
32
  | `pagination` | WordPress-style `/page/N/` links | none |
33
33
  | `breadcrumb` | Ancestors, then the current page, optional JSON-LD | none |
34
34
  | `band` | The styled fallback section: heading, lead, image, a grid of items (figure, title, detail, text, points, link) and buttons, in the measured frame | none |
35
+ | `dialog` | A popup the visitor opens: a button and a modal `<dialog>` with text, buttons and a `form` slot; never opens by itself | a few lines, no dependency |
36
+ | `behavior` | A behavior island: a source widget no component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links, image lightbox), kept as its own markup and wired by one vanilla module; static and readable without it | `_shared/behaviors.ts`, no dependency |
35
37
 
36
38
  Each component lives in `components/<id>/`:
37
39
 
package/catalog.json CHANGED
@@ -446,6 +446,211 @@
446
446
  "actions[].style": "actions[].style"
447
447
  }
448
448
  },
449
+ {
450
+ "id": "behavior",
451
+ "name": "Behavior",
452
+ "description": "A behavior island: a source widget no kit component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links), kept as the site's own markup and wired by the kit's one small vanilla module. The markup is a rich-text field the writer has run through its island pass (no style, script, form or overlay; no source data-cr-*; ids prefixed); the data-cr-* attributes on the root come from props only. No script, or a refused selector: the island stays as printed, every item in reading order. Only pages with an island load the module.",
453
+ "category": "content",
454
+ "uses": [],
455
+ "shared": [
456
+ "Section.astro",
457
+ "behaviors.ts",
458
+ "measured.ts",
459
+ "safe-url.ts",
460
+ "types.ts"
461
+ ],
462
+ "variants": {
463
+ "tone": {
464
+ "options": [
465
+ "default",
466
+ "muted",
467
+ "inverse",
468
+ "accent"
469
+ ],
470
+ "default": "default",
471
+ "description": "Section background."
472
+ }
473
+ },
474
+ "props": {
475
+ "kind": {
476
+ "type": "select",
477
+ "required": true,
478
+ "content": false,
479
+ "options": [
480
+ "disclosure",
481
+ "tablist",
482
+ "carousel",
483
+ "dialog",
484
+ "countup",
485
+ "toggle",
486
+ "filter",
487
+ "anchor",
488
+ "lightbox"
489
+ ],
490
+ "description": "What the module makes of the markup: disclosure (aria-expanded/aria-controls, details), tablist (role=tab, arrow keys), carousel (a scroll-snap track over the items, previous/next), dialog (an opener and its panel as a modal), countup (numbers count up once in view), toggle (aria-pressed shows/hides), filter (data-filter buttons over the items), anchor (in-page links scroll). lightbox (a link to an image file opens the image in a modal)."
491
+ },
492
+ "html": {
493
+ "type": "richtext",
494
+ "label": "Content",
495
+ "description": "The widget's markup as the source rendered it, after the writer's island pass. Editable in Studio as HTML."
496
+ },
497
+ "items": {
498
+ "type": "string",
499
+ "content": false,
500
+ "description": "The engine's selector for the items inside the island (slides, counters, filtered cards)."
501
+ },
502
+ "title": {
503
+ "type": "string",
504
+ "content": false,
505
+ "description": "A tablist's tab selector, when the source's tabs are not role=tab."
506
+ },
507
+ "panel": {
508
+ "type": "string",
509
+ "content": false,
510
+ "description": "A tablist's panel selector, in tab order, when the tabs do not name their panels."
511
+ },
512
+ "fact": {
513
+ "type": "string",
514
+ "content": false,
515
+ "description": "The source behavior's fact id; the gates find the island by it (data-cr-fact)."
516
+ },
517
+ "previousLabel": {
518
+ "type": "string",
519
+ "default": "Previous",
520
+ "content": false,
521
+ "description": "Carousel button (ui-strings slider.prev)."
522
+ },
523
+ "nextLabel": {
524
+ "type": "string",
525
+ "default": "Next",
526
+ "content": false,
527
+ "description": "Carousel button (ui-strings slider.next)."
528
+ },
529
+ "closeLabel": {
530
+ "type": "string",
531
+ "default": "Close",
532
+ "content": false,
533
+ "description": "Dialog and lightbox close button (ui-strings dialog.close)."
534
+ },
535
+ "measured": {
536
+ "type": "object",
537
+ "content": false,
538
+ "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.",
539
+ "fields": {
540
+ "containerPx": {
541
+ "type": "integer",
542
+ "min": 0,
543
+ "max": 2560,
544
+ "description": "The frame's content width."
545
+ },
546
+ "padY": {
547
+ "type": "integer",
548
+ "min": 0,
549
+ "max": 400,
550
+ "description": "Top and bottom padding."
551
+ },
552
+ "padX": {
553
+ "type": "integer",
554
+ "min": 0,
555
+ "max": 200,
556
+ "description": "The frame's side padding (gutter)."
557
+ },
558
+ "bg": {
559
+ "type": "object",
560
+ "description": "The section's background.",
561
+ "fields": {
562
+ "color": {
563
+ "type": "string",
564
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
565
+ },
566
+ "image": {
567
+ "type": "string",
568
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
569
+ },
570
+ "overlay": {
571
+ "type": "string",
572
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
573
+ },
574
+ "size": {
575
+ "type": "string",
576
+ "description": "cover, contain or auto; anything else draws as cover."
577
+ },
578
+ "position": {
579
+ "type": "string",
580
+ "description": "One or two of center/top/bottom/left/right or percentages."
581
+ }
582
+ }
583
+ },
584
+ "columns": {
585
+ "type": "object",
586
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
587
+ "fields": {
588
+ "390": {
589
+ "type": "integer",
590
+ "min": 1,
591
+ "max": 6,
592
+ "description": "Phone."
593
+ },
594
+ "768": {
595
+ "type": "integer",
596
+ "min": 1,
597
+ "max": 6,
598
+ "description": "Tablet."
599
+ },
600
+ "1280": {
601
+ "type": "integer",
602
+ "min": 1,
603
+ "max": 6,
604
+ "description": "Desktop."
605
+ }
606
+ }
607
+ },
608
+ "gap": {
609
+ "type": "integer",
610
+ "min": 0,
611
+ "max": 200,
612
+ "description": "Gap between items."
613
+ },
614
+ "tone": {
615
+ "type": "select",
616
+ "options": [
617
+ "light",
618
+ "dark"
619
+ ],
620
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
621
+ },
622
+ "align": {
623
+ "type": "select",
624
+ "options": [
625
+ "start",
626
+ "center",
627
+ "end"
628
+ ],
629
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
630
+ },
631
+ "radius": {
632
+ "type": "integer",
633
+ "min": 0,
634
+ "max": 200,
635
+ "description": "Card and item corner radius."
636
+ },
637
+ "minHeight": {
638
+ "type": "integer",
639
+ "min": 0,
640
+ "max": 2000,
641
+ "description": "The section's minimum height."
642
+ }
643
+ }
644
+ }
645
+ },
646
+ "slots": {},
647
+ "js": "vanilla",
648
+ "dependencies": {},
649
+ "sources": {},
650
+ "files": [
651
+ "Behavior.astro"
652
+ ]
653
+ },
449
654
  {
450
655
  "id": "breadcrumb",
451
656
  "name": "Breadcrumb",
@@ -626,6 +831,11 @@
626
831
  "label": "Image description",
627
832
  "description": "What the image shows, for readers who cannot see it; empty when it is decoration."
628
833
  },
834
+ "icon": {
835
+ "type": "image",
836
+ "label": "Icon",
837
+ "description": "A small image above the title (decoration)."
838
+ },
629
839
  "eyebrow": {
630
840
  "type": "string",
631
841
  "label": "Eyebrow",
@@ -791,6 +1001,7 @@
791
1001
  "items[].linkLabel": "items[].link_label",
792
1002
  "items[].image": "items[].image",
793
1003
  "items[].imageAlt": "items[].image_alt",
1004
+ "items[].icon": "items[].icon",
794
1005
  "items[].eyebrow": "items[].eyebrow"
795
1006
  }
796
1007
  },
@@ -1953,9 +2164,27 @@
1953
2164
  "default": false,
1954
2165
  "content": false,
1955
2166
  "description": "Rendered already open, in the flow of the section and not modal: for previews and visual baselines only, never for a page."
2167
+ },
2168
+ "trigger": {
2169
+ "type": "select",
2170
+ "options": [
2171
+ "click",
2172
+ "load",
2173
+ "exit",
2174
+ "scroll"
2175
+ ],
2176
+ "content": false,
2177
+ "description": "How the source popup opened. Here it always opens by its button, never by itself; the value stays on the root for the gates and the receipt, which say the change."
2178
+ },
2179
+ "fact": {
2180
+ "type": "string",
2181
+ "content": false,
2182
+ "description": "The source behavior's fact id; the gates find the dialog by it (data-cr-fact)."
1956
2183
  }
1957
2184
  },
1958
- "slots": {},
2185
+ "slots": {
2186
+ "form": "The popup's form, under its text and buttons: the starter's form (Studio) or its link to the form on WordPress."
2187
+ },
1959
2188
  "js": "vanilla",
1960
2189
  "dependencies": {},
1961
2190
  "sources": {
@@ -2369,6 +2598,7 @@
2369
2598
  "category": "section",
2370
2599
  "uses": [],
2371
2600
  "shared": [
2601
+ "KitImage.astro",
2372
2602
  "Section.astro",
2373
2603
  "measured.ts",
2374
2604
  "safe-url.ts",
@@ -2447,6 +2677,11 @@
2447
2677
  "chat"
2448
2678
  ],
2449
2679
  "description": "The kit icon shown with the icon style; check when absent."
2680
+ },
2681
+ "iconImage": {
2682
+ "type": "image",
2683
+ "label": "Icon image",
2684
+ "description": "The feature's own icon as an image file (the old site's icon, moved as an svg): drawn instead of the named icon; the check style ignores it."
2450
2685
  }
2451
2686
  }
2452
2687
  },
@@ -2614,7 +2849,8 @@
2614
2849
  "items": "items",
2615
2850
  "items[].title": "items[].title",
2616
2851
  "items[].text": "items[].text",
2617
- "items[].icon": "items[].icon"
2852
+ "items[].icon": "items[].icon",
2853
+ "items[].iconImage": "items[].icon_image"
2618
2854
  }
2619
2855
  },
2620
2856
  {
@@ -0,0 +1,242 @@
1
+ // Behavior islands: the source site's own markup for a widget no kit component carries (a slider, tabs, an
2
+ // accordion, a counter, a filter, an image lightbox), kept as content and made to work again by this one small module. The markup is
3
+ // the content; the behavior is ours: no source library, no jQuery, nothing the source shipped runs.
4
+ //
5
+ // Each island is a root the Behavior component prints: `data-cr-behavior` names the kind, and the item, title and
6
+ // panel selectors come from the engine (`data-cr-items`, `data-cr-title`, `data-cr-panel`), never from the source
7
+ // (the writer strips every `data-cr-*` the source markup carried before it wraps it). The module reads only ARIA,
8
+ // `details`, `hidden` and those attributes.
9
+ //
10
+ // Rules (t13's PR-E checklist §4): text goes in through `textContent` only, never `innerHTML` or its family; no
11
+ // `eval`, `Function`, `fetch` or `location`; every selector is scoped to its island root and run in try/catch (a
12
+ // selector the browser refuses leaves that island static); `init` is idempotent; anything that fails leaves the
13
+ // island as it was printed: readable, every item in reading order. Reduced motion is honoured.
14
+
15
+ type Kind = 'disclosure' | 'tablist' | 'carousel' | 'dialog' | 'countup' | 'toggle' | 'filter' | 'anchor' | 'lightbox'
16
+
17
+ const KINDS: ReadonlySet<string> = new Set<Kind>(['disclosure', 'tablist', 'carousel', 'dialog', 'countup', 'toggle', 'filter', 'anchor', 'lightbox'])
18
+ /** Past this many items an island wires the first ones; the rest stay visible as printed (said on the receipt). */
19
+ const MAX_ITEMS = 50
20
+
21
+ const reduced = (): boolean => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
22
+
23
+ /** Elements under the island root for a selector the engine wrote; nothing (never a throw) for a bad selector. */
24
+ function within(root: HTMLElement, selector: string | undefined): HTMLElement[] {
25
+ if (!selector) return []
26
+ try { return [...root.querySelectorAll<HTMLElement>(selector)].slice(0, MAX_ITEMS) }
27
+ catch { return [] }
28
+ }
29
+
30
+ /** The element an `aria-controls` / `href="#…"` names, looked up inside the island only (ids are island-prefixed). */
31
+ function target(root: HTMLElement, id: string | null): HTMLElement | null {
32
+ if (!id) return null
33
+ try { return root.querySelector<HTMLElement>(`#${CSS.escape(id.replace(/^#/, ''))}`) }
34
+ catch { return null }
35
+ }
36
+
37
+ function disclosure(root: HTMLElement): void {
38
+ for (const button of within(root, 'button[aria-expanded][aria-controls], [role="button"][aria-expanded][aria-controls]')) {
39
+ const panel = target(root, button.getAttribute('aria-controls'))
40
+ if (!panel) continue
41
+ const set = (open: boolean) => { button.setAttribute('aria-expanded', String(open)); panel.hidden = !open }
42
+ set(button.getAttribute('aria-expanded') === 'true')
43
+ button.addEventListener('click', () => set(button.getAttribute('aria-expanded') !== 'true'))
44
+ }
45
+ // `details`/`summary` already open and close in the browser.
46
+ }
47
+
48
+ function tablist(root: HTMLElement): void {
49
+ const tabs = within(root, root.dataset.crTitle ?? '[role="tab"]')
50
+ const panels = tabs.map(tab => target(root, tab.getAttribute('aria-controls')))
51
+ const fallback = within(root, root.dataset.crPanel)
52
+ const panelOf = (i: number): HTMLElement | null => panels[i] ?? fallback[i] ?? null
53
+ if (tabs.length < 2 || tabs.some((_, i) => !panelOf(i))) return
54
+ const select = (index: number, focus = false) => {
55
+ tabs.forEach((tab, i) => {
56
+ const on = i === index
57
+ tab.setAttribute('role', 'tab')
58
+ tab.setAttribute('aria-selected', String(on))
59
+ tab.tabIndex = on ? 0 : -1
60
+ panelOf(i)!.hidden = !on
61
+ })
62
+ if (focus) tabs[index]!.focus()
63
+ }
64
+ const first = Math.max(0, tabs.findIndex(tab => tab.getAttribute('aria-selected') === 'true'))
65
+ select(first)
66
+ tabs.forEach((tab, i) => {
67
+ tab.addEventListener('click', (event) => { event.preventDefault(); select(i) })
68
+ tab.addEventListener('keydown', (event) => {
69
+ const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key]
70
+ if (step) { event.preventDefault(); select((i + step + tabs.length) % tabs.length, true) }
71
+ else if (event.key === 'Home') { event.preventDefault(); select(0, true) }
72
+ else if (event.key === 'End') { event.preventDefault(); select(tabs.length - 1, true) }
73
+ })
74
+ })
75
+ }
76
+
77
+ function control(label: string, onClick: () => void): HTMLButtonElement {
78
+ const button = document.createElement('button')
79
+ button.type = 'button'
80
+ button.textContent = label
81
+ button.className = 'cr-behavior-control'
82
+ button.addEventListener('click', onClick)
83
+ return button
84
+ }
85
+
86
+ function carousel(root: HTMLElement): void {
87
+ const items = within(root, root.dataset.crItems)
88
+ const track = items[0]?.parentElement
89
+ // Past MAX_ITEMS (or other children in the track) the carousel stays static: never squeeze unwired slides into the row.
90
+ if (items.length < 2 || !track || !items.every(item => item.parentElement === track) || track.children.length !== items.length) return
91
+ // A scroll-snap track: the browser moves it (swipe, keys, scroll); the buttons only scroll it. No timer: a slider
92
+ // that advanced by itself on the old site waits for the visitor here (said on the receipt).
93
+ Object.assign(track.style, { display: 'flex', overflowX: 'auto', scrollSnapType: 'x mandatory', scrollBehavior: reduced() ? 'auto' : 'smooth' })
94
+ for (const item of items) Object.assign(item.style, { flex: '0 0 100%', scrollSnapAlign: 'start' })
95
+ track.setAttribute('tabindex', '0')
96
+ const go = (step: number) => track.scrollBy({ left: step * track.clientWidth })
97
+ const bar = document.createElement('div')
98
+ bar.className = 'cr-behavior-controls'
99
+ bar.append(control(root.dataset.crPrev ?? 'Previous', () => go(-1)), control(root.dataset.crNext ?? 'Next', () => go(1)))
100
+ track.after(bar)
101
+ }
102
+
103
+ function dialog(root: HTMLElement): void {
104
+ for (const opener of within(root, '[aria-haspopup="dialog"][aria-controls]')) {
105
+ const content = target(root, opener.getAttribute('aria-controls'))
106
+ if (!content || content.closest('dialog')) continue
107
+ const box = document.createElement('dialog')
108
+ box.className = 'cr-behavior-dialog'
109
+ content.before(box)
110
+ box.append(content)
111
+ content.hidden = false
112
+ box.append(control(root.dataset.crClose ?? 'Close', () => box.close()))
113
+ opener.addEventListener('click', (event) => { event.preventDefault(); box.showModal() })
114
+ box.addEventListener('click', (event) => { if (event.target === box) box.close() })
115
+ }
116
+ }
117
+
118
+ /** The first text node under `item` holding a number: the digits a counter shows, beside its icon or suffix spans. */
119
+ function digitsOf(item: HTMLElement): { node: Text, digits: string } | null {
120
+ const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT)
121
+ for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
122
+ const match = /\d[\d.,]*/.exec(node.data)
123
+ if (match) return { node, digits: match[0] }
124
+ }
125
+ return null
126
+ }
127
+
128
+ function countup(root: HTMLElement): void {
129
+ const items = within(root, root.dataset.crItems)
130
+ if (reduced() || typeof IntersectionObserver !== 'function') return
131
+ for (const item of items) {
132
+ const found = digitsOf(item)
133
+ if (!found) continue
134
+ const { node, digits } = found
135
+ const text = node.data
136
+ const end = Number(digits.replace(/[.,](?=\d{3}\b)/g, '').replace(',', '.'))
137
+ if (!Number.isFinite(end) || end <= 0) continue
138
+ const fraction = digits.split(/[.,]/)[1] ?? ''
139
+ const decimals = fraction.length > 2 ? 0 : fraction.length
140
+ const seen = new IntersectionObserver((entries) => {
141
+ if (!entries.some(e => e.isIntersecting)) return
142
+ seen.disconnect()
143
+ const start = performance.now()
144
+ // Only the digits' own text node changes: an icon, a unit or a suffix span beside it stays as printed.
145
+ const tick = (now: number) => {
146
+ const t = Math.min(1, (now - start) / 1200)
147
+ node.data = text.replace(digits, t < 1 ? (end * t).toFixed(decimals) : digits)
148
+ if (t < 1) requestAnimationFrame(tick)
149
+ }
150
+ requestAnimationFrame(tick)
151
+ })
152
+ seen.observe(item)
153
+ }
154
+ }
155
+
156
+ function toggle(root: HTMLElement): void {
157
+ for (const button of within(root, 'button[aria-pressed][aria-controls]')) {
158
+ const panel = target(root, button.getAttribute('aria-controls'))
159
+ if (!panel) continue
160
+ const set = (on: boolean) => { button.setAttribute('aria-pressed', String(on)); panel.hidden = !on }
161
+ set(button.getAttribute('aria-pressed') === 'true')
162
+ button.addEventListener('click', () => set(button.getAttribute('aria-pressed') !== 'true'))
163
+ }
164
+ }
165
+
166
+ /** What an item is filed under: its classes and its `data-category` / `data-filter` words. */
167
+ const tokens = (el: HTMLElement): string[] => `${el.className} ${el.dataset.category ?? ''} ${el.dataset.filter ?? ''}`.split(/[\s,]+/).filter(Boolean)
168
+
169
+ function filter(root: HTMLElement): void {
170
+ const items = within(root, root.dataset.crItems)
171
+ const buttons = within(root, '[data-filter]')
172
+ if (!items.length || !buttons.length) return
173
+ for (const button of buttons) {
174
+ button.addEventListener('click', (event) => {
175
+ event.preventDefault()
176
+ const want = (button.dataset.filter ?? '').replace(/^\./, '')
177
+ for (const b of buttons) b.setAttribute('aria-pressed', String(b === button))
178
+ for (const item of items) item.hidden = !(want === '' || want === '*' || want === 'all' || tokens(item).includes(want))
179
+ })
180
+ }
181
+ }
182
+
183
+ function anchor(root: HTMLElement): void {
184
+ for (const link of within(root, 'a[href^="#"]')) {
185
+ // The island's own (prefixed) target first; the page's only when the island has none.
186
+ const id = link.getAttribute('href')!.slice(1)
187
+ const to = target(root, id) ?? (id ? document.getElementById(id) : null)
188
+ if (!to) continue
189
+ link.addEventListener('click', (event) => { event.preventDefault(); to.scrollIntoView({ behavior: reduced() ? 'auto' : 'smooth' }) })
190
+ }
191
+ }
192
+
193
+ /** A link to an image file, as a source lightbox wrapped one (`a[href$=.jpg] > img`): http(s) or same-site only. */
194
+ function imageHref(link: HTMLAnchorElement): string | null {
195
+ let url: URL
196
+ try { url = new URL(link.getAttribute('href') ?? '', document.baseURI) }
197
+ catch { return null }
198
+ return (url.protocol === 'https:' || url.protocol === 'http:') && /\.(?:avif|gif|jpe?g|png|webp)$/i.test(url.pathname) ? url.href : null
199
+ }
200
+
201
+ function lightbox(root: HTMLElement): void {
202
+ let box: HTMLDialogElement | null = null
203
+ let image: HTMLImageElement | null = null
204
+ for (const link of within(root, 'a[href]') as HTMLAnchorElement[]) {
205
+ const thumb = link.querySelector('img')
206
+ const href = thumb ? imageHref(link) : null
207
+ if (!thumb || !href) continue
208
+ link.addEventListener('click', (event) => {
209
+ event.preventDefault()
210
+ if (!box || !image) {
211
+ box = document.createElement('dialog')
212
+ box.className = 'cr-behavior-dialog cr-behavior-lightbox'
213
+ image = document.createElement('img')
214
+ box.append(image, control(root.dataset.crClose ?? 'Close', () => box!.close()))
215
+ box.addEventListener('click', (e) => { if (e.target === box) box!.close() })
216
+ root.append(box)
217
+ }
218
+ image.src = href
219
+ image.alt = thumb.alt
220
+ box.showModal()
221
+ })
222
+ }
223
+ }
224
+
225
+ const WIRE: Record<Kind, (root: HTMLElement) => void> = { disclosure, tablist, carousel, dialog, countup, toggle, filter, anchor, lightbox }
226
+
227
+ /** Wires every island on the page once. Safe to call again (a view transition, a second script copy). */
228
+ export function initBehaviors(scope: ParentNode = document): void {
229
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-cr-behavior]')) {
230
+ const kind = root.dataset.crBehavior ?? ''
231
+ // A root inside another root is not ours: the component prints one root per island, so a nested one came with the
232
+ // markup (a spoofed island the writer's pass would strip). Never wired, so its selectors never run.
233
+ if (root.dataset.crReady !== undefined || !KINDS.has(kind) || root.parentElement?.closest('[data-cr-behavior]')) continue
234
+ try {
235
+ WIRE[kind as Kind](root)
236
+ root.dataset.crReady = ''
237
+ }
238
+ catch {
239
+ // Left as printed: the content is all there, it just does not move.
240
+ }
241
+ }
242
+ }
@@ -0,0 +1,71 @@
1
+ ---
2
+ // A behavior island: a widget of the source site that no kit component carries (a slider, tabs, an accordion, a
3
+ // counter, a filter), kept as the site's own markup and made to work again by the kit's one small module
4
+ // (`_shared/behaviors.ts`). The markup is content, a rich-text field the writer already ran through the island
5
+ // pass (no style element, no script, no form, no overlay, no `data-cr-*` of its own, ids prefixed); the behavior
6
+ // is ours. The `data-cr-*` attributes on the root are the only ones on the page, and they come from these props.
7
+ //
8
+ // With no script, or a selector the browser refuses, the island stays as printed: every item in reading order,
9
+ // nothing hidden. Only a page that has an island loads the module (Astro bundles a component's script once, for the
10
+ // pages that use it).
11
+ import Section from '../_shared/Section.astro'
12
+ import type { MeasuredStyle } from '../_shared/types'
13
+
14
+ interface Props {
15
+ /** What the module makes of it. */
16
+ kind: 'disclosure' | 'tablist' | 'carousel' | 'dialog' | 'countup' | 'toggle' | 'filter' | 'anchor' | 'lightbox'
17
+ /** The island's markup (rendered HTML of a richtext field, after the writer's island pass). */
18
+ html?: string | undefined
19
+ /** The engine's selectors inside the island: the items (slides, counters, filtered cards), a tab's title, a panel. */
20
+ items?: string | undefined
21
+ title?: string | undefined
22
+ panel?: string | undefined
23
+ /** The source behavior's fact id: the gates find this element by it (`data-cr-fact`). */
24
+ fact?: string | undefined
25
+ /** Button labels the module writes (ui-strings slider.prev / slider.next / dialog.close). */
26
+ previousLabel?: string | undefined
27
+ nextLabel?: string | undefined
28
+ closeLabel?: string | undefined
29
+ tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
30
+ /** The source section's measured style (`MeasuredStyle`). */
31
+ measured?: MeasuredStyle | undefined
32
+ }
33
+
34
+ const { kind, html, items, title, panel, fact, previousLabel, nextLabel, closeLabel, tone, measured } = Astro.props
35
+ // Selectors and ids go into attributes only, never into markup or a script; an empty one is left out.
36
+ const attr = (value: string | undefined): string | undefined => (value && value.length <= 300 ? value : undefined)
37
+ const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
38
+ ---
39
+ {html && (
40
+ <Section tone={tone} measured={measured}>
41
+ <div
42
+ class="cr-behavior"
43
+ data-cr-behavior={kind}
44
+ data-cr-items={attr(items)}
45
+ data-cr-title={attr(title)}
46
+ data-cr-panel={attr(panel)}
47
+ data-cr-fact={factId}
48
+ data-cr-prev={previousLabel}
49
+ data-cr-next={nextLabel}
50
+ data-cr-close={closeLabel}
51
+ set:html={html}
52
+ />
53
+ </Section>
54
+ )}
55
+
56
+ <script>
57
+ import { initBehaviors } from '../_shared/behaviors'
58
+
59
+ initBehaviors()
60
+ // With Astro's client router a page arrives without a reload: wire its islands too (ready roots are skipped).
61
+ document.addEventListener('astro:page-load', () => initBehaviors())
62
+ </script>
63
+
64
+ <style is:global>
65
+ .cr-behavior-controls { display: flex; gap: 0.5rem; justify-content: center; margin-block-start: 1rem; }
66
+ .cr-behavior-control { cursor: pointer; border: 1px solid currentColor; border-radius: var(--radius-control, 0.375rem); padding: 0.375rem 0.875rem; background: transparent; }
67
+ .cr-behavior-control:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
68
+ .cr-behavior-dialog { margin: auto; max-width: min(92vw, 40rem); max-height: 85dvh; border: 1px solid var(--color-line); border-radius: var(--radius-card, 0.75rem); padding: 1.5rem; }
69
+ .cr-behavior-dialog::backdrop { background: rgb(0 0 0 / 0.5); }
70
+ .cr-behavior-lightbox img { display: block; max-width: 100%; max-height: 75dvh; margin-inline: auto; }
71
+ </style>
@@ -0,0 +1,54 @@
1
+ [
2
+ {
3
+ "name": "carousel",
4
+ "props": {
5
+ "kind": "carousel",
6
+ "items": ".slide",
7
+ "fact": "interactive:fx1",
8
+ "html": "<div class=\"slides\"><div class=\"slide\"><h2>First</h2><p>One of three slides.</p></div><div class=\"slide\"><h2>Second</h2><p>Two of three.</p></div><div class=\"slide\"><h2>Third</h2><p>Three of three.</p></div></div>"
9
+ }
10
+ },
11
+ {
12
+ "name": "tablist",
13
+ "props": {
14
+ "kind": "tablist",
15
+ "fact": "interactive:fx2",
16
+ "html": "<div role=\"tablist\"><button role=\"tab\" aria-controls=\"cr-fx2-a\" aria-selected=\"true\">Plans</button><button role=\"tab\" aria-controls=\"cr-fx2-b\">Support</button></div><div id=\"cr-fx2-a\"><p>Every plan includes hosting.</p></div><div id=\"cr-fx2-b\"><p>Support answers within a day.</p></div>"
17
+ }
18
+ },
19
+ {
20
+ "name": "disclosure",
21
+ "props": {
22
+ "kind": "disclosure",
23
+ "fact": "interactive:fx3",
24
+ "tone": "muted",
25
+ "html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx3-a\">Do you ship abroad?</button></h2><div id=\"cr-fx3-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
26
+ }
27
+ },
28
+ {
29
+ "name": "nested-spoof",
30
+ "props": {
31
+ "kind": "disclosure",
32
+ "fact": "interactive:fx4",
33
+ "html": "<h2><button aria-expanded=\"true\" aria-controls=\"cr-fx4-a\">Opening hours</button></h2><div id=\"cr-fx4-a\"><p>Monday to Friday.</p><div data-cr-behavior=\"carousel\" data-cr-items=\"body *\"><p>A spoofed inner root: never wired, printed as text.</p></div></div>"
34
+ }
35
+ },
36
+ {
37
+ "name": "disclosure-inverse",
38
+ "props": {
39
+ "kind": "disclosure",
40
+ "fact": "interactive:fx3",
41
+ "tone": "inverse",
42
+ "html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx3-a\">Do you ship abroad?</button></h2><div id=\"cr-fx3-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
43
+ }
44
+ },
45
+ {
46
+ "name": "disclosure-accent",
47
+ "props": {
48
+ "kind": "disclosure",
49
+ "fact": "interactive:fx3",
50
+ "tone": "accent",
51
+ "html": "<h2><button aria-expanded=\"false\" aria-controls=\"cr-fx3-a\">Do you ship abroad?</button></h2><div id=\"cr-fx3-a\"><p>Yes, to the whole of Europe.</p></div><details><summary>Can I return an order?</summary><p>Within 30 days.</p></details>"
52
+ }
53
+ }
54
+ ]
@@ -0,0 +1,202 @@
1
+ {
2
+ "id": "behavior",
3
+ "name": "Behavior",
4
+ "description": "A behavior island: a source widget no kit component carries (slider, tabs, accordion, counter, toggle, filter, in-page dialog, anchor links), kept as the site's own markup and wired by the kit's one small vanilla module. The markup is a rich-text field the writer has run through its island pass (no style, script, form or overlay; no source data-cr-*; ids prefixed); the data-cr-* attributes on the root come from props only. No script, or a refused selector: the island stays as printed, every item in reading order. Only pages with an island load the module.",
5
+ "category": "content",
6
+ "uses": [],
7
+ "shared": [
8
+ "Section.astro",
9
+ "behaviors.ts",
10
+ "measured.ts",
11
+ "safe-url.ts",
12
+ "types.ts"
13
+ ],
14
+ "variants": {
15
+ "tone": {
16
+ "options": [
17
+ "default",
18
+ "muted",
19
+ "inverse",
20
+ "accent"
21
+ ],
22
+ "default": "default",
23
+ "description": "Section background."
24
+ }
25
+ },
26
+ "props": {
27
+ "kind": {
28
+ "type": "select",
29
+ "required": true,
30
+ "content": false,
31
+ "options": [
32
+ "disclosure",
33
+ "tablist",
34
+ "carousel",
35
+ "dialog",
36
+ "countup",
37
+ "toggle",
38
+ "filter",
39
+ "anchor",
40
+ "lightbox"
41
+ ],
42
+ "description": "What the module makes of the markup: disclosure (aria-expanded/aria-controls, details), tablist (role=tab, arrow keys), carousel (a scroll-snap track over the items, previous/next), dialog (an opener and its panel as a modal), countup (numbers count up once in view), toggle (aria-pressed shows/hides), filter (data-filter buttons over the items), anchor (in-page links scroll). lightbox (a link to an image file opens the image in a modal)."
43
+ },
44
+ "html": {
45
+ "type": "richtext",
46
+ "label": "Content",
47
+ "description": "The widget's markup as the source rendered it, after the writer's island pass. Editable in Studio as HTML."
48
+ },
49
+ "items": {
50
+ "type": "string",
51
+ "content": false,
52
+ "description": "The engine's selector for the items inside the island (slides, counters, filtered cards)."
53
+ },
54
+ "title": {
55
+ "type": "string",
56
+ "content": false,
57
+ "description": "A tablist's tab selector, when the source's tabs are not role=tab."
58
+ },
59
+ "panel": {
60
+ "type": "string",
61
+ "content": false,
62
+ "description": "A tablist's panel selector, in tab order, when the tabs do not name their panels."
63
+ },
64
+ "fact": {
65
+ "type": "string",
66
+ "content": false,
67
+ "description": "The source behavior's fact id; the gates find the island by it (data-cr-fact)."
68
+ },
69
+ "previousLabel": {
70
+ "type": "string",
71
+ "default": "Previous",
72
+ "content": false,
73
+ "description": "Carousel button (ui-strings slider.prev)."
74
+ },
75
+ "nextLabel": {
76
+ "type": "string",
77
+ "default": "Next",
78
+ "content": false,
79
+ "description": "Carousel button (ui-strings slider.next)."
80
+ },
81
+ "closeLabel": {
82
+ "type": "string",
83
+ "default": "Close",
84
+ "content": false,
85
+ "description": "Dialog and lightbox close button (ui-strings dialog.close)."
86
+ },
87
+ "measured": {
88
+ "type": "object",
89
+ "content": false,
90
+ "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.",
91
+ "fields": {
92
+ "containerPx": {
93
+ "type": "integer",
94
+ "min": 0,
95
+ "max": 2560,
96
+ "description": "The frame's content width."
97
+ },
98
+ "padY": {
99
+ "type": "integer",
100
+ "min": 0,
101
+ "max": 400,
102
+ "description": "Top and bottom padding."
103
+ },
104
+ "padX": {
105
+ "type": "integer",
106
+ "min": 0,
107
+ "max": 200,
108
+ "description": "The frame's side padding (gutter)."
109
+ },
110
+ "bg": {
111
+ "type": "object",
112
+ "description": "The section's background.",
113
+ "fields": {
114
+ "color": {
115
+ "type": "string",
116
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
117
+ },
118
+ "image": {
119
+ "type": "string",
120
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
121
+ },
122
+ "overlay": {
123
+ "type": "string",
124
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
125
+ },
126
+ "size": {
127
+ "type": "string",
128
+ "description": "cover, contain or auto; anything else draws as cover."
129
+ },
130
+ "position": {
131
+ "type": "string",
132
+ "description": "One or two of center/top/bottom/left/right or percentages."
133
+ }
134
+ }
135
+ },
136
+ "columns": {
137
+ "type": "object",
138
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
139
+ "fields": {
140
+ "390": {
141
+ "type": "integer",
142
+ "min": 1,
143
+ "max": 6,
144
+ "description": "Phone."
145
+ },
146
+ "768": {
147
+ "type": "integer",
148
+ "min": 1,
149
+ "max": 6,
150
+ "description": "Tablet."
151
+ },
152
+ "1280": {
153
+ "type": "integer",
154
+ "min": 1,
155
+ "max": 6,
156
+ "description": "Desktop."
157
+ }
158
+ }
159
+ },
160
+ "gap": {
161
+ "type": "integer",
162
+ "min": 0,
163
+ "max": 200,
164
+ "description": "Gap between items."
165
+ },
166
+ "tone": {
167
+ "type": "select",
168
+ "options": [
169
+ "light",
170
+ "dark"
171
+ ],
172
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
173
+ },
174
+ "align": {
175
+ "type": "select",
176
+ "options": [
177
+ "start",
178
+ "center",
179
+ "end"
180
+ ],
181
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
182
+ },
183
+ "radius": {
184
+ "type": "integer",
185
+ "min": 0,
186
+ "max": 200,
187
+ "description": "Card and item corner radius."
188
+ },
189
+ "minHeight": {
190
+ "type": "integer",
191
+ "min": 0,
192
+ "max": 2000,
193
+ "description": "The section's minimum height."
194
+ }
195
+ }
196
+ }
197
+ },
198
+ "slots": {},
199
+ "js": "vanilla",
200
+ "dependencies": {},
201
+ "sources": {}
202
+ }
@@ -42,6 +42,8 @@ interface Item {
42
42
  /** The image's address (or, until 0.7, an `ImageInput`). */
43
43
  image?: ImageSource | undefined
44
44
  imageAlt?: string | undefined
45
+ /** A small image (an icon) above the title: decoration, so it has no alt (an icon box's svg, a feature's mark). */
46
+ icon?: string | undefined
45
47
  eyebrow?: string | undefined
46
48
  }
47
49
 
@@ -79,6 +81,7 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
79
81
  {items.map(item => (
80
82
  <li class={card()}>
81
83
  {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>}
84
+ {item.icon && <KitImage image={{ src: item.icon, alt: '' }} class="h-10 w-10 object-contain" />}
82
85
  {item.eyebrow && <p class="text-sm font-medium text-accent-text">{item.eyebrow}</p>}
83
86
  <CardHeading class={cardTitle()}>
84
87
  {item.href && !item.linkLabel
@@ -4,5 +4,6 @@
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
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": "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"}]}},
7
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
9
  ]
@@ -99,6 +99,11 @@
99
99
  "label": "Image description",
100
100
  "description": "What the image shows, for readers who cannot see it; empty when it is decoration."
101
101
  },
102
+ "icon": {
103
+ "type": "image",
104
+ "label": "Icon",
105
+ "description": "A small image above the title (decoration)."
106
+ },
102
107
  "eyebrow": {
103
108
  "type": "string",
104
109
  "label": "Eyebrow",
@@ -1,6 +1,6 @@
1
1
  ---
2
- // A popup, as a dialog the visitor opens: a button in a section of the page and a modal <dialog> with a heading, text
3
- // and buttons. Replaces Elementor popups and Popup Maker overlays. Never opens by itself (no timers, no exit intent):
2
+ // A popup, as a dialog the visitor opens: a button in a section of the page and a modal <dialog> with a heading, text,
3
+ // buttons and, in its `form` slot, the popup's form. Replaces Elementor popups and Popup Maker overlays. Never opens by itself (no timers, no exit intent):
4
4
  // content that a source popup showed on load or on scroll is reachable from the button instead, and sits in the HTML
5
5
  // for crawlers. The section is the kit's (Section: tone, spacing, measured style), like every other section.
6
6
  //
@@ -62,9 +62,17 @@ interface Props {
62
62
  measured?: MeasuredStyle | undefined
63
63
  /** Rendered already open, in the flow of the section and not modal: for previews and visual baselines only, never for a page. */
64
64
  open?: boolean | undefined
65
+ /**
66
+ * How the source popup opened (`load`, `exit`, `scroll`: by itself; `click`: by its trigger). Here it always opens by
67
+ * the button, never by itself; the value is kept on the root for the gates and the receipt, which say the change.
68
+ */
69
+ trigger?: 'click' | 'load' | 'exit' | 'scroll' | undefined
70
+ /** The source behavior's fact id: the gates find the dialog by it (`data-cr-fact`). */
71
+ fact?: string | undefined
65
72
  }
66
73
 
67
- const { id: givenId = 'dialog', triggerLabel, heading, html, actions = [], closeLabel = 'Close', triggerStyle, size, align: given, tone, measured, open = false } = Astro.props
74
+ const { id: givenId = 'dialog', triggerLabel, heading, html, actions = [], closeLabel = 'Close', triggerStyle, size, align: given, tone, measured, open = false, trigger, fact } = Astro.props
75
+ const factId = fact && /^[\w:.-]{1,120}$/.test(fact) ? fact : undefined
68
76
  // The id goes into attributes and into the scripting-off style sheet, so it is a plain token or the default.
69
77
  const id = /^[A-Za-z][\w-]*$/.test(givenId) ? givenId : 'dialog'
70
78
  const align = given ?? measured?.align
@@ -80,7 +88,7 @@ const invoker = { command: 'show-modal', commandfor: id }
80
88
  const noscript = `<style>[data-kit-dialog="${id}"]>[data-kit-dialog-open],#${id} form[method=dialog]{display:none}#${id}:not([open]){display:block;position:static;margin:0;width:100%;max-width:none;max-height:none;box-shadow:none}</style>`
81
89
  ---
82
90
  <Section measured={measured} tone={tone} spacing="md" id={`${id}-section`}>
83
- <div class={s.root()} data-kit-dialog={id}>
91
+ <div class={s.root()} data-kit-dialog={id} data-kit-trigger={trigger} data-cr-fact={factId}>
84
92
  <noscript set:html={noscript} />
85
93
  <button type="button" class={s.trigger()} data-kit-dialog-open aria-haspopup="dialog" {...invoker}>{triggerLabel}</button>
86
94
  <dialog id={id} class={s.box()} open={open || undefined} aria-labelledby={heading ? headingId : undefined} aria-label={heading ? undefined : triggerLabel}>
@@ -88,6 +96,8 @@ const noscript = `<style>[data-kit-dialog="${id}"]>[data-kit-dialog-open],#${id}
88
96
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
89
97
  {html && <Prose html={html} searchable={false} />}
90
98
  {actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} />)}</div>}
99
+ {/* The popup's form (a source popup often held the contact or newsletter form): the starter's form, or its link to the form on WordPress. */}
100
+ {Astro.slots.has('form') && <div data-kit-dialog-form><slot name="form" /></div>}
91
101
  </div>
92
102
  <form method="dialog">
93
103
  <button type="submit" class={s.close()} aria-label={closeLabel}>
@@ -235,9 +235,27 @@
235
235
  "default": false,
236
236
  "content": false,
237
237
  "description": "Rendered already open, in the flow of the section and not modal: for previews and visual baselines only, never for a page."
238
+ },
239
+ "trigger": {
240
+ "type": "select",
241
+ "options": [
242
+ "click",
243
+ "load",
244
+ "exit",
245
+ "scroll"
246
+ ],
247
+ "content": false,
248
+ "description": "How the source popup opened. Here it always opens by its button, never by itself; the value stays on the root for the gates and the receipt, which say the change."
249
+ },
250
+ "fact": {
251
+ "type": "string",
252
+ "content": false,
253
+ "description": "The source behavior's fact id; the gates find the dialog by it (data-cr-fact)."
238
254
  }
239
255
  },
240
- "slots": {},
256
+ "slots": {
257
+ "form": "The popup's form, under its text and buttons: the starter's form (Studio) or its link to the form on WordPress."
258
+ },
241
259
  "js": "vanilla",
242
260
  "dependencies": {},
243
261
  "sources": {
@@ -2,6 +2,7 @@
2
2
  // A list of short features — a title and a line of text each — marked with
3
3
  // the feature's icon or a shared check, under an optional heading and intro.
4
4
  import { tv } from 'tailwind-variants'
5
+ import KitImage from '../_shared/KitImage.astro'
5
6
  import Section, { type Tone } from '../_shared/Section.astro'
6
7
  import { measuredGrid } from '../_shared/measured'
7
8
  import type { MeasuredStyle } from '../_shared/types'
@@ -51,6 +52,8 @@ interface Item {
51
52
  text?: string | undefined
52
53
  /** A kit icon name (check, star, bolt, shield, heart, globe, clock, chat). */
53
54
  icon?: string | undefined
55
+ /** The feature's own icon as an image file (a site's svg): drawn instead of the named icon, except by `check`. */
56
+ iconImage?: string | undefined
54
57
  }
55
58
 
56
59
  interface Props {
@@ -74,6 +77,8 @@ const { heading, intro, items, columns, style = 'icon', tone, bare = false, titl
74
77
  const s = list({ columns, style, tone, bare })
75
78
  const headingId = heading && id ? `${id}-title` : undefined
76
79
  const pathOf = (item: Item) => (style !== 'check' && item.icon && icons[item.icon]) || icons.check
80
+ // The feature's own image icon, where the style shows each feature's own mark.
81
+ const imageOf = (item: Item) => (style !== 'check' && item.iconImage) || undefined
77
82
  // A plain list's items are not headings; otherwise one level under the section heading, or the section's when there is none.
78
83
  const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : heading ? 'h3' : 'h2'
79
84
  ---
@@ -83,7 +88,9 @@ const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : head
83
88
  {items.map(item => (
84
89
  <li class={s.item()}>
85
90
  <span class={s.mark()} aria-hidden="true">
86
- <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>
91
+ {imageOf(item)
92
+ ? <KitImage image={{ src: imageOf(item)!, alt: '' }} class="size-5 object-contain" />
93
+ : <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>}
87
94
  </span>
88
95
  <div>
89
96
  <Title class={s.title()}>{item.title}</Title>
@@ -105,7 +112,9 @@ const Title = style === 'list' ? 'span' : bare ? `h${titleLevel}` as 'h3' : head
105
112
  {items.map(item => (
106
113
  <li class={s.item()}>
107
114
  <span class={s.mark()} aria-hidden="true">
108
- <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>
115
+ {imageOf(item)
116
+ ? <KitImage image={{ src: imageOf(item)!, alt: '' }} class="size-5 object-contain" />
117
+ : <svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d={pathOf(item)} /></svg>}
109
118
  </span>
110
119
  <div>
111
120
  <Title class={s.title()}>{item.title}</Title>
@@ -151,5 +151,6 @@
151
151
  "style": "list"
152
152
  }
153
153
  },
154
+ {"name": "list-icon-images", "props": {"heading": "In the package", "style": "list", "items": [{"title": "A design built around your products", "iconImage": "/kit-fixtures/icon-1.svg"}, {"title": "Checkout with cards and wallets", "iconImage": "/kit-fixtures/icon-1.svg"}, {"title": "Thirty days of launch support", "icon": "chat"}]}},
154
155
  {"name": "measured-gap", "props": {"heading": "Why us", "measured": {"columns": {"390": 1, "768": 2, "1280": 3}, "gap": 16}, "items": [{"title": "Fast", "text": "Static HTML."}, {"title": "Kept", "text": "Every address survives."}, {"title": "Editable", "text": "Content in Studio."}]}}
155
156
  ]
@@ -5,6 +5,7 @@
5
5
  "category": "section",
6
6
  "uses": [],
7
7
  "shared": [
8
+ "KitImage.astro",
8
9
  "Section.astro",
9
10
  "measured.ts",
10
11
  "safe-url.ts",
@@ -83,6 +84,11 @@
83
84
  "chat"
84
85
  ],
85
86
  "description": "The kit icon shown with the icon style; check when absent."
87
+ },
88
+ "iconImage": {
89
+ "type": "image",
90
+ "label": "Icon image",
91
+ "description": "The feature's own icon as an image file (the old site's icon, moved as an svg): drawn instead of the named icon; the check style ignores it."
86
92
  }
87
93
  }
88
94
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/astro-kit",
3
- "version": "0.35.0",
3
+ "version": "0.37.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",