@cogenta/theme-portfolio 1.1.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 +17 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/index.js +20 -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 +24 -0
  9. package/dist/src/render/blocks/accordion.js.map +1 -0
  10. package/dist/src/render/blocks/collection-list.d.ts +25 -0
  11. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  12. package/dist/src/render/blocks/collection-list.js +77 -0
  13. package/dist/src/render/blocks/collection-list.js.map +1 -0
  14. package/dist/src/render/blocks/cta.d.ts +10 -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 +88 -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 +23 -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 +30 -0
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -0
  30. package/dist/src/render/blocks/gallery.d.ts +18 -0
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  32. package/dist/src/render/blocks/gallery.js +34 -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 +23 -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 +28 -0
  45. package/dist/src/render/blocks/logos.js.map +1 -0
  46. package/dist/src/render/blocks/media-figure.d.ts +19 -0
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  48. package/dist/src/render/blocks/media-figure.js +36 -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 +16 -0
  55. package/dist/src/render/blocks/prose.d.ts.map +1 -0
  56. package/dist/src/render/blocks/prose.js +17 -0
  57. package/dist/src/render/blocks/prose.js.map +1 -0
  58. package/dist/src/render/blocks/quote.d.ts +18 -0
  59. package/dist/src/render/blocks/quote.d.ts.map +1 -0
  60. package/dist/src/render/blocks/quote.js +31 -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 +18 -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 +22 -0
  69. package/dist/src/render/blocks/stats.js.map +1 -0
  70. package/dist/src/render/blocks/testimonial.d.ts +20 -0
  71. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  72. package/dist/src/render/blocks/testimonial.js +29 -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 +114 -0
  77. package/dist/src/render/chrome.js.map +1 -0
  78. package/dist/src/render/render-block.d.ts +12 -0
  79. package/dist/src/render/render-block.d.ts.map +1 -0
  80. package/dist/src/render/render-block.js +111 -0
  81. package/dist/src/render/render-block.js.map +1 -0
  82. package/dist/src/render/term-archive.d.ts +14 -0
  83. package/dist/src/render/term-archive.d.ts.map +1 -0
  84. package/dist/src/render/term-archive.js +36 -0
  85. package/dist/src/render/term-archive.js.map +1 -0
  86. package/dist/theme.config.d.ts +26 -0
  87. package/dist/theme.config.d.ts.map +1 -0
  88. package/dist/theme.config.js +56 -0
  89. package/dist/theme.config.js.map +1 -0
  90. package/package.json +57 -0
  91. package/src/index.ts +19 -0
  92. package/src/render/blocks/accordion.ts +64 -0
  93. package/src/render/blocks/collection-list.ts +181 -0
  94. package/src/render/blocks/cta.ts +37 -0
  95. package/src/render/blocks/embed.ts +111 -0
  96. package/src/render/blocks/faq.ts +54 -0
  97. package/src/render/blocks/feature-grid.ts +68 -0
  98. package/src/render/blocks/gallery.ts +55 -0
  99. package/src/render/blocks/hero.ts +55 -0
  100. package/src/render/blocks/logo-strip.ts +38 -0
  101. package/src/render/blocks/logos.ts +63 -0
  102. package/src/render/blocks/media-figure.ts +51 -0
  103. package/src/render/blocks/pricing-table.ts +69 -0
  104. package/src/render/blocks/prose.ts +22 -0
  105. package/src/render/blocks/quote.ts +48 -0
  106. package/src/render/blocks/stat-counter.ts +49 -0
  107. package/src/render/blocks/stats.ts +49 -0
  108. package/src/render/blocks/testimonial.ts +45 -0
  109. package/src/render/chrome.ts +142 -0
  110. package/src/render/render-block.ts +155 -0
  111. package/src/render/term-archive.ts +88 -0
  112. package/src/styles/archive.css +40 -0
  113. package/src/styles/base.css +1056 -0
  114. package/src/styles/blocks.css +1468 -0
  115. package/src/styles/theme.css +20 -0
  116. package/src/styles/tokens.css +363 -0
  117. package/tokens.json +36 -0
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // The shared contract D primitives every theme package implements against —
2
+ // re-exported so a consumer of this package never has to know they live in
3
+ // `@cogenta/theme-kit` to use them alongside this theme's own blocks.
4
+ export * from '@cogenta/theme-kit'
5
+ export * from './render/blocks/collection-list.js'
6
+ export * from './render/blocks/cta.js'
7
+ export * from './render/blocks/embed.js'
8
+ export * from './render/blocks/faq.js'
9
+ export * from './render/blocks/feature-grid.js'
10
+ export * from './render/blocks/gallery.js'
11
+ export * from './render/blocks/hero.js'
12
+ export * from './render/blocks/logos.js'
13
+ export * from './render/blocks/media-figure.js'
14
+ export * from './render/blocks/prose.js'
15
+ export * from './render/blocks/quote.js'
16
+ export * from './render/blocks/stats.js'
17
+ export * from './render/chrome.js'
18
+ export * from './render/render-block.js'
19
+ export * from './render/term-archive.js'
@@ -0,0 +1,64 @@
1
+ import type { AccordionBlock, AccordionItem } 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
+ * Same zero-JS mechanism as `faq.ts` — `<details>`/`<summary>` gives
13
+ * expand/collapse, keyboard operation and the `[open]` state to assistive
14
+ * technology for free — but with markup and class names of its own
15
+ * (`cg-accordion__*`, never `cg-faq__*`) so the two blocks can diverge
16
+ * visually later without one quietly dragging the other along. RFC 0001
17
+ * treats them as two distinct editorial intents sharing one data shape; this
18
+ * package keeps that distinction in its markup too.
19
+ *
20
+ * The marker here is a plain rotating caret rather than `faq.ts`'s plus/
21
+ * cross, and questions sit in a numbered ledger the same way `logos.ts`'s
22
+ * items do — an accordion reads as an indexed list of topics, not a wall of
23
+ * frequently-asked questions.
24
+ */
25
+ function renderItem(item: AccordionItem, ctx: RenderContext, index: number): HtmlElement {
26
+ return h(
27
+ 'li',
28
+ { class: 'cg-accordion__item' },
29
+ h(
30
+ 'details',
31
+ { class: 'cg-accordion__details' },
32
+ h(
33
+ 'summary',
34
+ { class: 'cg-accordion__question' },
35
+ h(
36
+ 'span',
37
+ { class: 'cg-accordion__index', 'aria-hidden': 'true' },
38
+ String(index + 1).padStart(2, '0'),
39
+ ),
40
+ h('span', { class: 'cg-accordion__label' }, item.question),
41
+ ),
42
+ h('div', { class: 'cg-accordion__answer' }, renderRichText(ctx, item.answer)),
43
+ ),
44
+ )
45
+ }
46
+
47
+ export function renderAccordion(block: AccordionBlock, ctx: RenderContext): HtmlElement {
48
+ return h(
49
+ 'section',
50
+ { class: 'cg-block cg-accordion', 'data-block': 'accordion' },
51
+ block.title === undefined
52
+ ? null
53
+ : heading(
54
+ blockHeadingTag('accordion') ?? 'h2',
55
+ { class: 'cg-accordion__title', 'data-field': 'title' },
56
+ block.title,
57
+ ),
58
+ h(
59
+ 'ul',
60
+ { class: 'cg-accordion__items' },
61
+ block.items.map((item, index) => renderItem(item, ctx, index)),
62
+ ),
63
+ )
64
+ }
@@ -0,0 +1,181 @@
1
+ import type { CollectionListBlock } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ buildCollectionListQuery,
5
+ type ContentEntry,
6
+ entryDate,
7
+ entryExcerpt,
8
+ entryHref,
9
+ entryImage,
10
+ entryTitle,
11
+ type HeadingTag,
12
+ type HtmlElement,
13
+ h,
14
+ heading,
15
+ nestedHeadingTag,
16
+ type RenderContext,
17
+ renderImageSource,
18
+ } from '@cogenta/theme-kit'
19
+
20
+ /**
21
+ * The only block of the seventeen that reads data at render time — contract
22
+ * B marks it `runtime: 'server'` for that reason.
23
+ *
24
+ * The read is *not* done here. `query` builds the request
25
+ * (`@cogenta/theme-kit`'s `buildCollectionListQuery`, shared across every
26
+ * theme since it derives purely from the block's own fields), the caller
27
+ * awaits `ctx.content.list(...)` before rendering starts, and this function
28
+ * stays a pure function of the entries it is handed.
29
+ *
30
+ * `list` keeps this theme's original register — a numbered mono index, the
31
+ * title set at feature-headline scale, the date pushed to the far edge —
32
+ * unchanged since L23. `grid`/`carousel` (`theme@1.4`) retile the same
33
+ * entries into full-bleed project cards instead: a cover (`entryImage`,
34
+ * 4:3), the title in display type, and — read straight off the raw
35
+ * `ContentEntry` this block's entries already are, never invented — the
36
+ * entry's own `role`/`year` fields as a meta line when a collection happens
37
+ * to declare them (the `portfolio` blueprint's `project` does; any other
38
+ * collection simply shows no meta line).
39
+ */
40
+ export { buildCollectionListQuery as query }
41
+
42
+ function rawText(entry: ContentEntry, field: string): string | undefined {
43
+ const value = entry[field]
44
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined
45
+ }
46
+
47
+ /** `role`/`year`, read straight off the entry — the "raw field" meta line a grid card shows when the collection behind it has them. */
48
+ function renderCardMeta(entry: ContentEntry): HtmlElement | null {
49
+ const role = rawText(entry, 'role')
50
+ const year = rawText(entry, 'year')
51
+ if (role === undefined && year === undefined) return null
52
+ return h(
53
+ 'p',
54
+ { class: 'cg-collection__meta' },
55
+ role === undefined ? null : h('span', { class: 'cg-collection__meta-role' }, role),
56
+ year === undefined ? null : h('span', { class: 'cg-collection__meta-year' }, year),
57
+ )
58
+ }
59
+
60
+ function renderCard(
61
+ entry: ContentEntry,
62
+ ctx: RenderContext,
63
+ tag: HeadingTag,
64
+ index: number,
65
+ ): HtmlElement {
66
+ const cover = entryImage(entry, ctx, { width: 900, height: 675, fit: 'cover' })
67
+ return h(
68
+ 'li',
69
+ { class: 'cg-entry cg-entry--card' },
70
+ h(
71
+ 'article',
72
+ { class: 'cg-entry__body' },
73
+ h(
74
+ 'a',
75
+ { class: 'cg-entry__cover-link', href: entryHref(entry, ctx), tabindex: -1 },
76
+ cover === undefined
77
+ ? h('span', { class: 'cg-entry__cover-empty', 'aria-hidden': 'true' })
78
+ : renderImageSource(cover, {
79
+ className: 'cg-entry__cover',
80
+ sizes: '(min-width: 80rem) 50vw, 100vw',
81
+ }),
82
+ ),
83
+ h(
84
+ 'span',
85
+ { class: 'cg-entry__index', 'aria-hidden': 'true' },
86
+ String(index + 1).padStart(2, '0'),
87
+ ),
88
+ heading(
89
+ tag,
90
+ { class: 'cg-entry__title' },
91
+ h('a', { class: 'cg-entry__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
92
+ ),
93
+ renderCardMeta(entry),
94
+ ),
95
+ )
96
+ }
97
+
98
+ function renderRow(
99
+ entry: ContentEntry,
100
+ ctx: RenderContext,
101
+ tag: HeadingTag,
102
+ index: number,
103
+ ): HtmlElement {
104
+ const date = entryDate(entry)
105
+ const excerpt = entryExcerpt(entry)
106
+ return h(
107
+ 'li',
108
+ { class: 'cg-entry' },
109
+ h(
110
+ 'article',
111
+ { class: 'cg-entry__body' },
112
+ h(
113
+ 'span',
114
+ { class: 'cg-entry__index', 'aria-hidden': 'true' },
115
+ String(index + 1).padStart(2, '0'),
116
+ ),
117
+ heading(
118
+ tag,
119
+ { class: 'cg-entry__title' },
120
+ h('a', { class: 'cg-entry__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
121
+ ),
122
+ date === undefined
123
+ ? null
124
+ : h(
125
+ 'time',
126
+ { class: 'cg-entry__date', datetime: date },
127
+ // Formatted in the rendered locale rather than left as an ISO
128
+ // string: the machine-readable form is already in `datetime`.
129
+ new Intl.DateTimeFormat(ctx.locale, { dateStyle: 'long' }).format(new Date(date)),
130
+ ),
131
+ excerpt === undefined ? null : h('p', { class: 'cg-entry__excerpt' }, excerpt),
132
+ ),
133
+ )
134
+ }
135
+
136
+ export function renderCollectionList(
137
+ block: CollectionListBlock,
138
+ ctx: RenderContext,
139
+ entries: readonly ContentEntry[],
140
+ ): HtmlElement {
141
+ const hasTitle = block.title !== undefined
142
+ const entryTag = nestedHeadingTag('collectionList', hasTitle)
143
+ const isTiled = block.layout === 'grid' || block.layout === 'carousel'
144
+ const renderItem = isTiled ? renderCard : renderRow
145
+ const items =
146
+ entries.length === 0
147
+ ? h('p', { class: 'cg-collection__empty' }, ctx.t('collection.empty'))
148
+ : h(
149
+ 'ol',
150
+ { class: 'cg-collection__items' },
151
+ entries.map((entry, index) => renderItem(entry, ctx, entryTag, index)),
152
+ )
153
+
154
+ return h(
155
+ 'section',
156
+ {
157
+ class: 'cg-block cg-collection',
158
+ 'data-block': 'collectionList',
159
+ 'data-layout': block.layout,
160
+ },
161
+ hasTitle
162
+ ? heading(
163
+ blockHeadingTag('collectionList') ?? 'h2',
164
+ { class: 'cg-collection__title', 'data-field': 'title' },
165
+ block.title ?? '',
166
+ )
167
+ : null,
168
+ block.layout === 'carousel'
169
+ ? h(
170
+ 'div',
171
+ {
172
+ class: 'cg-collection__viewport',
173
+ role: 'region',
174
+ 'aria-label': block.title ?? ctx.t('collection.carousel'),
175
+ tabindex: '0',
176
+ },
177
+ items,
178
+ )
179
+ : items,
180
+ )
181
+ }
@@ -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 one panel on the page that inverts the palette — ink background, paper
13
+ * text — so it reads as a full stop rather than another section. The title
14
+ * is set at the same display scale as the hero: a call to action on a
15
+ * portfolio site is a second headline, not a footnote.
16
+ */
17
+ export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
18
+ return h(
19
+ 'section',
20
+ { class: 'cg-block cg-cta', 'data-block': 'cta' },
21
+ h(
22
+ 'div',
23
+ { class: 'cg-cta__frame' },
24
+ heading(
25
+ blockHeadingTag('cta') ?? 'h2',
26
+ { class: 'cg-cta__title', 'data-field': 'title' },
27
+ block.title,
28
+ ),
29
+ block.text === undefined
30
+ ? null
31
+ : h('p', { class: 'cg-cta__text', 'data-field': 'text' }, block.text),
32
+ // `actions` is required and non-empty for a `cta`, so the list is always
33
+ // rendered; the null branch stays reachable only through invalid data.
34
+ actionList(ctx, block.actions, block.title),
35
+ ),
36
+ )
37
+ }
@@ -0,0 +1,111 @@
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 tag, no preconnect, no poster
9
+ * image fetched from the provider. An embed that "only" loads a thumbnail has
10
+ * already handed the visitor's IP address and a cookie to the provider,
11
+ * which is the exact transfer consent was supposed to gate.
12
+ *
13
+ * The theme ships no consent *button*, because it ships no JavaScript:
14
+ * granting consent is a site-wide decision that belongs to the consent
15
+ * layer, not to a block. The card carries a bracketed provider tag in the
16
+ * mono register — `[ youtube ]` — the theme's own visual shorthand for
17
+ * "something external belongs here".
18
+ */
19
+
20
+ /** `null` means "this provider has no embeddable frame we trust". */
21
+ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
22
+ const url = URL.parse(rawUrl)
23
+ if (url === null) return null
24
+ const segments = url.pathname.split('/').filter((segment) => segment !== '')
25
+
26
+ switch (provider) {
27
+ case 'youtube': {
28
+ // `youtube-nocookie.com` is the privacy-preserving host, and the only one
29
+ // this theme will point an iframe at.
30
+ const id = url.hostname.endsWith('youtu.be')
31
+ ? segments[0]
32
+ : (url.searchParams.get('v') ?? segments[1])
33
+ return id === undefined || id === null ? null : `https://www.youtube-nocookie.com/embed/${id}`
34
+ }
35
+ case 'vimeo': {
36
+ const id = segments.find((segment) => /^\d+$/.test(segment))
37
+ return id === undefined ? null : `https://player.vimeo.com/video/${id}`
38
+ }
39
+ case 'dailymotion': {
40
+ const id = url.hostname.endsWith('dai.ly') ? segments[0] : segments[segments.length - 1]
41
+ return id === undefined ? null : `https://geo.dailymotion.com/player.html?video=${id}`
42
+ }
43
+ case 'spotify': {
44
+ const [kind, id] = segments
45
+ return kind === undefined || id === undefined
46
+ ? null
47
+ : `https://open.spotify.com/embed/${kind}/${id}`
48
+ }
49
+ case 'soundcloud':
50
+ return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
51
+ default:
52
+ // bluesky, mastodon, other: a post embed is a script tag, which this
53
+ // theme does not load. The link card is the rendering.
54
+ return null
55
+ }
56
+ }
57
+
58
+ function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
59
+ return h(
60
+ 'div',
61
+ { class: 'cg-embed__placeholder' },
62
+ h('span', { class: 'cg-embed__tag' }, `[ ${block.provider} ]`),
63
+ h('p', { class: 'cg-embed__notice' }, reason),
64
+ h(
65
+ 'a',
66
+ {
67
+ class: 'cg-embed__link',
68
+ href: ctx.link(block.url),
69
+ rel: 'noopener noreferrer nofollow',
70
+ },
71
+ ctx.t('embed.open', { provider: block.provider }),
72
+ ),
73
+ )
74
+ }
75
+
76
+ export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
77
+ const source = block.consentRequired ? null : frameSource(block.provider, block.url)
78
+ // Video providers are 16:9 unless the editor framed them otherwise; without a
79
+ // ratio the frame would collapse and shift the layout as it loads.
80
+ const ratio = aspectRatio(block.ratio) ?? '16 / 9'
81
+
82
+ return h(
83
+ 'div',
84
+ {
85
+ class: 'cg-block cg-embed',
86
+ 'data-block': 'embed',
87
+ 'data-provider': block.provider,
88
+ 'data-consent': block.consentRequired ? 'required' : 'not-required',
89
+ style: `--cg-ratio:${ratio}`,
90
+ },
91
+ source === null
92
+ ? consentCard(
93
+ block,
94
+ ctx,
95
+ block.consentRequired
96
+ ? ctx.t('embed.consentRequired', { provider: block.provider })
97
+ : ctx.t('embed.unsupported', { provider: block.provider }),
98
+ )
99
+ : h('iframe', {
100
+ class: 'cg-embed__frame',
101
+ src: source,
102
+ // An iframe with no accessible name is announced as "frame" and
103
+ // nothing else. WCAG 4.1.2, and the single most common embed defect.
104
+ title: ctx.t('embed.title', { provider: block.provider }),
105
+ loading: 'lazy',
106
+ referrerpolicy: 'strict-origin-when-cross-origin',
107
+ allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
108
+ allowfullscreen: true,
109
+ }),
110
+ )
111
+ }
@@ -0,0 +1,54 @@
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.
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. A CSS-drawn plus mark (rotated to a cross on `[open]`) replaces
21
+ * the native marker rather than a chevron — the theme's own vocabulary of
22
+ * shapes, kept purely visual.
23
+ */
24
+ function renderItem(item: FaqItem, ctx: RenderContext): HtmlElement {
25
+ return h(
26
+ 'li',
27
+ { class: 'cg-faq__item' },
28
+ h(
29
+ 'details',
30
+ { class: 'cg-faq__details' },
31
+ h('summary', { class: 'cg-faq__question' }, h('span', {}, item.question)),
32
+ h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
33
+ ),
34
+ )
35
+ }
36
+
37
+ export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
38
+ return h(
39
+ 'section',
40
+ { class: 'cg-block cg-faq', 'data-block': 'faq' },
41
+ block.title === undefined
42
+ ? null
43
+ : heading(
44
+ blockHeadingTag('faq') ?? 'h2',
45
+ { class: 'cg-faq__title', 'data-field': 'title' },
46
+ block.title,
47
+ ),
48
+ h(
49
+ 'ul',
50
+ { class: 'cg-faq__items' },
51
+ block.items.map((item) => renderItem(item, ctx)),
52
+ ),
53
+ )
54
+ }
@@ -0,0 +1,68 @@
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
+ * The item's title is the link, so the link's accessible name is the
16
+ * feature's name — a bare "learn more" repeated across a grid is the
17
+ * classic failure of WCAG 2.4.4, and the block carries no label field to
18
+ * write one anyway.
19
+ *
20
+ * `icon` names a symbol (`renderIcon`, `theme@1.4`) — drawn small and
21
+ * accent-coloured, decorative (`aria-hidden`, the title already carries the
22
+ * accessible name) — sitting *above* the running index numeral this theme
23
+ * has always drawn from a CSS counter, never replacing it: the numeral is
24
+ * this theme's own editorial device, the icon is the shared vocabulary's.
25
+ * An item with no icon, or one whose name this theme does not recognise,
26
+ * keeps exactly the pre-1.4 numeral-only card.
27
+ */
28
+ function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
29
+ const title =
30
+ item.link === undefined
31
+ ? heading(tag, { class: 'cg-feature__title' }, item.title)
32
+ : heading(
33
+ tag,
34
+ { class: 'cg-feature__title' },
35
+ h('a', { class: 'cg-feature__link', href: href(ctx, item.link) }, item.title),
36
+ )
37
+ const icon =
38
+ item.icon === undefined ? null : renderIcon(item.icon, { className: 'cg-feature__icon' })
39
+ return h(
40
+ 'li',
41
+ { class: 'cg-feature', 'data-icon': item.icon },
42
+ icon,
43
+ h('span', { class: 'cg-feature__index', 'aria-hidden': 'true' }),
44
+ title,
45
+ item.text === undefined ? null : h('p', { class: 'cg-feature__text' }, item.text),
46
+ )
47
+ }
48
+
49
+ export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
50
+ const hasTitle = block.title !== undefined
51
+ const itemTag = nestedHeadingTag('featureGrid', hasTitle)
52
+ return h(
53
+ 'section',
54
+ { class: 'cg-block cg-features', 'data-block': 'featureGrid' },
55
+ hasTitle
56
+ ? heading(
57
+ blockHeadingTag('featureGrid') ?? 'h2',
58
+ { class: 'cg-features__title', 'data-field': 'title' },
59
+ block.title ?? '',
60
+ )
61
+ : null,
62
+ h(
63
+ 'ul',
64
+ { class: 'cg-features__items' },
65
+ block.items.map((item) => renderItem(item, ctx, itemTag)),
66
+ ),
67
+ )
68
+ }
@@ -0,0 +1,55 @@
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: no auto-advance to fight (WCAG 2.2.2), no focus stolen
9
+ * from an off-screen slide, and it still works before hydration.
10
+ *
11
+ * Every tile carries a decorative index badge (`aria-hidden`, a CSS counter)
12
+ * — a numbered contact sheet rather than a plain image wall, and never a
13
+ * second name for the picture a screen reader would announce.
14
+ *
15
+ * A focusable scrollable region needs an accessible name, or a screen reader
16
+ * announces an unlabelled tab stop; that is WCAG 2.4.3 and 4.1.2 both.
17
+ */
18
+ export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
19
+ const items = h(
20
+ 'ul',
21
+ { class: 'cg-gallery__items' },
22
+ block.items.map((item) =>
23
+ h(
24
+ 'li',
25
+ { class: 'cg-gallery__item' },
26
+ h('span', { class: 'cg-gallery__index', 'aria-hidden': 'true' }),
27
+ image(ctx, item.media, {
28
+ className: 'cg-gallery__image',
29
+ sizes: '(min-width: 48rem) 22rem, 60vw',
30
+ }),
31
+ ),
32
+ ),
33
+ )
34
+
35
+ return h(
36
+ 'section',
37
+ {
38
+ class: 'cg-block cg-gallery',
39
+ 'data-block': 'gallery',
40
+ 'data-layout': block.layout,
41
+ },
42
+ block.layout === 'carousel'
43
+ ? h(
44
+ 'div',
45
+ {
46
+ class: 'cg-gallery__viewport',
47
+ role: 'region',
48
+ 'aria-label': ctx.t('gallery.carousel'),
49
+ tabindex: '0',
50
+ },
51
+ items,
52
+ )
53
+ : items,
54
+ )
55
+ }
@@ -0,0 +1,55 @@
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 declares `headingLevel: 'h1'`
14
+ * for it, and `renderPage` relies on that to avoid emitting a second one.
15
+ *
16
+ * Structurally distinct from a centred banner: the eyebrow becomes a running
17
+ * index mark set in the mono register (`— eyebrow`), the title is a display
18
+ * headline that overflows its own column on wide screens (`blocks.css` gives
19
+ * it a fluid `clamp()`), and the media is offset into the title's own
20
+ * negative margin rather than sitting in a tidy second column — the overlap
21
+ * is what makes this a poster, not a slide.
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__frame' },
31
+ block.eyebrow === undefined
32
+ ? null
33
+ : h('p', { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
34
+ heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
35
+ block.subtitle === undefined
36
+ ? null
37
+ : h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
38
+ actionList(ctx, block.actions, ctx.t('hero.actions')),
39
+ ),
40
+ block.media === undefined
41
+ ? null
42
+ : h(
43
+ 'div',
44
+ { class: 'cg-hero__media' },
45
+ // The only image on the page that is above the fold by construction,
46
+ // so the only one that must not be lazy: lazy-loading the LCP element
47
+ // is a measured Lighthouse regression, not a theoretical one.
48
+ image(ctx, block.media, {
49
+ className: 'cg-hero__image',
50
+ loading: 'eager',
51
+ sizes: '(min-width: 64rem) 46vw, 100vw',
52
+ }),
53
+ ),
54
+ )
55
+ }
@@ -0,0 +1,38 @@
1
+ import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * Lighter-weight than `logos` on purpose (RFC 0001): no per-mark `name`/
6
+ * `url`, so no link, no accessible-name field to write, and no numbered
7
+ * ledger row — this is the quieter "as seen in" strip, marks set edge to
8
+ * edge with no dividers, desaturated exactly like `logos.ts`'s own marks
9
+ * (the theme's one visual language for "a logo not currently in focus").
10
+ * The image's own alt text (set once, in the media library) is what names
11
+ * each mark, same as `logos.ts` falls back to when a link is absent — never
12
+ * invented here, which is why `image` is called with no `altFrom`.
13
+ */
14
+ function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
15
+ return h(
16
+ 'li',
17
+ { class: 'cg-logostrip__item' },
18
+ image(ctx, item.media, {
19
+ className: 'cg-logostrip__image',
20
+ variant: { fit: 'contain' },
21
+ }),
22
+ )
23
+ }
24
+
25
+ export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
26
+ return h(
27
+ 'figure',
28
+ { class: 'cg-block cg-logostrip', 'data-block': 'logoStrip' },
29
+ h(
30
+ 'ul',
31
+ { class: 'cg-logostrip__items' },
32
+ block.logos.map((item) => renderItem(item, ctx)),
33
+ ),
34
+ block.caption === undefined
35
+ ? null
36
+ : h('figcaption', { class: 'cg-logostrip__caption', 'data-field': 'caption' }, block.caption),
37
+ )
38
+ }