@cogenta/theme-association 0.3.2

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 (117) hide show
  1. package/LICENSE +373 -0
  2. package/dist/src/index.d.ts +21 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/index.js +21 -0
  5. package/dist/src/index.js.map +1 -0
  6. package/dist/src/render/blocks/accordion.d.ts +4 -0
  7. package/dist/src/render/blocks/accordion.d.ts.map +1 -0
  8. package/dist/src/render/blocks/accordion.js +16 -0
  9. package/dist/src/render/blocks/accordion.js.map +1 -0
  10. package/dist/src/render/blocks/collection-list.d.ts +12 -0
  11. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  12. package/dist/src/render/blocks/collection-list.js +64 -0
  13. package/dist/src/render/blocks/collection-list.js.map +1 -0
  14. package/dist/src/render/blocks/cta.d.ts +9 -0
  15. package/dist/src/render/blocks/cta.d.ts.map +1 -0
  16. package/dist/src/render/blocks/cta.js +16 -0
  17. package/dist/src/render/blocks/cta.js.map +1 -0
  18. package/dist/src/render/blocks/embed.d.ts +4 -0
  19. package/dist/src/render/blocks/embed.d.ts.map +1 -0
  20. package/dist/src/render/blocks/embed.js +76 -0
  21. package/dist/src/render/blocks/embed.js.map +1 -0
  22. package/dist/src/render/blocks/faq.d.ts +4 -0
  23. package/dist/src/render/blocks/faq.d.ts.map +1 -0
  24. package/dist/src/render/blocks/faq.js +21 -0
  25. package/dist/src/render/blocks/faq.js.map +1 -0
  26. package/dist/src/render/blocks/feature-grid.d.ts +4 -0
  27. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
  28. package/dist/src/render/blocks/feature-grid.js +27 -0
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -0
  30. package/dist/src/render/blocks/gallery.d.ts +14 -0
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  32. package/dist/src/render/blocks/gallery.js +27 -0
  33. package/dist/src/render/blocks/gallery.js.map +1 -0
  34. package/dist/src/render/blocks/hero.d.ts +15 -0
  35. package/dist/src/render/blocks/hero.d.ts.map +1 -0
  36. package/dist/src/render/blocks/hero.js +31 -0
  37. package/dist/src/render/blocks/hero.js.map +1 -0
  38. package/dist/src/render/blocks/logo-strip.d.ts +4 -0
  39. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
  40. package/dist/src/render/blocks/logo-strip.js +16 -0
  41. package/dist/src/render/blocks/logo-strip.js.map +1 -0
  42. package/dist/src/render/blocks/logos.d.ts +4 -0
  43. package/dist/src/render/blocks/logos.d.ts.map +1 -0
  44. package/dist/src/render/blocks/logos.js +22 -0
  45. package/dist/src/render/blocks/logos.js.map +1 -0
  46. package/dist/src/render/blocks/media-figure.d.ts +12 -0
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  48. package/dist/src/render/blocks/media-figure.js +27 -0
  49. package/dist/src/render/blocks/media-figure.js.map +1 -0
  50. package/dist/src/render/blocks/pricing-table.d.ts +4 -0
  51. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
  52. package/dist/src/render/blocks/pricing-table.js +29 -0
  53. package/dist/src/render/blocks/pricing-table.js.map +1 -0
  54. package/dist/src/render/blocks/prose.d.ts +9 -0
  55. package/dist/src/render/blocks/prose.d.ts.map +1 -0
  56. package/dist/src/render/blocks/prose.js +10 -0
  57. package/dist/src/render/blocks/prose.js.map +1 -0
  58. package/dist/src/render/blocks/quote.d.ts +13 -0
  59. package/dist/src/render/blocks/quote.d.ts.map +1 -0
  60. package/dist/src/render/blocks/quote.js +26 -0
  61. package/dist/src/render/blocks/quote.js.map +1 -0
  62. package/dist/src/render/blocks/stat-counter.d.ts +4 -0
  63. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
  64. package/dist/src/render/blocks/stat-counter.js +14 -0
  65. package/dist/src/render/blocks/stat-counter.js.map +1 -0
  66. package/dist/src/render/blocks/stats.d.ts +4 -0
  67. package/dist/src/render/blocks/stats.d.ts.map +1 -0
  68. package/dist/src/render/blocks/stats.js +18 -0
  69. package/dist/src/render/blocks/stats.js.map +1 -0
  70. package/dist/src/render/blocks/testimonial.d.ts +11 -0
  71. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  72. package/dist/src/render/blocks/testimonial.js +20 -0
  73. package/dist/src/render/blocks/testimonial.js.map +1 -0
  74. package/dist/src/render/chrome.d.ts +3 -0
  75. package/dist/src/render/chrome.d.ts.map +1 -0
  76. package/dist/src/render/chrome.js +106 -0
  77. package/dist/src/render/chrome.js.map +1 -0
  78. package/dist/src/render/render-block.d.ts +21 -0
  79. package/dist/src/render/render-block.d.ts.map +1 -0
  80. package/dist/src/render/render-block.js +104 -0
  81. package/dist/src/render/render-block.js.map +1 -0
  82. package/dist/src/render/term-archive.d.ts +3 -0
  83. package/dist/src/render/term-archive.d.ts.map +1 -0
  84. package/dist/src/render/term-archive.js +42 -0
  85. package/dist/src/render/term-archive.js.map +1 -0
  86. package/dist/theme.config.d.ts +15 -0
  87. package/dist/theme.config.d.ts.map +1 -0
  88. package/dist/theme.config.js +45 -0
  89. package/dist/theme.config.js.map +1 -0
  90. package/package.json +57 -0
  91. package/src/index.ts +20 -0
  92. package/src/render/blocks/accordion.ts +47 -0
  93. package/src/render/blocks/collection-list.ts +144 -0
  94. package/src/render/blocks/cta.ts +37 -0
  95. package/src/render/blocks/embed.ts +98 -0
  96. package/src/render/blocks/faq.ts +52 -0
  97. package/src/render/blocks/feature-grid.ts +67 -0
  98. package/src/render/blocks/gallery.ts +47 -0
  99. package/src/render/blocks/hero.ts +60 -0
  100. package/src/render/blocks/logo-strip.ts +31 -0
  101. package/src/render/blocks/logos.ts +52 -0
  102. package/src/render/blocks/media-figure.ts +38 -0
  103. package/src/render/blocks/pricing-table.ts +73 -0
  104. package/src/render/blocks/prose.ts +15 -0
  105. package/src/render/blocks/quote.ts +39 -0
  106. package/src/render/blocks/stat-counter.ts +36 -0
  107. package/src/render/blocks/stats.ts +45 -0
  108. package/src/render/blocks/testimonial.ts +36 -0
  109. package/src/render/chrome.ts +134 -0
  110. package/src/render/render-block.ts +140 -0
  111. package/src/render/term-archive.ts +92 -0
  112. package/src/styles/archive.css +129 -0
  113. package/src/styles/base.css +842 -0
  114. package/src/styles/blocks.css +1002 -0
  115. package/src/styles/theme.css +31 -0
  116. package/src/styles/tokens.css +332 -0
  117. package/tokens.json +36 -0
@@ -0,0 +1,144 @@
1
+ import type { CollectionListBlock } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ buildCollectionListQuery,
5
+ type ContentEntry,
6
+ entryExcerpt,
7
+ entryHref,
8
+ entryImage,
9
+ entryTitle,
10
+ type HeadingTag,
11
+ type HtmlElement,
12
+ h,
13
+ heading,
14
+ nestedHeadingTag,
15
+ type RenderContext,
16
+ renderImageSource,
17
+ } from '@cogenta/theme-kit'
18
+
19
+ /**
20
+ * The only block of the seventeen that reads data at render time — contract
21
+ * B marks it `runtime: 'server'` for that reason. The read is *not* done
22
+ * here: `query` builds the request from the block's own fields, the caller
23
+ * awaits `ctx.content.list(...)` before rendering starts, and this function
24
+ * stays a pure function of the entries handed to it.
25
+ */
26
+ export { buildCollectionListQuery as query }
27
+
28
+ /** A string field, read straight off the raw entry — never invented. */
29
+ function stringField(entry: ContentEntry, field: string): string | undefined {
30
+ const value = entry[field]
31
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined
32
+ }
33
+
34
+ /**
35
+ * "Upcoming events" cards are what this block is built around: a rounded,
36
+ * shadowed card whose left edge carries a big day/month badge, built
37
+ * straight from the entry's own `date` field when it has one — never from
38
+ * `entryDate` (which reads `publishedAt`/`createdAt`, a system field with a
39
+ * different meaning for an event than the date it actually happens on).
40
+ * `location` is read the same direct way, since it too is a schema field no
41
+ * shared helper names.
42
+ *
43
+ * An entry from a collection with neither `date` nor `location` — any other
44
+ * collection a site points a `collectionList` at — falls back to the same
45
+ * title/excerpt/cover row every other theme's list block shows, so this
46
+ * block stays generically usable, not association-only.
47
+ */
48
+ function renderEntry(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
49
+ const rawDate = stringField(entry, 'date')
50
+ const parsed = rawDate === undefined ? null : new Date(rawDate)
51
+ const hasDate = parsed !== null && !Number.isNaN(parsed.getTime())
52
+ const location = stringField(entry, 'location')
53
+ const excerpt = entryExcerpt(entry)
54
+ const cover = entryImage(entry, ctx, { width: 320, height: 320, fit: 'cover' })
55
+
56
+ const badge = !hasDate
57
+ ? null
58
+ : h(
59
+ 'time',
60
+ { class: 'cg-event-card__date', datetime: rawDate },
61
+ h('span', { class: 'cg-event-card__day' }, String((parsed as Date).getDate())),
62
+ h(
63
+ 'span',
64
+ { class: 'cg-event-card__month' },
65
+ new Intl.DateTimeFormat(ctx.locale, { month: 'short' }).format(parsed as Date),
66
+ ),
67
+ h(
68
+ 'span',
69
+ { class: 'cg-event-card__time' },
70
+ new Intl.DateTimeFormat(ctx.locale, { hour: 'numeric', minute: '2-digit' }).format(
71
+ parsed as Date,
72
+ ),
73
+ ),
74
+ )
75
+
76
+ return h(
77
+ 'li',
78
+ { class: 'cg-event-card' },
79
+ badge,
80
+ cover === undefined
81
+ ? null
82
+ : h('div', { class: 'cg-event-card__cover' }, renderImageSource(cover, { loading: 'lazy' })),
83
+ h(
84
+ 'div',
85
+ { class: 'cg-event-card__body' },
86
+ location === undefined ? null : h('p', { class: 'cg-event-card__location' }, location),
87
+ heading(
88
+ tag,
89
+ { class: 'cg-event-card__title' },
90
+ h(
91
+ 'a',
92
+ { class: 'cg-event-card__link', href: entryHref(entry, ctx) },
93
+ entryTitle(entry, ctx),
94
+ ),
95
+ ),
96
+ excerpt === undefined ? null : h('p', { class: 'cg-event-card__excerpt' }, excerpt),
97
+ ),
98
+ )
99
+ }
100
+
101
+ export function renderCollectionList(
102
+ block: CollectionListBlock,
103
+ ctx: RenderContext,
104
+ entries: readonly ContentEntry[],
105
+ ): HtmlElement {
106
+ const hasTitle = block.title !== undefined
107
+ const entryTag = nestedHeadingTag('collectionList', hasTitle)
108
+ const items =
109
+ entries.length === 0
110
+ ? h('p', { class: 'cg-list__empty' }, ctx.t('collection.empty'))
111
+ : h(
112
+ 'ul',
113
+ { class: 'cg-list__items cg-event-cards' },
114
+ entries.map((entry) => renderEntry(entry, ctx, entryTag)),
115
+ )
116
+
117
+ return h(
118
+ 'section',
119
+ {
120
+ class: 'cg-block cg-list',
121
+ 'data-block': 'collectionList',
122
+ 'data-layout': block.layout,
123
+ },
124
+ hasTitle
125
+ ? heading(
126
+ blockHeadingTag('collectionList') ?? 'h2',
127
+ { class: 'cg-list__title-heading', 'data-field': 'title' },
128
+ block.title ?? '',
129
+ )
130
+ : null,
131
+ block.layout === 'carousel'
132
+ ? h(
133
+ 'div',
134
+ {
135
+ class: 'cg-list__viewport',
136
+ role: 'region',
137
+ 'aria-label': block.title ?? ctx.t('collection.carousel'),
138
+ tabindex: '0',
139
+ },
140
+ items,
141
+ )
142
+ : items,
143
+ )
144
+ }
@@ -0,0 +1,37 @@
1
+ import type { CtaBlock } from '@cogenta/blocks'
2
+ import {
3
+ actionList,
4
+ blockHeadingTag,
5
+ type HtmlElement,
6
+ h,
7
+ heading,
8
+ type RenderContext,
9
+ } from '@cogenta/theme-kit'
10
+
11
+ /**
12
+ * The "Every gift counts" donate band — a full-width, tinted panel (the
13
+ * skin's own accent, softened) rather than a plain section, so the one block
14
+ * whose entire job is a call to act reads as the loudest thing on the page.
15
+ */
16
+ export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
17
+ return h(
18
+ 'section',
19
+ { class: 'cg-block cg-cta', 'data-block': 'cta' },
20
+ h(
21
+ 'div',
22
+ { class: 'cg-cta__inner' },
23
+ heading(
24
+ blockHeadingTag('cta') ?? 'h2',
25
+ { class: 'cg-cta__title', 'data-field': 'title' },
26
+ block.title,
27
+ ),
28
+ block.text === undefined
29
+ ? null
30
+ : h('p', { class: 'cg-cta__text', 'data-field': 'text' }, block.text),
31
+ // `actions` is required and non-empty for a `cta`, so the list is
32
+ // always rendered; the null branch stays reachable only through
33
+ // invalid data.
34
+ actionList(ctx, block.actions, block.title),
35
+ ),
36
+ )
37
+ }
@@ -0,0 +1,98 @@
1
+ import type { EmbedBlock } from '@cogenta/blocks'
2
+ import { aspectRatio, type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * Nothing here contacts a third party before the visitor has consented.
6
+ *
7
+ * When `consentRequired` is true the block renders a self-contained card and
8
+ * an outbound link — no `<iframe>`, no `<script>`, no preconnect. The theme
9
+ * ships no consent *button*, because it ships no JavaScript at all: granting
10
+ * consent is a site-wide decision that belongs to the consent layer, not to
11
+ * a block.
12
+ */
13
+
14
+ /** `null` means "this provider has no embeddable frame we trust". */
15
+ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
16
+ const url = URL.parse(rawUrl)
17
+ if (url === null) return null
18
+ const segments = url.pathname.split('/').filter((segment) => segment !== '')
19
+
20
+ switch (provider) {
21
+ case 'youtube': {
22
+ const id = url.hostname.endsWith('youtu.be')
23
+ ? segments[0]
24
+ : (url.searchParams.get('v') ?? segments[1])
25
+ return id === undefined || id === null ? null : `https://www.youtube-nocookie.com/embed/${id}`
26
+ }
27
+ case 'vimeo': {
28
+ const id = segments.find((segment) => /^\d+$/.test(segment))
29
+ return id === undefined ? null : `https://player.vimeo.com/video/${id}`
30
+ }
31
+ case 'dailymotion': {
32
+ const id = url.hostname.endsWith('dai.ly') ? segments[0] : segments[segments.length - 1]
33
+ return id === undefined ? null : `https://geo.dailymotion.com/player.html?video=${id}`
34
+ }
35
+ case 'spotify': {
36
+ const [kind, id] = segments
37
+ return kind === undefined || id === undefined
38
+ ? null
39
+ : `https://open.spotify.com/embed/${kind}/${id}`
40
+ }
41
+ case 'soundcloud':
42
+ return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
43
+ default:
44
+ // bluesky, mastodon, other: a post embed is a script tag, which this
45
+ // theme does not load. The link card is the rendering.
46
+ return null
47
+ }
48
+ }
49
+
50
+ function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
51
+ return h(
52
+ 'div',
53
+ { class: 'cg-embed__placeholder' },
54
+ h('p', { class: 'cg-embed__notice' }, reason),
55
+ h(
56
+ 'a',
57
+ {
58
+ class: 'cg-embed__link',
59
+ href: ctx.link(block.url),
60
+ rel: 'noopener noreferrer nofollow',
61
+ },
62
+ ctx.t('embed.open', { provider: block.provider }),
63
+ ),
64
+ )
65
+ }
66
+
67
+ export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
68
+ const source = block.consentRequired ? null : frameSource(block.provider, block.url)
69
+ const ratio = aspectRatio(block.ratio) ?? '16 / 9'
70
+
71
+ return h(
72
+ 'div',
73
+ {
74
+ class: 'cg-block cg-embed',
75
+ 'data-block': 'embed',
76
+ 'data-provider': block.provider,
77
+ 'data-consent': block.consentRequired ? 'required' : 'not-required',
78
+ style: `--cg-ratio:${ratio}`,
79
+ },
80
+ source === null
81
+ ? consentCard(
82
+ block,
83
+ ctx,
84
+ block.consentRequired
85
+ ? ctx.t('embed.consentRequired', { provider: block.provider })
86
+ : ctx.t('embed.unsupported', { provider: block.provider }),
87
+ )
88
+ : h('iframe', {
89
+ class: 'cg-embed__frame',
90
+ src: source,
91
+ title: ctx.t('embed.title', { provider: block.provider }),
92
+ loading: 'lazy',
93
+ referrerpolicy: 'strict-origin-when-cross-origin',
94
+ allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
95
+ allowfullscreen: true,
96
+ }),
97
+ )
98
+ }
@@ -0,0 +1,52 @@
1
+ import type { FaqBlock, FaqItem } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ type HtmlElement,
5
+ h,
6
+ heading,
7
+ type RenderContext,
8
+ renderRichText,
9
+ } from '@cogenta/theme-kit'
10
+
11
+ /**
12
+ * `<details>`/`<summary>` rather than a scripted accordion: expanding,
13
+ * keyboard operation, the expanded state announced to assistive technology
14
+ * and in-page search all come from the browser, at zero bytes of
15
+ * JavaScript — the same mechanism this theme's own mobile nav toggle uses.
16
+ *
17
+ * The question is plain text inside `<summary>`, not a heading: a heading
18
+ * nested in a summary is rendered inconsistently across screen readers, and
19
+ * the questions are already reachable as a list under the block's own
20
+ * heading.
21
+ */
22
+ function renderItem(item: FaqItem, ctx: RenderContext): HtmlElement {
23
+ return h(
24
+ 'li',
25
+ { class: 'cg-faq__item' },
26
+ h(
27
+ 'details',
28
+ { class: 'cg-faq__details' },
29
+ h('summary', { class: 'cg-faq__question' }, item.question),
30
+ h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
31
+ ),
32
+ )
33
+ }
34
+
35
+ export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
36
+ return h(
37
+ 'section',
38
+ { class: 'cg-block cg-faq', 'data-block': 'faq' },
39
+ block.title === undefined
40
+ ? null
41
+ : heading(
42
+ blockHeadingTag('faq') ?? 'h2',
43
+ { class: 'cg-faq__title', 'data-field': 'title' },
44
+ block.title,
45
+ ),
46
+ h(
47
+ 'ul',
48
+ { class: 'cg-faq__items' },
49
+ block.items.map((item) => renderItem(item, ctx)),
50
+ ),
51
+ )
52
+ }
@@ -0,0 +1,67 @@
1
+ import type { FeatureGridBlock, FeatureItem } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ type HeadingTag,
5
+ type HtmlElement,
6
+ h,
7
+ heading,
8
+ href,
9
+ nestedHeadingTag,
10
+ type RenderContext,
11
+ renderIcon,
12
+ } from '@cogenta/theme-kit'
13
+
14
+ /**
15
+ * "What we do" — three (or more) programme cards, each a rounded, softly
16
+ * shadowed tile with its icon in a round chip. The item's title carries the
17
+ * link, so the link's accessible name is the programme's own name (WCAG
18
+ * 2.4.4) — the block has no separate "learn more" field to write.
19
+ *
20
+ * `icon` names a symbol, never markup: it is exposed as a data attribute for
21
+ * the skin and, when the name is one `renderIcon` recognises, a real inline
22
+ * glyph — `aria-hidden`, since the title already names the programme.
23
+ */
24
+ function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
25
+ const title =
26
+ item.link === undefined
27
+ ? heading(tag, { class: 'cg-feature__title' }, item.title)
28
+ : heading(
29
+ tag,
30
+ { class: 'cg-feature__title' },
31
+ h('a', { class: 'cg-feature__link', href: href(ctx, item.link) }, item.title),
32
+ )
33
+ return h(
34
+ 'li',
35
+ { class: 'cg-feature' },
36
+ item.icon === undefined
37
+ ? null
38
+ : h(
39
+ 'span',
40
+ { class: 'cg-feature__icon', 'data-icon': item.icon, 'aria-hidden': 'true' },
41
+ renderIcon(item.icon),
42
+ ),
43
+ title,
44
+ item.text === undefined ? null : h('p', { class: 'cg-feature__text' }, item.text),
45
+ )
46
+ }
47
+
48
+ export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
49
+ const hasTitle = block.title !== undefined
50
+ const itemTag = nestedHeadingTag('featureGrid', hasTitle)
51
+ return h(
52
+ 'section',
53
+ { class: 'cg-block cg-features', 'data-block': 'featureGrid' },
54
+ hasTitle
55
+ ? heading(
56
+ blockHeadingTag('featureGrid') ?? 'h2',
57
+ { class: 'cg-features__title', 'data-field': 'title' },
58
+ block.title ?? '',
59
+ )
60
+ : null,
61
+ h(
62
+ 'ul',
63
+ { class: 'cg-features__items' },
64
+ block.items.map((item) => renderItem(item, ctx, itemTag)),
65
+ ),
66
+ )
67
+ }
@@ -0,0 +1,47 @@
1
+ import type { GalleryBlock } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * The carousel ships **no JavaScript**. It is a scroll-snapping list, which
6
+ * gives touch swiping, trackpad scrolling and — because the list is focusable
7
+ * and labelled — arrow-key scrolling, with none of the failure modes of a
8
+ * scripted carousel (WCAG 2.2.2).
9
+ *
10
+ * `grid` (the layout the association home page's photo gallery uses) shows
11
+ * six rounded, evenly sized tiles — a warm contact sheet rather than a rigid
12
+ * masonry wall.
13
+ */
14
+ export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
15
+ const items = h(
16
+ 'ul',
17
+ { class: 'cg-gallery__items' },
18
+ block.items.map((item) =>
19
+ h(
20
+ 'li',
21
+ { class: 'cg-gallery__item' },
22
+ image(ctx, item.media, { sizes: '(min-width: 45rem) 20rem, 60vw' }),
23
+ ),
24
+ ),
25
+ )
26
+
27
+ return h(
28
+ 'section',
29
+ {
30
+ class: 'cg-block cg-gallery',
31
+ 'data-block': 'gallery',
32
+ 'data-layout': block.layout,
33
+ },
34
+ block.layout === 'carousel'
35
+ ? h(
36
+ 'div',
37
+ {
38
+ class: 'cg-gallery__viewport',
39
+ role: 'region',
40
+ 'aria-label': ctx.t('gallery.carousel'),
41
+ tabindex: '0',
42
+ },
43
+ items,
44
+ )
45
+ : items,
46
+ )
47
+ }
@@ -0,0 +1,60 @@
1
+ import type { HeroBlock } from '@cogenta/blocks'
2
+ import {
3
+ actionList,
4
+ blockHeadingTag,
5
+ type HtmlElement,
6
+ h,
7
+ heading,
8
+ image,
9
+ type RenderContext,
10
+ } from '@cogenta/theme-kit'
11
+
12
+ /**
13
+ * The hero carries the page's `h1` (contract B: `headingLevel: 'h1'`), so
14
+ * `renderPage` relies on it to avoid emitting a second one.
15
+ *
16
+ * The warm, community read this theme opens with: a rounded "eyebrow" pill
17
+ * (the cause, not a generic label), a big friendly title in the display
18
+ * face, and — when there is one — the media sitting in its own big rounded
19
+ * card with a soft halo glow behind it (`cg-hero__halo`, purely decorative,
20
+ * `aria-hidden`), rather than a hard-edged frame. Centred copy on mobile,
21
+ * two columns from `md` up.
22
+ */
23
+ export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
24
+ const tag = blockHeadingTag('hero') ?? 'h1'
25
+ return h(
26
+ 'section',
27
+ { class: 'cg-block cg-hero', 'data-block': 'hero' },
28
+ h(
29
+ 'div',
30
+ { class: 'cg-hero__copy' },
31
+ block.eyebrow === undefined
32
+ ? null
33
+ : h(
34
+ 'p',
35
+ { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' },
36
+ h('span', { class: 'cg-pill' }, block.eyebrow),
37
+ ),
38
+ heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
39
+ block.subtitle === undefined
40
+ ? null
41
+ : h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
42
+ actionList(ctx, block.actions, ctx.t('hero.actions')),
43
+ ),
44
+ block.media === undefined
45
+ ? null
46
+ : h(
47
+ 'div',
48
+ { class: 'cg-hero__frame' },
49
+ h('span', { class: 'cg-hero__halo', 'aria-hidden': 'true' }),
50
+ // The only image above the fold by construction, so the only one
51
+ // that must not be lazy — a lazy-loaded LCP element is a measured
52
+ // Lighthouse regression, not a theoretical one.
53
+ image(ctx, block.media, {
54
+ className: 'cg-hero__media',
55
+ loading: 'eager',
56
+ sizes: '(min-width: 60rem) 44vw, 100vw',
57
+ }),
58
+ ),
59
+ )
60
+ }
@@ -0,0 +1,31 @@
1
+ import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * `blocks@2.0` (RFC 0001). "Our partners" — the lighter-weight social-proof
6
+ * row next to `logos`: no per-logo name or link, so no accessible-name field
7
+ * to require — each image's own alt text (set once, in the media library)
8
+ * names it.
9
+ */
10
+ function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
11
+ return h(
12
+ 'li',
13
+ { class: 'cg-logo-band__item' },
14
+ image(ctx, item.media, { className: 'cg-logo-band__image', variant: { fit: 'contain' } }),
15
+ )
16
+ }
17
+
18
+ export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
19
+ return h(
20
+ 'figure',
21
+ { class: 'cg-block cg-logo-band', 'data-block': 'logoStrip' },
22
+ h(
23
+ 'ul',
24
+ { class: 'cg-logo-band__items' },
25
+ block.logos.map((item) => renderItem(item, ctx)),
26
+ ),
27
+ block.caption === undefined
28
+ ? null
29
+ : h('figcaption', { class: 'cg-logo-band__caption', 'data-field': 'caption' }, block.caption),
30
+ )
31
+ }
@@ -0,0 +1,52 @@
1
+ import type { LogoItem, LogosBlock } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ type HtmlElement,
5
+ h,
6
+ heading,
7
+ image,
8
+ type RenderContext,
9
+ } from '@cogenta/theme-kit'
10
+
11
+ /**
12
+ * Contract B says the organisation's name **is** the accessible name of the
13
+ * link. So the logo image carries it as alt text when the media entity has
14
+ * none, and no visually hidden duplicate is added.
15
+ */
16
+ function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
17
+ const logo = image(ctx, item.media, {
18
+ className: 'cg-logo__image',
19
+ altFrom: item.name,
20
+ variant: { fit: 'contain' },
21
+ })
22
+ return h(
23
+ 'li',
24
+ { class: 'cg-logo' },
25
+ item.url === undefined
26
+ ? logo
27
+ : h(
28
+ 'a',
29
+ { class: 'cg-logo__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
30
+ logo,
31
+ ),
32
+ )
33
+ }
34
+
35
+ export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
36
+ return h(
37
+ 'section',
38
+ { class: 'cg-block cg-logos', 'data-block': 'logos' },
39
+ block.title === undefined
40
+ ? null
41
+ : heading(
42
+ blockHeadingTag('logos') ?? 'h2',
43
+ { class: 'cg-logos__title', 'data-field': 'title' },
44
+ block.title,
45
+ ),
46
+ h(
47
+ 'ul',
48
+ { class: 'cg-logos__items' },
49
+ block.items.map((item) => renderItem(item, ctx)),
50
+ ),
51
+ )
52
+ }
@@ -0,0 +1,38 @@
1
+ import type { MediaFigureBlock } from '@cogenta/blocks'
2
+ import { aspectRatio, type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * `<figure>`/`<figcaption>` rather than a div and a paragraph: the
6
+ * association between the picture and its caption is then in the markup.
7
+ *
8
+ * The media sits in a big rounded card (`cg-figure__media`, `--cg-radius-lg`)
9
+ * with a soft shadow, matching the hero's own frame treatment rather than a
10
+ * hard-edged photo.
11
+ */
12
+ export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext): HtmlElement {
13
+ const ratio = aspectRatio(block.ratio)
14
+ const hasCaption = block.caption !== undefined || block.credit !== undefined
15
+ return h(
16
+ 'figure',
17
+ {
18
+ class: 'cg-block cg-figure',
19
+ 'data-block': 'mediaFigure',
20
+ 'data-align': block.align ?? 'center',
21
+ style: ratio === undefined ? undefined : `--cg-ratio:${ratio}`,
22
+ },
23
+ image(ctx, block.media, {
24
+ className: 'cg-figure__media',
25
+ sizes: '(min-width: 45rem) 40rem, 100vw',
26
+ }),
27
+ hasCaption
28
+ ? h(
29
+ 'figcaption',
30
+ { class: 'cg-figure__caption' },
31
+ block.caption,
32
+ block.credit === undefined
33
+ ? null
34
+ : h('span', { class: 'cg-figure__credit', 'data-field': 'credit' }, block.credit),
35
+ )
36
+ : null,
37
+ )
38
+ }