@contentrain/astro-kit 0.36.0 → 0.38.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",
@@ -1110,6 +1315,7 @@
1110
1315
  "embed"
1111
1316
  ],
1112
1317
  "shared": [
1318
+ "KitImage.astro",
1113
1319
  "Section.astro",
1114
1320
  "measured.ts",
1115
1321
  "safe-url.ts",
@@ -1211,6 +1417,11 @@
1211
1417
  "type": "url",
1212
1418
  "label": "Link",
1213
1419
  "description": "A tel:, mailto: or map link for it."
1420
+ },
1421
+ "iconImage": {
1422
+ "type": "image",
1423
+ "label": "Icon image",
1424
+ "description": "The detail's own icon as an image file (the old site's map marker or phone, moved as an svg), drawn before it."
1214
1425
  }
1215
1426
  }
1216
1427
  },
@@ -1412,6 +1623,7 @@
1412
1623
  "details[].label": "details[].label",
1413
1624
  "details[].value": "details[].value",
1414
1625
  "details[].href": "details[].href",
1626
+ "details[].iconImage": "details[].icon_image",
1415
1627
  "hours": "hours",
1416
1628
  "socials": "socials",
1417
1629
  "socials[].label": "socials[].label",
@@ -1959,9 +2171,27 @@
1959
2171
  "default": false,
1960
2172
  "content": false,
1961
2173
  "description": "Rendered already open, in the flow of the section and not modal: for previews and visual baselines only, never for a page."
2174
+ },
2175
+ "trigger": {
2176
+ "type": "select",
2177
+ "options": [
2178
+ "click",
2179
+ "load",
2180
+ "exit",
2181
+ "scroll"
2182
+ ],
2183
+ "content": false,
2184
+ "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."
2185
+ },
2186
+ "fact": {
2187
+ "type": "string",
2188
+ "content": false,
2189
+ "description": "The source behavior's fact id; the gates find the dialog by it (data-cr-fact)."
1962
2190
  }
1963
2191
  },
1964
- "slots": {},
2192
+ "slots": {
2193
+ "form": "The popup's form, under its text and buttons: the starter's form (Studio) or its link to the form on WordPress."
2194
+ },
1965
2195
  "js": "vanilla",
1966
2196
  "dependencies": {},
1967
2197
  "sources": {
@@ -2375,6 +2605,7 @@
2375
2605
  "category": "section",
2376
2606
  "uses": [],
2377
2607
  "shared": [
2608
+ "KitImage.astro",
2378
2609
  "Section.astro",
2379
2610
  "measured.ts",
2380
2611
  "safe-url.ts",
@@ -2453,6 +2684,11 @@
2453
2684
  "chat"
2454
2685
  ],
2455
2686
  "description": "The kit icon shown with the icon style; check when absent."
2687
+ },
2688
+ "iconImage": {
2689
+ "type": "image",
2690
+ "label": "Icon image",
2691
+ "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."
2456
2692
  }
2457
2693
  }
2458
2694
  },
@@ -2620,7 +2856,8 @@
2620
2856
  "items": "items",
2621
2857
  "items[].title": "items[].title",
2622
2858
  "items[].text": "items[].text",
2623
- "items[].icon": "items[].icon"
2859
+ "items[].icon": "items[].icon",
2860
+ "items[].iconImage": "items[].icon_image"
2624
2861
  }
2625
2862
  },
2626
2863
  {
@@ -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
+ }
@@ -10,6 +10,7 @@
10
10
  // that posts to their form service, an address to write to, or a link to the
11
11
  // form on WordPress.
12
12
  import { tv } from 'tailwind-variants'
13
+ import KitImage from '../_shared/KitImage.astro'
13
14
  import Section from '../_shared/Section.astro'
14
15
  import type { MeasuredStyle } from '../_shared/types'
15
16
  import Embed from '../embed/Embed.astro'
@@ -24,6 +25,8 @@ export interface ContactDetail {
24
25
  value: string
25
26
  /** `mailto:`, `tel:` or a map link. */
26
27
  href?: string | undefined
28
+ /** The detail's own icon as an image file (the old site's map marker or phone, moved as an svg), drawn before it. */
29
+ iconImage?: string | undefined
27
30
  }
28
31
 
29
32
  const contact = tv({
@@ -35,6 +38,7 @@ const contact = tv({
35
38
  details: 'mt-2 flex flex-col gap-4',
36
39
  term: 'text-sm font-semibold opacity-75',
37
40
  value: 'text-current',
41
+ mark: 'mr-2 inline-flex size-5 shrink-0 align-[-0.25em]',
38
42
  formColumn: 'flex flex-col gap-4',
39
43
  formHeading: 'text-xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-2xl',
40
44
  form: 'rounded-card',
@@ -128,7 +132,7 @@ const FormHeading = heading ? 'h3' : 'h2'
128
132
  {details.map(detail => (
129
133
  <div class={detailsStyle === 'cards' ? s.card() : undefined}>
130
134
  <dt class={s.term()}>{detail.label}</dt>
131
- <dd class={s.value()}>{detail.href ? <a href={safeHref(detail.href)} class={LINK}>{detail.value}</a> : detail.value}</dd>
135
+ <dd class={s.value()}>{detail.iconImage && <span class={s.mark()} aria-hidden="true"><KitImage image={{ src: detail.iconImage, alt: '' }} class="size-5 object-contain" /></span>}{detail.href ? <a href={safeHref(detail.href)} class={LINK}>{detail.value}</a> : detail.value}</dd>
132
136
  </div>
133
137
  ))}
134
138
  </dl>
@@ -137,6 +141,7 @@ const FormHeading = heading ? 'h3' : 'h2'
137
141
  <ul class={s.details()}>
138
142
  {details.map(detail => (
139
143
  <li class={s.value({ class: detailsStyle === 'cards' ? s.card() : undefined })}>
144
+ {detail.iconImage && <span class={s.mark()} aria-hidden="true"><KitImage image={{ src: detail.iconImage, alt: '' }} class="size-5 object-contain" /></span>}
140
145
  {detail.label && <span class={s.term()}>{detail.label}: </span>}
141
146
  {detail.href ? <a href={safeHref(detail.href)} class={LINK}>{detail.value}</a> : detail.value}
142
147
  </li>
@@ -177,5 +177,30 @@
177
177
  "title": "Our studio"
178
178
  }
179
179
  }
180
+ },
181
+ {
182
+ "name": "detail-icon-images",
183
+ "props": {
184
+ "model": "contact-submissions",
185
+ "heading": "Contact",
186
+ "intro": "Tell us what you make and when you want to launch.",
187
+ "layout": "stacked",
188
+ "align": "start",
189
+ "details": [
190
+ {
191
+ "value": "Studio 4, Harbour Lane",
192
+ "iconImage": "/kit-fixtures/icon-1.svg"
193
+ },
194
+ {
195
+ "value": "hello@northwind.example",
196
+ "href": "mailto:hello@northwind.example",
197
+ "iconImage": "/kit-fixtures/icon-1.svg"
198
+ },
199
+ {
200
+ "value": "+351 21 000 0000",
201
+ "href": "tel:+351210000000"
202
+ }
203
+ ]
204
+ }
180
205
  }
181
206
  ]
@@ -7,6 +7,7 @@
7
7
  "embed"
8
8
  ],
9
9
  "shared": [
10
+ "KitImage.astro",
10
11
  "Section.astro",
11
12
  "measured.ts",
12
13
  "safe-url.ts",
@@ -108,6 +109,11 @@
108
109
  "type": "url",
109
110
  "label": "Link",
110
111
  "description": "A tel:, mailto: or map link for it."
112
+ },
113
+ "iconImage": {
114
+ "type": "image",
115
+ "label": "Icon image",
116
+ "description": "The detail's own icon as an image file (the old site's map marker or phone, moved as an svg), drawn before it."
111
117
  }
112
118
  }
113
119
  },
@@ -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.36.0",
3
+ "version": "0.38.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",