@contentrain/astro-kit 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +30 -3
  2. package/catalog.json +251 -4
  3. package/components/_shared/Button.astro +1 -1
  4. package/components/_shared/KitImage.astro +32 -5
  5. package/components/_shared/Section.astro +1 -1
  6. package/components/breadcrumb/Breadcrumb.astro +1 -1
  7. package/components/card-grid/CardGrid.astro +4 -4
  8. package/components/contact-form/ContactForm.astro +1 -1
  9. package/components/cta/Cta.astro +2 -2
  10. package/components/embed/Embed.astro +104 -0
  11. package/components/embed/embed.ts +106 -0
  12. package/components/embed/fixtures.json +34 -0
  13. package/components/embed/meta.json +32 -0
  14. package/components/faq/Faq.astro +5 -3
  15. package/components/faq/fixtures.json +4 -0
  16. package/components/faq/meta.json +1 -1
  17. package/components/footer/Footer.astro +4 -4
  18. package/components/footer/meta.json +107 -14
  19. package/components/gallery/Gallery.astro +3 -3
  20. package/components/header/Header.astro +1 -1
  21. package/components/hero/Hero.astro +14 -7
  22. package/components/hero/fixtures.json +21 -0
  23. package/components/hero/meta.json +8 -0
  24. package/components/nav/Nav.astro +4 -2
  25. package/components/nav/meta.json +86 -13
  26. package/components/newsletter/Newsletter.astro +110 -0
  27. package/components/newsletter/fixtures.json +6 -0
  28. package/components/newsletter/meta.json +105 -0
  29. package/components/newsletter/newsletter.ts +73 -0
  30. package/components/pagination/Pagination.astro +1 -1
  31. package/components/post-card/PostCard.astro +2 -2
  32. package/components/slider/Slider.astro +3 -3
  33. package/components/tabs/Tabs.astro +2 -2
  34. package/components/testimonial/Testimonial.astro +1 -1
  35. package/dist/index.d.mts +11 -4
  36. package/dist/index.d.mts.map +1 -1
  37. package/dist/index.mjs +19 -7
  38. package/dist/index.mjs.map +1 -1
  39. package/mapping/classic.json +91 -1
  40. package/mapping/divi.json +22 -1
  41. package/mapping/elementor.json +46 -1
  42. package/mapping/gutenberg.json +70 -4
  43. package/package.json +2 -2
package/README.md CHANGED
@@ -27,6 +27,8 @@ They read its design tokens and pass its gates: `astro check` with the
27
27
  | `gallery` | Image grid with a popover lightbox | none |
28
28
  | `slider` | Carousel on a scroll-snap row | Embla |
29
29
  | `contact-form` | Contentrain Studio form in a section | Studio runtime |
30
+ | `newsletter` | Sign-up posting straight to the list provider (Mailchimp, Kit, MailerLite, Brevo, Buttondown) | none |
31
+ | `embed` | Video, map or player loaded on click; a provider link until then (YouTube via nocookie) | a few lines, no dependency |
30
32
  | `pagination` | WordPress-style `/page/N/` links | none |
31
33
  | `breadcrumb` | Ancestors, then the current page, optional JSON-LD | none |
32
34
 
@@ -45,8 +47,30 @@ Components use colour roles and widths, never raw values. A site defines them
45
47
  in `@theme` (the starter already does): `--color-surface`,
46
48
  `--color-surface-muted`, `--color-ink`, `--color-ink-muted`, `--color-line`,
47
49
  `--color-accent`, `--color-accent-ink`, `--font-sans`, `--container-prose`,
48
- `--container-page`, `--container-wide` and `--radius-card`. The same list is in
49
- `catalog.json` under `tokens`.
50
+ `--container-page`, `--container-wide` and `--radius-card`.
51
+
52
+ A migrated site also carries its theme's type and shape, and components read
53
+ these with a fallback to the kit's own values, so a site without them looks
54
+ unchanged:
55
+ - `--font-weight-heading`: section headings fall back to 700, item titles to 600.
56
+ - `--font-weight-body`, `--text-body`, `--leading-body` and `--leading-heading`:
57
+ the body and rich-text type. These are in the starter's CSS.
58
+ - `--radius-control`: buttons and form controls.
59
+ - `--radius-image`: images, which fall back to `--radius-card`.
60
+ - `--spacing-gutter`: the page frame's side padding, which falls back to 1rem
61
+ on phones and 2rem from `md` up.
62
+
63
+ The theme's type scale and rhythm are not read by components. The site's
64
+ theme applies them to the kit's markers, and only when the site sets them:
65
+ - `--text-heading-1` to `--text-heading-3`: headings inside `main`.
66
+ - `--text-body`: `[data-kit-text]`, the lead and body paragraphs of hero, CTA
67
+ and card grid.
68
+ - `--text-nav`: the header navigation, `[data-cr-part="nav-header"]`.
69
+ - `--spacing-section`: the distance between two consecutive sections; each
70
+ `[data-kit-section]` gets half of it as vertical padding, except
71
+ where `data-kit-spacing="none"`.
72
+
73
+ The full list is in `catalog.json` under `tokens`.
50
74
 
51
75
  ## API
52
76
 
@@ -71,7 +95,10 @@ rulesFor(gutenberg, 'core/cover') // → the rule that turns a cover block into
71
95
  builder elements to components, with a source for each prop from a small closed
72
96
  set of expressions (see `src/mapping.ts`). They are versioned, and an element
73
97
  no rule covers stays rich text (`fallback: "prose"`). When one rule matches an
74
- element, the outermost match owns its subtree.
98
+ element, the outermost match owns its subtree. `defaults` lists attribute
99
+ values a builder leaves out of its data while they are at their default
100
+ (Elementor saves no `video_type` for a YouTube video); `when.attr` and `attr:`
101
+ read through them (`attrsOf`).
75
102
 
76
103
  ## Development
77
104
 
package/catalog.json CHANGED
@@ -12,7 +12,20 @@
12
12
  "--container-prose",
13
13
  "--container-page",
14
14
  "--container-wide",
15
- "--radius-card"
15
+ "--radius-card",
16
+ "--font-weight-heading",
17
+ "--font-weight-body",
18
+ "--text-body",
19
+ "--leading-body",
20
+ "--leading-heading",
21
+ "--radius-control",
22
+ "--radius-image",
23
+ "--spacing-gutter",
24
+ "--text-nav",
25
+ "--text-heading-1",
26
+ "--text-heading-2",
27
+ "--text-heading-3",
28
+ "--spacing-section"
16
29
  ],
17
30
  "dependencies": {
18
31
  "tailwind-merge": "^3.7.0",
@@ -425,6 +438,114 @@
425
438
  "Cta.astro"
426
439
  ]
427
440
  },
441
+ {
442
+ "id": "embed",
443
+ "name": "Embed",
444
+ "description": "A video, map or player from another site (YouTube, Vimeo, Google Maps, OpenStreetMap, Spotify, SoundCloud, Calendly, Typeform, Loom, Wistia), loaded only on click: until then a poster link to the provider's page, with no iframe, no third-party request and no cookie (YouTube through youtube-nocookie.com). Unknown providers are never framed; they stay a plain link.",
445
+ "category": "content",
446
+ "uses": [],
447
+ "shared": [
448
+ "KitImage.astro",
449
+ "types.ts"
450
+ ],
451
+ "variants": {
452
+ "aspect": {
453
+ "options": [
454
+ "auto",
455
+ "video",
456
+ "portrait",
457
+ "square",
458
+ "map"
459
+ ],
460
+ "default": "auto",
461
+ "description": "auto: 16:9 for videos, 9:16 for YouTube Shorts, 4:3 (16:9 wide) for maps, a fixed strip for audio players. The others force a ratio."
462
+ },
463
+ "width": {
464
+ "options": [
465
+ "prose",
466
+ "page"
467
+ ],
468
+ "default": "prose",
469
+ "description": "prose: as wide as the reading column. page: the full page frame."
470
+ }
471
+ },
472
+ "props": {
473
+ "url": {
474
+ "type": "url",
475
+ "description": "A video page (youtube.com/watch, youtu.be, /shorts/, vimeo.com/<id>) or a known provider's embed address (google.com/maps/embed, open.spotify.com/embed, w.soundcloud.com/player, calendly.com, *.typeform.com/to, loom.com/embed, fast.wistia.net/embed)."
476
+ },
477
+ "address": {
478
+ "type": "string",
479
+ "description": "A place or address shown on a Google map when there is no url (Elementor google_maps address, Divi map address)."
480
+ },
481
+ "title": {
482
+ "type": "string",
483
+ "description": "What it is: the player's accessible name and the poster's text."
484
+ },
485
+ "caption": {
486
+ "type": "string",
487
+ "description": "A line under the embed."
488
+ },
489
+ "poster": {
490
+ "type": "object",
491
+ "fields": {
492
+ "src": {
493
+ "type": "image",
494
+ "required": true
495
+ },
496
+ "alt": {
497
+ "type": "string",
498
+ "required": true
499
+ },
500
+ "width": {
501
+ "type": "integer"
502
+ },
503
+ "height": {
504
+ "type": "integer"
505
+ }
506
+ },
507
+ "description": "Local poster image (an ImageInput; its alt is not used, the link names the embed). Without one the poster is a plain dark panel. Never a provider thumbnail URL: that would be a third-party request before the click."
508
+ },
509
+ "playLabel": {
510
+ "type": "string",
511
+ "description": "Poster action for a video or audio player (ui-strings embed.play)."
512
+ },
513
+ "showLabel": {
514
+ "type": "string",
515
+ "description": "Poster action for a map or widget (ui-strings embed.show)."
516
+ },
517
+ "id": {
518
+ "type": "string",
519
+ "description": "Element id."
520
+ }
521
+ },
522
+ "slots": {},
523
+ "js": "vanilla",
524
+ "dependencies": {},
525
+ "sources": {
526
+ "gutenberg": [
527
+ "core/embed"
528
+ ],
529
+ "elementor": [
530
+ "elementor/video",
531
+ "elementor/google_maps"
532
+ ],
533
+ "divi": [
534
+ "divi/et_pb_video",
535
+ "divi/et_pb_map"
536
+ ],
537
+ "classic": [
538
+ "classic/youtube",
539
+ "classic/vimeo",
540
+ "classic/google-maps"
541
+ ]
542
+ },
543
+ "a11y": "The poster is one link named '<action>: <title>' that opens the provider's page without script. With script, Enter or a click puts the iframe in its place with the title as its accessible name and moves focus into it. Modified clicks (new tab) keep the link behaviour. The play icon is decorative.",
544
+ "files": [
545
+ "Embed.astro",
546
+ "embed.ts"
547
+ ]
548
+ },
428
549
  {
429
550
  "id": "faq",
430
551
  "name": "FAQ",
@@ -438,10 +559,11 @@
438
559
  "style": {
439
560
  "options": [
440
561
  "lines",
441
- "cards"
562
+ "cards",
563
+ "plain"
442
564
  ],
443
565
  "default": "lines",
444
- "description": "lines: rows divided by rules. cards: each question in a rounded box."
566
+ "description": "lines: rows divided by rules. cards: each question in a rounded box. plain: the browser's own disclosure triangle without rules or boxes (WordPress's details block)."
445
567
  },
446
568
  "tone": {
447
569
  "options": [
@@ -567,7 +689,11 @@
567
689
  "fields": {
568
690
  "title": {
569
691
  "type": "string",
570
- "required": true
692
+ "description": "Heading shown above the links; leave out when the source shows none."
693
+ },
694
+ "label": {
695
+ "type": "string",
696
+ "description": "Accessible name of the column navigation (the menu name); defaults to the title."
571
697
  },
572
698
  "links": {
573
699
  "type": "array",
@@ -956,6 +1082,14 @@
956
1082
  ],
957
1083
  "default": "default",
958
1084
  "description": "Section background (ignored by cover, which is always light text on the image)."
1085
+ },
1086
+ "align": {
1087
+ "options": [
1088
+ "start",
1089
+ "center"
1090
+ ],
1091
+ "default": "center",
1092
+ "description": "Text position over a cover image (layout cover only): centred, as WordPress covers default to, or at the start edge."
959
1093
  }
960
1094
  },
961
1095
  "props": {
@@ -1131,6 +1265,10 @@
1131
1265
  "id": {
1132
1266
  "type": "string",
1133
1267
  "description": "Unique id when a page renders two navigations."
1268
+ },
1269
+ "part": {
1270
+ "type": "string",
1271
+ "description": "What the navigation is, for checks that find a page's parts (data-cr-part), e.g. \"nav-header\"."
1134
1272
  }
1135
1273
  },
1136
1274
  "slots": {},
@@ -1157,6 +1295,115 @@
1157
1295
  "Nav.astro"
1158
1296
  ]
1159
1297
  },
1298
+ {
1299
+ "id": "newsletter",
1300
+ "name": "Newsletter sign-up",
1301
+ "description": "A newsletter sign-up that posts straight to the list provider's hosted form endpoint (Mailchimp, Kit, MailerLite, Brevo, Buttondown): a plain HTML form with no script and no server of ours. The provider shows its own confirmation page. The action must be the named provider's endpoint (exact host and path; `other`: https off the site's origin), otherwise nothing renders. WordPress-side sign-up plugins (MC4WP, Divi signup, Jetpack) post through the site and are not mapped here: the plan lists them for review.",
1302
+ "category": "section",
1303
+ "uses": [],
1304
+ "shared": [
1305
+ "Section.astro"
1306
+ ],
1307
+ "variants": {
1308
+ "layout": {
1309
+ "options": [
1310
+ "inline",
1311
+ "stacked"
1312
+ ],
1313
+ "default": "inline",
1314
+ "description": "inline: copy beside the form on wide screens. stacked: one centred column."
1315
+ },
1316
+ "tone": {
1317
+ "options": [
1318
+ "default",
1319
+ "muted",
1320
+ "inverse",
1321
+ "accent"
1322
+ ],
1323
+ "default": "muted",
1324
+ "description": "Section background."
1325
+ }
1326
+ },
1327
+ "props": {
1328
+ "action": {
1329
+ "type": "url",
1330
+ "required": true,
1331
+ "description": "The provider's hosted form endpoint (Mailchimp *.list-manage.com/subscribe/post, Kit app.kit.com/forms/<n>/subscriptions, MailerLite assets.mailerlite.com/jsonp/<n>/forms/<n>/subscribe, Brevo *.sibforms.com/serve/, Buttondown buttondown.com/api/emails/embed-subscribe/). At migration the engine reads it from the source form element (`dom:form@action`) into the section's content, where it stays editable in Studio; it never passes through the fact pack or a model."
1332
+ },
1333
+ "provider": {
1334
+ "type": "select",
1335
+ "options": [
1336
+ "mailchimp",
1337
+ "kit",
1338
+ "mailerlite",
1339
+ "brevo",
1340
+ "buttondown",
1341
+ "other"
1342
+ ],
1343
+ "default": "mailchimp",
1344
+ "description": "Names the fields the endpoint expects (Mailchimp EMAIL/FNAME + its bot-trap field, Kit email_address, MailerLite fields[email], Brevo EMAIL, Buttondown email)."
1345
+ },
1346
+ "emailName": {
1347
+ "type": "string",
1348
+ "description": "Field name for the address when the provider is other or names it differently."
1349
+ },
1350
+ "askName": {
1351
+ "type": "boolean",
1352
+ "default": false,
1353
+ "description": "Ask for a first name too, when the provider takes one (the source form had a name field)."
1354
+ },
1355
+ "heading": {
1356
+ "type": "string",
1357
+ "description": "Section heading."
1358
+ },
1359
+ "intro": {
1360
+ "type": "text",
1361
+ "description": "A line or two about the newsletter."
1362
+ },
1363
+ "note": {
1364
+ "type": "string",
1365
+ "description": "Small print under the form (privacy, frequency)."
1366
+ },
1367
+ "emailLabel": {
1368
+ "type": "string",
1369
+ "description": "ui-strings newsletter.email."
1370
+ },
1371
+ "nameLabel": {
1372
+ "type": "string",
1373
+ "description": "ui-strings newsletter.name."
1374
+ },
1375
+ "submitLabel": {
1376
+ "type": "string",
1377
+ "description": "ui-strings newsletter.submit."
1378
+ },
1379
+ "newTab": {
1380
+ "type": "boolean",
1381
+ "default": true,
1382
+ "description": "Open the provider's confirmation page in a new tab (the reader keeps the site)."
1383
+ },
1384
+ "id": {
1385
+ "type": "string",
1386
+ "description": "Section id, unique per page (two sign-ups on one page need two ids): the heading gets `<id>-title`, the inputs `<id>-email`/`<id>-name`."
1387
+ }
1388
+ },
1389
+ "slots": {},
1390
+ "js": "none",
1391
+ "dependencies": {},
1392
+ "sources": {
1393
+ "classic": [
1394
+ "classic/mailchimp",
1395
+ "classic/kit",
1396
+ "classic/mailerlite",
1397
+ "classic/brevo",
1398
+ "classic/buttondown"
1399
+ ]
1400
+ },
1401
+ "a11y": "Every input has a visible label and the address field is type=email, required, with autocomplete=email, so the browser validates it and announces errors without script. The form is named by the section heading (or the submit label). Mailchimp's bot-trap field is aria-hidden, off screen and out of the tab order.",
1402
+ "files": [
1403
+ "Newsletter.astro",
1404
+ "newsletter.ts"
1405
+ ]
1406
+ },
1160
1407
  {
1161
1408
  "id": "pagination",
1162
1409
  "name": "Pagination",
@@ -5,7 +5,7 @@ import { tv } from 'tailwind-variants'
5
5
  import type { ActionInput } from './types'
6
6
 
7
7
  const button = tv({
8
- base: 'inline-flex items-center justify-center gap-2 rounded-md font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
8
+ base: 'inline-flex items-center justify-center gap-2 rounded-[var(--radius-control,var(--radius-md))] font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
9
9
  variants: {
10
10
  style: {
11
11
  primary: 'bg-accent text-accent-ink hover:bg-ink hover:text-surface',
@@ -1,6 +1,17 @@
1
1
  ---
2
- // An image through astro:assets when its size is known — optimized and
3
- // sized, so the layout never shifts — and a plain lazy <img> when it is not.
2
+ // An image through astro:assets, optimized and sized so the layout never
3
+ // shifts, from either place a site's media lives:
4
+ //
5
+ // - the repository, under public/ (`/media/2026/01/cover.jpg`): the file is
6
+ // read at build time and gets a srcset of resized copies;
7
+ // - a media host the site allows (Contentrain Studio's delivery URLs, in
8
+ // astro.config `image.remotePatterns`): fetched at build time when its
9
+ // size is known, and resized the same way.
10
+ //
11
+ // Anything else — a vector, a host the site does not allow, a remote image of
12
+ // unknown size — is a plain lazy <img>: nothing is fetched from a host the
13
+ // site did not name.
14
+ import type { ImageMetadata } from 'astro'
4
15
  import { Image } from 'astro:assets'
5
16
  import type { ImageInput } from './types'
6
17
 
@@ -13,9 +24,25 @@ interface Props {
13
24
  priority?: boolean | undefined
14
25
  }
15
26
 
27
+ // Raster files under public/, loaded only when a page shows one.
28
+ const LOCAL = import.meta.glob<ImageMetadata>('/public/**/*.{avif,gif,jpeg,jpg,png,tif,tiff,webp,AVIF,GIF,JPEG,JPG,PNG,TIF,TIFF,WEBP}', { import: 'default' })
29
+
16
30
  const { image, sizes = '100vw', class: className, priority = false } = Astro.props
17
31
  const loading = priority ? 'eager' : 'lazy'
32
+ const fetchpriority = priority ? 'high' : 'auto'
33
+ const path = image.src.startsWith('/') && !image.src.startsWith('//') ? image.src.split(/[?#]/)[0]! : undefined
34
+ // A malformed escape (`%E0%A4%A`) is no file in public/: the image stays a plain <img> rather than failing the build.
35
+ const decoded = (() => { try { return path === undefined ? undefined : decodeURI(path) } catch { return undefined } })()
36
+ const load = decoded === undefined ? undefined : LOCAL[`/public${decoded}`]
37
+ const local = load ? await load() : undefined
38
+ // The stored size (WordPress's chosen size of the file), else the file's own.
39
+ const size = image.width && image.height ? { width: image.width, height: image.height } : {}
40
+ // Candidate widths for the srcset, up to the image's own: the browser picks one by `sizes`.
41
+ const STEPS = [320, 640, 960, 1280, 1600, 1920, 2560]
42
+ const widthsUpTo = (max: number) => [...STEPS.filter(step => step < max), max]
18
43
  ---
19
- {image.width && image.height
20
- ? <Image src={image.src} alt={image.alt} width={image.width} height={image.height} sizes={sizes} class={className} loading={loading} fetchpriority={priority ? 'high' : 'auto'} />
21
- : <img src={image.src} alt={image.alt} class={className} loading={loading} decoding="async" />}
44
+ {local
45
+ ? <Image src={local} alt={image.alt} {...size} widths={widthsUpTo(image.width ?? local.width)} sizes={sizes} class={className} loading={loading} fetchpriority={fetchpriority} />
46
+ : image.width && image.height && path === undefined
47
+ ? <Image src={image.src} alt={image.alt} width={image.width} height={image.height} widths={widthsUpTo(image.width)} sizes={sizes} class={className} loading={loading} fetchpriority={fetchpriority} />
48
+ : <img src={image.src} alt={image.alt} width={image.width} height={image.height} class={className} loading={loading} fetchpriority={fetchpriority} decoding="async" />}
@@ -38,6 +38,6 @@ interface Props {
38
38
 
39
39
  const { tone, spacing, width = 'page', as: Tag = 'section', id, class: className, 'aria-labelledby': labelledBy } = Astro.props
40
40
  ---
41
- <Tag id={id} aria-labelledby={labelledBy} class={section({ tone, spacing, class: className })} data-tone={tone ?? 'default'}>
41
+ <Tag id={id} aria-labelledby={labelledBy} class={section({ tone, spacing, class: className })} data-tone={tone ?? 'default'} data-kit-section data-kit-spacing={spacing ?? 'md'}>
42
42
  {width === 'none' ? <slot /> : <div class={width === 'prose' ? 'container-prose' : 'container-page'}><slot /></div>}
43
43
  </Tag>
@@ -42,7 +42,7 @@ const jsonLd = schema
42
42
  }).replace(/</g, '\\u003c')
43
43
  : undefined
44
44
  ---
45
- <nav aria-label={label}>
45
+ <nav aria-label={label} data-cr-part="breadcrumb">
46
46
  <ol class={slots.list()}>
47
47
  {items.map(item => (
48
48
  <li class="flex items-center gap-2">
@@ -10,8 +10,8 @@ const grid = tv({
10
10
  slots: {
11
11
  list: 'grid items-start gap-6 md:gap-8',
12
12
  card: 'group relative flex flex-col gap-3 rounded-[var(--radius-card)]',
13
- media: 'overflow-hidden rounded-[var(--radius-card)]',
14
- title: 'text-lg leading-snug font-semibold text-balance',
13
+ media: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
14
+ title: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)] text-balance',
15
15
  text: 'opacity-80',
16
16
  },
17
17
  variants: {
@@ -61,7 +61,7 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
61
61
  <Section tone={tone} aria-labelledby={headingId}>
62
62
  {(title || intro) && (
63
63
  <header class:list={['mb-10 flex max-w-2xl flex-col gap-3', align === 'center' && 'mx-auto items-center text-center']}>
64
- {title && <Heading id={headingId} class="text-3xl font-bold tracking-tight text-balance md:text-4xl">{title}</Heading>}
64
+ {title && <Heading id={headingId} class="text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl">{title}</Heading>}
65
65
  {intro && <p class:list={['text-lg', tone === 'accent' ? undefined : 'opacity-80']}>{intro}</p>}
66
66
  </header>
67
67
  )}
@@ -75,7 +75,7 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
75
75
  ? <a href={item.href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent">{item.title}</a>
76
76
  : item.title}
77
77
  </CardHeading>
78
- {item.text && <p class={text({ class: dim })}>{item.text}</p>}
78
+ {item.text && <p class={text({ class: dim })} data-kit-text>{item.text}</p>}
79
79
  </li>
80
80
  ))}
81
81
  </ul>
@@ -21,7 +21,7 @@ const contact = tv({
21
21
  slots: {
22
22
  grid: 'grid gap-10',
23
23
  copy: 'flex flex-col gap-4',
24
- heading: 'text-2xl font-bold tracking-tight text-balance md:text-3xl',
24
+ heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
25
25
  intro: 'max-w-xl text-pretty opacity-85',
26
26
  details: 'mt-2 flex flex-col gap-4',
27
27
  term: 'text-sm font-semibold opacity-75',
@@ -10,7 +10,7 @@ const cta = tv({
10
10
  slots: {
11
11
  box: 'flex flex-col gap-6',
12
12
  copy: 'flex flex-col gap-3',
13
- heading: 'text-2xl font-bold tracking-tight text-balance md:text-3xl',
13
+ heading: 'text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
14
14
  body: 'max-w-2xl text-pretty opacity-85',
15
15
  actions: 'flex flex-wrap gap-3',
16
16
  },
@@ -57,7 +57,7 @@ const shown = actions.map(action => (tone === 'accent' && (action.style ?? 'prim
57
57
  <div class={s.box()}>
58
58
  <div class={s.copy()}>
59
59
  <h2 id={headingId} class={s.heading()}>{heading}</h2>
60
- {body && <p class={s.body()}>{body}</p>}
60
+ {body && <p class={s.body()} data-kit-text>{body}</p>}
61
61
  </div>
62
62
  {shown.length > 0 && <div class={s.actions()}>{shown.map(action => <Button action={action} size="lg" />)}</div>}
63
63
  </div>
@@ -0,0 +1,104 @@
1
+ ---
2
+ // A video, map or player from another site, loaded only when the reader asks
3
+ // for it. Until then it is a link to the provider's page drawn as a poster:
4
+ // no iframe, no third-party request, no cookie (YouTube plays from
5
+ // youtube-nocookie.com). A small script turns the click into the player in
6
+ // place; without it the link simply opens the provider. Unknown providers are
7
+ // never framed — they stay a plain link.
8
+ import { tv } from 'tailwind-variants'
9
+ import KitImage from '../_shared/KitImage.astro'
10
+ import type { ImageInput } from '../_shared/types'
11
+ import { embedTarget, mapTarget } from './embed'
12
+
13
+ const embed = tv({
14
+ slots: {
15
+ root: 'mx-auto my-8 w-full',
16
+ frame: 'relative overflow-hidden rounded-card bg-ink text-surface',
17
+ facade: 'group absolute inset-0 flex items-end justify-start no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
18
+ poster: 'absolute inset-0 h-full w-full object-cover opacity-80 transition-opacity group-hover:opacity-65',
19
+ play: 'absolute top-1/2 left-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-accent text-accent-ink shadow-lg transition-transform group-hover:scale-105 group-focus-visible:scale-105',
20
+ label: 'relative m-0 w-full bg-gradient-to-t from-ink/90 to-transparent px-4 pt-10 pb-3 text-sm font-semibold text-balance text-surface md:px-5 md:pb-4 md:text-base',
21
+ caption: 'mt-2 text-sm text-ink-muted',
22
+ },
23
+ variants: {
24
+ aspect: {
25
+ auto: {},
26
+ video: { frame: 'aspect-video' },
27
+ portrait: { root: 'max-w-sm', frame: 'aspect-[9/16]' },
28
+ square: { root: 'max-w-xl', frame: 'aspect-square' },
29
+ map: { frame: 'aspect-[4/3] md:aspect-[16/9]' },
30
+ },
31
+ width: { prose: { root: 'max-w-(--container-prose)' }, page: {} },
32
+ },
33
+ defaultVariants: { aspect: 'auto', width: 'prose' },
34
+ })
35
+
36
+ interface Props {
37
+ /** A video page (YouTube, Vimeo) or a known provider's embed address (Google Maps embed, Spotify, SoundCloud, Calendly, Typeform, Loom, Wistia). */
38
+ url?: string | undefined
39
+ /** A place or address to show on a Google map, when there is no `url`. */
40
+ address?: string | undefined
41
+ /** What it is ("Studio tour", "Our office"): the iframe's accessible name and the poster's text. */
42
+ title?: string | undefined
43
+ caption?: string | undefined
44
+ /** Local poster image; without one the poster is a plain dark panel. */
45
+ poster?: ImageInput | undefined
46
+ /** Poster action for a video or player (ui-strings embed.play). */
47
+ playLabel?: string | undefined
48
+ /** Poster action for a map or widget (ui-strings embed.show). */
49
+ showLabel?: string | undefined
50
+ aspect?: 'auto' | 'video' | 'portrait' | 'square' | 'map' | undefined
51
+ width?: 'prose' | 'page' | undefined
52
+ id?: string | undefined
53
+ }
54
+
55
+ const { url, address, title, caption, poster, playLabel = 'Play', showLabel = 'Show', aspect = 'auto', width, id } = Astro.props
56
+ const target = (url ? embedTarget(url) : null) ?? (address && !url ? mapTarget(address) : null)
57
+ const shape = aspect !== 'auto' ? aspect : target?.portrait ? 'portrait' : target?.kind === 'map' ? 'map' : 'video'
58
+ // A player strip (Spotify, SoundCloud) has a fixed height, not a ratio.
59
+ const strip = aspect === 'auto' && target?.kind === 'audio'
60
+ const s = embed({ aspect: strip ? 'auto' : shape, width })
61
+ const action = target && (target.kind === 'video' || target.kind === 'audio') ? playLabel : showLabel
62
+ const name = title ?? caption ?? ''
63
+ const linkUrl = url && /^https?:\/\//i.test(url) ? url : undefined
64
+ ---
65
+ {target
66
+ ? (
67
+ <figure id={id} class={s.root()} data-kit-embed>
68
+ <div class={s.frame({ class: strip ? 'h-[166px]' : undefined })}>
69
+ <a href={target.href} class={s.facade()} data-embed-src={target.src} data-embed-title={name || target.provider} aria-label={name ? `${action}: ${name}` : action} rel="noopener">
70
+ {poster && <KitImage image={{ ...poster, alt: '' }} sizes="(min-width: 768px) 48rem, 100vw" class={s.poster()} />}
71
+ <span class={s.play()} aria-hidden="true">
72
+ {target.kind === 'map'
73
+ ? <svg viewBox="0 0 24 24" class="size-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7-6.2-7-11.5a7 7 0 1 1 14 0C19 14.8 12 21 12 21Z" /><circle cx="12" cy="9.5" r="2.5" /></svg>
74
+ : <svg viewBox="0 0 24 24" class="ms-1 size-7" fill="currentColor"><path d="M7 4.5v15a.5.5 0 0 0 .77.42l11.5-7.5a.5.5 0 0 0 0-.84L7.77 4.08A.5.5 0 0 0 7 4.5Z" /></svg>}
75
+ </span>
76
+ <span class={s.label()} aria-hidden="true">{name ? `${action}: ${name}` : action}</span>
77
+ </a>
78
+ </div>
79
+ {caption && <figcaption class={s.caption()}>{caption}</figcaption>}
80
+ </figure>
81
+ )
82
+ : linkUrl && (
83
+ <p id={id} class={s.root()}><a href={linkUrl} rel="noopener" class="underline decoration-line underline-offset-4 hover:decoration-current">{name || linkUrl}</a></p>
84
+ )}
85
+
86
+ <script>
87
+ // Click (or Enter) on the poster: the player replaces it, focused, so keyboard and screen-reader users land in it.
88
+ // A modified click (new tab, new window) keeps the link's own behaviour.
89
+ document.addEventListener('click', (event) => {
90
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
91
+ const link = (event.target as Element | null)?.closest<HTMLAnchorElement>('[data-kit-embed] a[data-embed-src]')
92
+ if (!link?.dataset.embedSrc) return
93
+ event.preventDefault()
94
+ const frame = document.createElement('iframe')
95
+ frame.src = link.dataset.embedSrc
96
+ frame.title = link.dataset.embedTitle ?? ''
97
+ frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen; clipboard-write'
98
+ frame.allowFullscreen = true
99
+ frame.referrerPolicy = 'strict-origin-when-cross-origin'
100
+ frame.className = 'absolute inset-0 h-full w-full border-0'
101
+ link.replaceWith(frame)
102
+ frame.focus()
103
+ })
104
+ </script>