@contentrain/astro-kit 0.31.0 → 0.33.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 (102) hide show
  1. package/catalog.json +4600 -1584
  2. package/components/_shared/Button.astro +2 -1
  3. package/components/_shared/KitImage.astro +9 -4
  4. package/components/_shared/Section.astro +3 -1
  5. package/components/_shared/SectionHeader.astro +27 -0
  6. package/components/_shared/measured.ts +23 -0
  7. package/components/_shared/safe-url.ts +67 -0
  8. package/components/_shared/social.ts +73 -0
  9. package/components/announcement/Announcement.astro +2 -1
  10. package/components/announcement/meta.json +1 -1
  11. package/components/band/Band.astro +3 -2
  12. package/components/band/meta.json +1 -0
  13. package/components/breadcrumb/Breadcrumb.astro +2 -1
  14. package/components/breadcrumb/meta.json +1 -1
  15. package/components/card-grid/CardGrid.astro +2 -1
  16. package/components/card-grid/meta.json +1 -0
  17. package/components/consent/Consent.astro +2 -1
  18. package/components/consent/meta.json +1 -1
  19. package/components/contact-details/ContactDetails.astro +3 -2
  20. package/components/contact-details/meta.json +1 -0
  21. package/components/contact-form/ContactForm.astro +51 -14
  22. package/components/contact-form/fixtures.json +169 -9
  23. package/components/contact-form/meta.json +284 -15
  24. package/components/cta/Cta.astro +52 -12
  25. package/components/cta/fixtures.json +5 -1
  26. package/components/cta/meta.json +11 -2
  27. package/components/dialog/Dialog.astro +113 -0
  28. package/components/dialog/fixtures.json +84 -0
  29. package/components/dialog/meta.json +252 -0
  30. package/components/embed/meta.json +1 -1
  31. package/components/faq/Faq.astro +60 -17
  32. package/components/faq/fixtures.json +79 -0
  33. package/components/faq/meta.json +9 -3
  34. package/components/feature-list/FeatureList.astro +28 -4
  35. package/components/feature-list/meta.json +16 -3
  36. package/components/footer/Footer.astro +5 -4
  37. package/components/footer/meta.json +1 -0
  38. package/components/gallery/Gallery.astro +49 -14
  39. package/components/gallery/fixtures.json +275 -0
  40. package/components/gallery/meta.json +267 -14
  41. package/components/header/Header.astro +17 -5
  42. package/components/header/fixtures.json +90 -1
  43. package/components/header/meta.json +5 -3
  44. package/components/hero/meta.json +1 -0
  45. package/components/language-switcher/LanguageSwitcher.astro +68 -0
  46. package/components/language-switcher/fixtures.json +51 -0
  47. package/components/language-switcher/meta.json +73 -0
  48. package/components/logo-cloud/LogoCloud.astro +86 -21
  49. package/components/logo-cloud/fixtures.json +239 -0
  50. package/components/logo-cloud/meta.json +145 -5
  51. package/components/nav/Nav.astro +77 -24
  52. package/components/nav/NavTree.astro +40 -0
  53. package/components/nav/fixtures.json +165 -0
  54. package/components/nav/meta.json +11 -2
  55. package/components/newsletter/Newsletter.astro +2 -1
  56. package/components/newsletter/meta.json +1 -0
  57. package/components/pagination/Pagination.astro +4 -3
  58. package/components/pagination/meta.json +1 -1
  59. package/components/post-card/PostCard.astro +8 -5
  60. package/components/post-card/meta.json +3 -2
  61. package/components/post-list/PostList.astro +115 -0
  62. package/components/post-list/fixtures.json +387 -0
  63. package/components/post-list/meta.json +309 -0
  64. package/components/pricing/Pricing.astro +128 -24
  65. package/components/pricing/fixtures.json +288 -4
  66. package/components/pricing/meta.json +188 -1
  67. package/components/search-box/SearchBox.astro +51 -0
  68. package/components/search-box/fixtures.json +31 -0
  69. package/components/search-box/meta.json +75 -0
  70. package/components/slider/Slider.astro +19 -115
  71. package/components/slider/SliderTrack.astro +178 -0
  72. package/components/slider/fixtures.json +2 -1
  73. package/components/slider/meta.json +11 -5
  74. package/components/social-links/SocialLinks.astro +74 -0
  75. package/components/social-links/fixtures.json +67 -0
  76. package/components/social-links/meta.json +94 -0
  77. package/components/split/Split.astro +76 -14
  78. package/components/split/fixtures.json +8 -1
  79. package/components/split/meta.json +226 -4
  80. package/components/stats/Stats.astro +54 -11
  81. package/components/stats/fixtures.json +260 -0
  82. package/components/stats/meta.json +151 -4
  83. package/components/steps/Steps.astro +36 -8
  84. package/components/steps/fixtures.json +217 -5
  85. package/components/steps/meta.json +157 -3
  86. package/components/tabs/Tabs.astro +10 -3
  87. package/components/tabs/fixtures.json +4 -0
  88. package/components/tabs/meta.json +5 -2
  89. package/components/team/Team.astro +54 -9
  90. package/components/team/fixtures.json +217 -4
  91. package/components/team/meta.json +169 -3
  92. package/components/team/team.ts +4 -0
  93. package/components/testimonial/Testimonial.astro +71 -21
  94. package/components/testimonial/fixtures.json +73 -0
  95. package/components/testimonial/meta.json +168 -6
  96. package/dist/index.mjs +1 -1
  97. package/dist/index.mjs.map +1 -1
  98. package/mapping/classic.json +124 -3
  99. package/mapping/divi.json +26 -8
  100. package/mapping/elementor.json +133 -14
  101. package/mapping/gutenberg.json +38 -10
  102. package/package.json +2 -2
@@ -122,5 +122,94 @@
122
122
  ]
123
123
  }
124
124
  },
125
- { "name": "tagline", "props": { "siteName": "Golden Agency", "tagline": "Websites that sell what you make", "items": [{ "label": "Home", "href": "/" }, { "label": "Services", "href": "/services/" }, { "label": "Contact", "href": "/contact/" }], "menuLabel": "Menu", "closeLabel": "Close" } }
125
+ { "name": "tagline", "props": { "siteName": "Golden Agency", "tagline": "Websites that sell what you make", "items": [{ "label": "Home", "href": "/" }, { "label": "Services", "href": "/services/" }, { "label": "Contact", "href": "/contact/" }], "menuLabel": "Menu", "closeLabel": "Close" } },
126
+ {
127
+ "name": "mega-menu",
128
+ "props": {
129
+ "siteName": "Northwind",
130
+ "menu": "mega",
131
+ "items": [
132
+ {
133
+ "label": "Hakkımızda",
134
+ "href": "/hakkimizda/"
135
+ },
136
+ {
137
+ "label": "Ürünlerimiz",
138
+ "href": "#",
139
+ "children": [
140
+ {
141
+ "label": "Aracım İçin",
142
+ "href": "#",
143
+ "children": [
144
+ {
145
+ "label": "Trafik Sigortası",
146
+ "href": "/urunlerimiz/trafik-sigortasi/"
147
+ },
148
+ {
149
+ "label": "Kasko",
150
+ "href": "/urunlerimiz/kasko/"
151
+ }
152
+ ]
153
+ },
154
+ {
155
+ "label": "Evim İçin",
156
+ "href": "#",
157
+ "children": [
158
+ {
159
+ "label": "Dask",
160
+ "href": "/urunlerimiz/dask/"
161
+ },
162
+ {
163
+ "label": "Konut Sigortası",
164
+ "href": "/urunlerimiz/konut-sigortasi/"
165
+ }
166
+ ]
167
+ },
168
+ {
169
+ "label": "Sağlığım İçin",
170
+ "href": "#",
171
+ "children": [
172
+ {
173
+ "label": "Sağlık Sigortası",
174
+ "href": "/urunlerimiz/saglik-sigortasi/"
175
+ },
176
+ {
177
+ "label": "Tamamlayıcı Sağlık",
178
+ "href": "/urunlerimiz/tamamlayici-saglik/"
179
+ }
180
+ ]
181
+ },
182
+ {
183
+ "label": "Diğer",
184
+ "href": "#",
185
+ "children": [
186
+ {
187
+ "label": "İş Yeri Sigortası",
188
+ "href": "/urunlerimiz/is-yeri-sigortasi/"
189
+ },
190
+ {
191
+ "label": "Seyahat Sigortası",
192
+ "href": "/urunlerimiz/seyahat-sigortasi/"
193
+ }
194
+ ]
195
+ }
196
+ ]
197
+ },
198
+ {
199
+ "label": "Anlaşmalı Sigorta Şirketleri",
200
+ "href": "/anlasmali-sigorta-sirketleri/"
201
+ },
202
+ {
203
+ "label": "İletişim",
204
+ "href": "/iletisim/"
205
+ }
206
+ ],
207
+ "action": {
208
+ "label": "Get a quote",
209
+ "href": "/contact/"
210
+ }
211
+ }
212
+ },
213
+ { "name": "search", "props": { "siteName": "Northwind Journal", "items": [{ "label": "Journal", "href": "/" }, { "label": "About", "href": "/about/" }], "search": { "label": "Search", "placeholder": "Search this site", "submitLabel": "Search" } } },
214
+ {"name": "mega-menu-search", "props": {"siteName": "Northwind", "menu": "mega", "items": [{"label": "Hakkımızda", "href": "/hakkimizda/"}, {"label": "Ürünlerimiz", "href": "#", "children": [{"label": "Aracım İçin", "href": "#", "children": [{"label": "Trafik Sigortası", "href": "/urunlerimiz/trafik-sigortasi/"}, {"label": "Kasko", "href": "/urunlerimiz/kasko/"}]}, {"label": "Evim İçin", "href": "#", "children": [{"label": "Dask", "href": "/urunlerimiz/dask/"}, {"label": "Konut Sigortası", "href": "/urunlerimiz/konut-sigortasi/"}]}, {"label": "Sağlığım İçin", "href": "#", "children": [{"label": "Sağlık Sigortası", "href": "/urunlerimiz/saglik-sigortasi/"}, {"label": "Tamamlayıcı Sağlık", "href": "/urunlerimiz/tamamlayici-saglik/"}]}, {"label": "Diğer", "href": "#", "children": [{"label": "İş Yeri Sigortası", "href": "/urunlerimiz/is-yeri-sigortasi/"}, {"label": "Seyahat Sigortası", "href": "/urunlerimiz/seyahat-sigortasi/"}]}]}, {"label": "Anlaşmalı Sigorta Şirketleri", "href": "/anlasmali-sigorta-sirketleri/"}, {"label": "İletişim", "href": "/iletisim/"}], "action": {"label": "Get a quote", "href": "/contact/"}, "search": {"label": "Search", "placeholder": "Ara", "submitLabel": "Ara"}}}
126
215
  ]
@@ -3,12 +3,13 @@
3
3
  "name": "Header",
4
4
  "description": "Site header: brand (logo and/or name), navigation and an optional call-to-action button.",
5
5
  "category": "chrome",
6
- "uses": ["nav"],
7
- "shared": ["Button.astro", "KitImage.astro", "types.ts"],
6
+ "uses": ["nav", "search-box"],
7
+ "shared": ["Button.astro", "KitImage.astro", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "layout": { "options": ["split", "centered", "brand-left"], "default": "split", "description": "split: brand left, navigation right. centered: brand above centred navigation. brand-left: navigation right after the brand." },
10
10
  "tone": { "options": ["default", "inverse", "transparent"], "default": "default", "description": "Surface with a bottom border, dark (ink) background, or transparent over the hero that follows." },
11
- "position": { "options": ["static", "sticky"], "default": "static", "description": "Sticky keeps the header at the top while scrolling." }
11
+ "position": { "options": ["static", "sticky"], "default": "static", "description": "Sticky keeps the header at the top while scrolling." },
12
+ "menu": { "options": ["flyout", "mega"], "default": "flyout", "description": "How submenus open on wide screens: a list under the item (flyout) or a wide panel of columns (mega). Submenus nest to any depth." }
12
13
  },
13
14
  "props": {
14
15
  "siteName": { "type": "string", "required": true, "description": "Site title (site.title)." },
@@ -26,6 +27,7 @@
26
27
  "homeHref": { "type": "url", "default": "/", "description": "Where the brand links." },
27
28
  "items": { "type": "array", "required": true, "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true }, "newTab": { "type": "boolean" }, "children": { "type": "array" } } }, "description": "Primary menu (menus → menu-items)." },
28
29
  "action": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "href": { "type": "url", "required": true }, "style": { "type": "select", "options": ["primary", "secondary", "ghost"] } }, "description": "Header button, shown on wide screens." },
30
+ "search": { "type": "object", "fields": { "label": { "type": "string" }, "placeholder": { "type": "string" }, "submitLabel": { "type": "string" }, "action": { "type": "url" } }, "description": "A search field in the bar: a GET form to the site's search page (default /search); below the bar on a phone." },
29
31
  "navLabel": { "type": "string", "description": "Accessible name of the navigation (ui-strings nav.label)." },
30
32
  "menuLabel": { "type": "string", "description": "Narrow-screen toggle text (ui-strings nav.menu)." },
31
33
  "closeLabel": { "type": "string", "description": "Drawer close text (ui-strings nav.close)." }
@@ -9,6 +9,7 @@
9
9
  "KitImage.astro",
10
10
  "Section.astro",
11
11
  "measured.ts",
12
+ "safe-url.ts",
12
13
  "types.ts"
13
14
  ],
14
15
  "variants": {
@@ -0,0 +1,68 @@
1
+ ---
2
+ // Links to the same page in the site's other languages. Each link carries `hreflang` and its own `lang`, so the
3
+ // language's name is read in that language; the current one is marked `aria-current`. A plain list, or a native
4
+ // `<details>` menu that shows the current language: no JavaScript. The addresses come from the caller (the page's
5
+ // equivalent in each language); a link without one is not drawn.
6
+ import { tv } from 'tailwind-variants'
7
+ import { safeHref } from '../_shared/safe-url'
8
+
9
+ const switcher = tv({
10
+ slots: {
11
+ list: 'flex flex-wrap items-center gap-x-4 gap-y-1',
12
+ link: 'text-current no-underline opacity-80 hover:opacity-100 hover:underline aria-[current=true]:font-semibold aria-[current=true]:opacity-100',
13
+ summary: 'inline-flex cursor-pointer list-none items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-current/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent [&::-webkit-details-marker]:hidden',
14
+ panel: 'absolute z-30 mt-1 flex min-w-40 flex-col gap-1 rounded-lg border border-line bg-surface p-2 text-ink shadow-lg',
15
+ },
16
+ variants: {
17
+ layout: { list: {}, menu: {} },
18
+ align: { start: { panel: 'start-0' }, end: { panel: 'end-0' } },
19
+ // `name`: "Deutsch"; `code`: "DE".
20
+ labelStyle: { name: {}, code: { link: 'uppercase' } },
21
+ },
22
+ defaultVariants: { layout: 'list', align: 'start', labelStyle: 'name' },
23
+ })
24
+
25
+ interface Props {
26
+ /** The languages (none: nothing is drawn), `lang` a BCP 47 code ("de", "pt-BR"); `label` the language's own name ("Deutsch"). */
27
+ languages?: Array<{ lang: string, label: string, href: string, current?: boolean | undefined }> | undefined
28
+ /** Accessible name ("Language"). */
29
+ label?: string | undefined
30
+ layout?: 'list' | 'menu' | undefined
31
+ align?: 'start' | 'end' | undefined
32
+ labelStyle?: 'name' | 'code' | undefined
33
+ }
34
+
35
+ const { languages = [], label = 'Language', layout, align, labelStyle } = Astro.props
36
+ const s = switcher({ layout, align, labelStyle })
37
+ const shown = languages.filter(language => language.href)
38
+ const current = shown.find(language => language.current)
39
+ const text = (language: { lang: string, label: string }) => (labelStyle === 'code' ? language.lang.split('-')[0]! : language.label)
40
+ ---
41
+ {shown.length > 1 && (layout === 'menu'
42
+ ? (
43
+ <details class="relative" data-cr-part="language-switcher">
44
+ <summary class={s.summary()}>
45
+ <span class="sr-only">{label}: </span>
46
+ <span lang={current?.lang}>{current ? text(current) : label}</span>
47
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6" /></svg>
48
+ </summary>
49
+ <ul class={s.panel()}>
50
+ {shown.map(language => (
51
+ <li>
52
+ <a href={safeHref(language.href)} lang={language.lang} hreflang={language.lang} aria-current={language.current ? 'true' : undefined} class={s.link()}>{text(language)}</a>
53
+ </li>
54
+ ))}
55
+ </ul>
56
+ </details>
57
+ )
58
+ : (
59
+ <nav aria-label={label} data-cr-part="language-switcher">
60
+ <ul class={s.list()}>
61
+ {shown.map(language => (
62
+ <li>
63
+ <a href={safeHref(language.href)} lang={language.lang} hreflang={language.lang} aria-current={language.current ? 'true' : undefined} class={s.link()}>{text(language)}</a>
64
+ </li>
65
+ ))}
66
+ </ul>
67
+ </nav>
68
+ ))}
@@ -0,0 +1,51 @@
1
+ [
2
+ {
3
+ "name": "list",
4
+ "props": {
5
+ "languages": [
6
+ {
7
+ "lang": "en",
8
+ "label": "English",
9
+ "href": "/about/",
10
+ "current": true
11
+ },
12
+ {
13
+ "lang": "de",
14
+ "label": "Deutsch",
15
+ "href": "/de/ueber-uns/"
16
+ },
17
+ {
18
+ "lang": "tr",
19
+ "label": "Türkçe",
20
+ "href": "/tr/hakkimizda/"
21
+ }
22
+ ]
23
+ }
24
+ },
25
+ {
26
+ "name": "menu-end-code",
27
+ "props": {
28
+ "layout": "menu",
29
+ "align": "end",
30
+ "labelStyle": "code",
31
+ "languages": [
32
+ {
33
+ "lang": "en",
34
+ "label": "English",
35
+ "href": "/",
36
+ "current": true
37
+ },
38
+ {
39
+ "lang": "pt-BR",
40
+ "label": "Português",
41
+ "href": "/pt/"
42
+ },
43
+ {
44
+ "lang": "ar",
45
+ "label": "العربية",
46
+ "href": "/ar/"
47
+ }
48
+ ]
49
+ }
50
+ }
51
+ ]
@@ -0,0 +1,73 @@
1
+ {
2
+ "id": "language-switcher",
3
+ "name": "LanguageSwitcher",
4
+ "description": "Links to the same page in the site's other languages, each with hreflang and its own lang; the current one is marked aria-current. A plain list or a native <details> menu; no JavaScript. The addresses are the page's equivalents in each language, supplied by the caller.",
5
+ "category": "chrome",
6
+ "uses": [],
7
+ "shared": ["safe-url.ts"],
8
+ "variants": {
9
+ "layout": {
10
+ "options": [
11
+ "list",
12
+ "menu"
13
+ ],
14
+ "default": "list",
15
+ "description": "A row of links, or a menu that shows the current language and opens on click."
16
+ },
17
+ "align": {
18
+ "options": [
19
+ "start",
20
+ "end"
21
+ ],
22
+ "default": "start",
23
+ "description": "Which edge the menu panel lines up with."
24
+ },
25
+ "labelStyle": {
26
+ "options": [
27
+ "name",
28
+ "code"
29
+ ],
30
+ "default": "name",
31
+ "description": "The language's own name (\"Deutsch\") or its code (\"DE\")."
32
+ }
33
+ },
34
+ "props": {
35
+ "languages": {
36
+ "type": "array",
37
+ "items": {
38
+ "type": "object",
39
+ "fields": {
40
+ "lang": {
41
+ "type": "string",
42
+ "required": true
43
+ },
44
+ "label": {
45
+ "type": "string",
46
+ "required": true
47
+ },
48
+ "href": {
49
+ "type": "url",
50
+ "required": true
51
+ },
52
+ "current": {
53
+ "type": "boolean"
54
+ }
55
+ }
56
+ },
57
+ "description": "The languages: BCP 47 code, the language's own name, the page's address in it, and whether it is the page being read."
58
+ },
59
+ "label": {
60
+ "type": "string",
61
+ "description": "Accessible name (ui-strings language.label)."
62
+ }
63
+ },
64
+ "slots": {},
65
+ "js": "none",
66
+ "dependencies": {},
67
+ "sources": {
68
+ "gutenberg": [
69
+ "polylang/language-switcher"
70
+ ]
71
+ },
72
+ "a11y": "List layout: a labelled <nav>; menu layout: a native <details> whose summary carries the label for assistive technology and the current language visibly. Every link has lang and hreflang, so screen readers pronounce each name in its language; the current one is aria-current=true. Fewer than two languages draw nothing."
73
+ }
@@ -1,32 +1,59 @@
1
1
  ---
2
- // A grid of client or partner logos, each optionally linked, under an
3
- // optional heading.
2
+ // A grid, a row or an endless strip of client or partner logos, each
3
+ // optionally linked, under an optional heading. The strip is CSS only and
4
+ // stands still for visitors who ask for reduced motion.
4
5
  import { tv } from 'tailwind-variants'
5
6
  import KitImage from '../_shared/KitImage.astro'
6
7
  import Section, { type Tone } from '../_shared/Section.astro'
7
8
  import { imageOf, type ImageSource } from '../_shared/image'
9
+ import { measuredGrid } from '../_shared/measured'
10
+ import type { MeasuredStyle } from '../_shared/types'
11
+ import { safeHref } from '../_shared/safe-url'
8
12
 
9
13
  const cloud = tv({
10
14
  slots: {
11
- heading: 'mb-8 text-center text-sm font-semibold tracking-wide uppercase opacity-80',
15
+ heading: 'mb-8 text-sm font-semibold tracking-wide uppercase opacity-80',
12
16
  list: 'grid grid-cols-2 items-center gap-x-8 gap-y-10 sm:grid-cols-3',
13
17
  item: 'flex items-center justify-center',
14
18
  logo: 'h-10 w-auto max-w-full object-contain transition duration-200',
15
19
  },
16
20
  variants: {
21
+ layout: {
22
+ grid: {},
23
+ // One line of logos that wraps on a narrow screen; the logos keep their own widths.
24
+ row: { list: 'flex flex-wrap items-center gap-x-10 gap-y-6', item: 'shrink-0' },
25
+ // Two copies of the list side by side, moved by one keyframe; see the style block below.
26
+ marquee: { list: 'kit-marquee-track flex w-max items-center gap-x-12', item: 'shrink-0' },
27
+ },
17
28
  columns: {
18
29
  '2': { list: 'sm:grid-cols-2' },
19
30
  '3': { list: 'md:grid-cols-3' },
20
31
  '4': { list: 'md:grid-cols-4' },
32
+ '5': { list: 'md:grid-cols-3 lg:grid-cols-5' },
33
+ '6': { list: 'md:grid-cols-3 lg:grid-cols-6' },
34
+ },
35
+ // A fixed height, the width follows: logos come in every aspect ratio.
36
+ logoHeight: {
37
+ sm: { logo: 'h-8' },
38
+ md: {},
39
+ lg: { logo: 'h-14' },
21
40
  },
22
41
  style: {
23
42
  plain: {},
24
43
  // Colour comes back on hover and on keyboard focus of the logo's link.
25
44
  grayscale: { logo: 'opacity-70 grayscale group-hover:opacity-100 group-hover:grayscale-0 group-focus-visible:opacity-100 group-focus-visible:grayscale-0' },
45
+ // Dark logos on a dark band: drawn white, full strength.
46
+ 'mono-light': { logo: 'brightness-0 invert' },
26
47
  },
48
+ align: { center: { heading: 'text-center' }, start: { heading: 'text-start' } },
27
49
  tone: { default: {}, muted: {}, inverse: {}, accent: { heading: 'opacity-100' } },
28
50
  },
29
- defaultVariants: { columns: '4', style: 'plain', tone: 'default' },
51
+ compoundVariants: [
52
+ { layout: 'row', align: 'center', class: { list: 'justify-center' } },
53
+ { layout: 'row', align: 'start', class: { list: 'justify-start' } },
54
+ { layout: 'grid', align: 'start', class: { item: 'justify-start' } },
55
+ ],
56
+ defaultVariants: { layout: 'grid', columns: '4', logoHeight: 'md', style: 'plain', align: 'center', tone: 'default' },
30
57
  })
31
58
 
32
59
  interface Item {
@@ -40,30 +67,68 @@ interface Item {
40
67
  interface Props {
41
68
  heading?: string | undefined
42
69
  items: Item[]
43
- columns?: '2' | '3' | '4' | undefined
44
- style?: 'plain' | 'grayscale' | undefined
70
+ layout?: 'grid' | 'row' | 'marquee' | undefined
71
+ columns?: '2' | '3' | '4' | '5' | '6' | undefined
72
+ logoHeight?: 'sm' | 'md' | 'lg' | undefined
73
+ style?: 'plain' | 'grayscale' | 'mono-light' | undefined
74
+ align?: 'center' | 'start' | undefined
45
75
  tone?: Tone | undefined
76
+ /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
77
+ measured?: MeasuredStyle | undefined
46
78
  id?: string | undefined
47
79
  }
48
80
 
49
- const { heading, items, columns, style, tone, id } = Astro.props
50
- const s = cloud({ columns, style, tone })
81
+ const { heading, items, layout, columns, logoHeight, style, align, tone, measured, id } = Astro.props
82
+ const s = cloud({ layout, columns, logoHeight, style, align, tone })
83
+ // Measured columns and gap shape the grid layout only; a row or a strip keeps its own flow.
84
+ const grid = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
51
85
  const headingId = heading && id ? `${id}-title` : undefined
86
+ const marquee = layout === 'marquee'
87
+ // A linked logo with no name is read as the site it goes to, never as a made-up word.
88
+ const hostOf = (href: string) => {
89
+ try { return new URL(href, 'https://x.invalid').host.replace(/^www\./, '').replace(/^x\.invalid$/, '') } catch { return '' }
90
+ }
91
+ const shown = items.flatMap((item) => {
92
+ const image = imageOf(item.image, item.imageAlt || (item.href ? hostOf(item.href) : ''))
93
+ return image ? [{ image, href: item.href }] : []
94
+ })
95
+ // About four seconds a logo: the strip reads at a pace, whatever its length.
96
+ const duration = `${Math.max(20, shown.length * 4)}s`
52
97
  ---
53
- <Section tone={tone} id={id} aria-labelledby={headingId}>
98
+ <Section measured={measured} tone={tone} id={id} aria-labelledby={headingId}>
54
99
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
55
- <ul class={s.list()}>
56
- {items.map(item => {
57
- const image = imageOf(item.image, item.imageAlt)
58
- if (!image) return null
59
- const logo = <KitImage image={image} sizes="160px" class={s.logo()} />
60
- return (
100
+ <div class:list={[marquee && 'kit-marquee overflow-hidden']} style={marquee ? `--kit-marquee-duration:${duration}` : undefined}>
101
+ <ul class={s.list({ class: grid })}>
102
+ {shown.map(({ image, href }) => (
61
103
  <li class={s.item()}>
62
- {item.href
63
- ? <a href={item.href} class="group rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4">{logo}</a>
64
- : <span class="group">{logo}</span>}
104
+ {href
105
+ ? <a href={safeHref(href)} class="group rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4"><KitImage image={image} sizes="160px" class={s.logo()} /></a>
106
+ : <span class="group"><KitImage image={image} sizes="160px" class={s.logo()} /></span>}
65
107
  </li>
66
- )
67
- })}
68
- </ul>
108
+ ))}
109
+ </ul>
110
+ {marquee && (
111
+ // The second copy exists only to close the loop: hidden from assistive technology and from the tab order.
112
+ <ul class={`${s.list()} kit-marquee-copy`} aria-hidden="true" inert>
113
+ {shown.map(({ image }) => (
114
+ <li class={s.item()}><span class="group"><KitImage image={image} sizes="160px" class={s.logo()} /></span></li>
115
+ ))}
116
+ </ul>
117
+ )}
118
+ </div>
69
119
  </Section>
120
+ <style>
121
+ /* Reduced motion (the default here): one wrapped row, no copy. */
122
+ .kit-marquee .kit-marquee-track { width: auto; flex-wrap: wrap; justify-content: center; }
123
+ .kit-marquee .kit-marquee-copy { display: none; }
124
+ @media (prefers-reduced-motion: no-preference) {
125
+ .kit-marquee { display: flex; width: 100%; mask-image: linear-gradient(to right, transparent, #000 10%, #000 90%, transparent); }
126
+ .kit-marquee .kit-marquee-track,
127
+ .kit-marquee .kit-marquee-copy { display: flex; flex-wrap: nowrap; width: max-content; flex-shrink: 0; justify-content: flex-start; padding-inline-end: 3rem; animation: kit-marquee var(--kit-marquee-duration, 40s) linear infinite; }
128
+ .kit-marquee:hover .kit-marquee-track,
129
+ .kit-marquee:hover .kit-marquee-copy,
130
+ .kit-marquee:focus-within .kit-marquee-track,
131
+ .kit-marquee:focus-within .kit-marquee-copy { animation-play-state: paused; }
132
+ }
133
+ @keyframes kit-marquee { to { transform: translateX(-100%); } }
134
+ </style>