@cogenta/theme-entreprise 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 +16 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/index.js +16 -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 +14 -0
  11. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  12. package/dist/src/render/blocks/collection-list.js +55 -0
  13. package/dist/src/render/blocks/collection-list.js.map +1 -0
  14. package/dist/src/render/blocks/cta.d.ts +12 -0
  15. package/dist/src/render/blocks/cta.d.ts.map +1 -0
  16. package/dist/src/render/blocks/cta.js +19 -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 +92 -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 +25 -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 +33 -0
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -0
  30. package/dist/src/render/blocks/gallery.d.ts +15 -0
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  32. package/dist/src/render/blocks/gallery.js +28 -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 +21 -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 +30 -0
  45. package/dist/src/render/blocks/logos.js.map +1 -0
  46. package/dist/src/render/blocks/media-figure.d.ts +13 -0
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  48. package/dist/src/render/blocks/media-figure.js +28 -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 +33 -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 +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 +23 -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 +23 -0
  69. package/dist/src/render/blocks/stats.js.map +1 -0
  70. package/dist/src/render/blocks/testimonial.d.ts +16 -0
  71. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  72. package/dist/src/render/blocks/testimonial.js +25 -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 +101 -0
  77. package/dist/src/render/chrome.js.map +1 -0
  78. package/dist/src/render/render-block.d.ts +24 -0
  79. package/dist/src/render/render-block.d.ts.map +1 -0
  80. package/dist/src/render/render-block.js +108 -0
  81. package/dist/src/render/render-block.js.map +1 -0
  82. package/dist/src/render/term-archive.d.ts +12 -0
  83. package/dist/src/render/term-archive.d.ts.map +1 -0
  84. package/dist/src/render/term-archive.js +34 -0
  85. package/dist/src/render/term-archive.js.map +1 -0
  86. package/dist/theme.config.d.ts +16 -0
  87. package/dist/theme.config.d.ts.map +1 -0
  88. package/dist/theme.config.js +46 -0
  89. package/dist/theme.config.js.map +1 -0
  90. package/package.json +57 -0
  91. package/src/index.ts +15 -0
  92. package/src/render/blocks/accordion.ts +60 -0
  93. package/src/render/blocks/collection-list.ts +122 -0
  94. package/src/render/blocks/cta.ts +48 -0
  95. package/src/render/blocks/embed.ts +115 -0
  96. package/src/render/blocks/faq.ts +61 -0
  97. package/src/render/blocks/feature-grid.ts +73 -0
  98. package/src/render/blocks/gallery.ts +48 -0
  99. package/src/render/blocks/hero.ts +60 -0
  100. package/src/render/blocks/logo-strip.ts +36 -0
  101. package/src/render/blocks/logos.ts +60 -0
  102. package/src/render/blocks/media-figure.ts +43 -0
  103. package/src/render/blocks/pricing-table.ts +77 -0
  104. package/src/render/blocks/prose.ts +11 -0
  105. package/src/render/blocks/quote.ts +48 -0
  106. package/src/render/blocks/stat-counter.ts +45 -0
  107. package/src/render/blocks/stats.ts +50 -0
  108. package/src/render/blocks/testimonial.ts +41 -0
  109. package/src/render/chrome.ts +128 -0
  110. package/src/render/render-block.ts +144 -0
  111. package/src/render/term-archive.ts +80 -0
  112. package/src/styles/archive.css +68 -0
  113. package/src/styles/base.css +895 -0
  114. package/src/styles/blocks.css +1433 -0
  115. package/src/styles/theme.css +36 -0
  116. package/src/styles/tokens.css +326 -0
  117. package/tokens.json +36 -0
@@ -0,0 +1,60 @@
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
+ * `blocks@2.0` (RFC 0001). Reuses `faq`'s zero-JS `<details>`/`<summary>`
13
+ * mechanics — expanding, keyboard operation and the expanded state
14
+ * announced to assistive technology all come from the browser — but with a
15
+ * markup vocabulary and class names of its own (`cg-panels`, not
16
+ * `cg-faq`) so the two blocks can diverge visually later without one
17
+ * depending on the other: "accordion" and "frequently asked question" are
18
+ * different editorial intents even where the underlying question/answer
19
+ * shape coincides.
20
+ *
21
+ * Where `faq` numbers its rows, this one reads as a stack of bordered
22
+ * panels with a rotating chevron mark, closer to a features/specification
23
+ * accordion than a support page.
24
+ */
25
+ function renderItem(item: AccordionItem, ctx: RenderContext): HtmlElement {
26
+ return h(
27
+ 'li',
28
+ { class: 'cg-panels__item' },
29
+ h(
30
+ 'details',
31
+ { class: 'cg-panels__details' },
32
+ h(
33
+ 'summary',
34
+ { class: 'cg-panels__question' },
35
+ h('span', { class: 'cg-panels__question-text' }, item.question),
36
+ h('span', { class: 'cg-panels__chevron', 'aria-hidden': 'true' }),
37
+ ),
38
+ h('div', { class: 'cg-panels__answer' }, renderRichText(ctx, item.answer)),
39
+ ),
40
+ )
41
+ }
42
+
43
+ export function renderAccordion(block: AccordionBlock, ctx: RenderContext): HtmlElement {
44
+ return h(
45
+ 'section',
46
+ { class: 'cg-panels', 'data-block': 'accordion' },
47
+ block.title === undefined
48
+ ? null
49
+ : heading(
50
+ blockHeadingTag('accordion') ?? 'h2',
51
+ { class: 'cg-panels__title', 'data-field': 'title' },
52
+ block.title,
53
+ ),
54
+ h(
55
+ 'ul',
56
+ { class: 'cg-panels__items' },
57
+ block.items.map((item) => renderItem(item, ctx)),
58
+ ),
59
+ )
60
+ }
@@ -0,0 +1,122 @@
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 B
22
+ * marks it `runtime: 'server'` for that reason. The read is *not* done
23
+ * here: `query` builds the request from the block's own fields
24
+ * (`@cogenta/theme-kit`'s `buildCollectionListQuery`, shared across every
25
+ * theme), the caller awaits `ctx.content.list(...)` before rendering
26
+ * starts, and this function stays a pure function of the entries handed to
27
+ * it.
28
+ */
29
+ export { buildCollectionListQuery as query }
30
+
31
+ /**
32
+ * A card in every layout: a 16:9 cover on top (`entryImage`, the same
33
+ * `coverImage`/`cover`/… convention every entry-carrying block on this site
34
+ * reads), then title and excerpt below it — the "services/insights card"
35
+ * register a B2B site's listing pages use, replacing the previous ledger
36
+ * row (a date rail beside plain text, no visual at all). An entry with no
37
+ * image still renders a complete card; the cover slot is simply absent
38
+ * rather than a placeholder box standing in for one.
39
+ */
40
+ function renderCover(entry: ContentEntry, ctx: RenderContext): HtmlElement | null {
41
+ const cover = entryImage(entry, ctx)
42
+ if (cover === undefined) return null
43
+ return h(
44
+ 'span',
45
+ { class: 'cg-list__cover' },
46
+ renderImageSource(cover, { className: 'cg-list__cover-image', loading: 'lazy' }),
47
+ )
48
+ }
49
+
50
+ function renderEntry(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
51
+ const date = entryDate(entry)
52
+ const excerpt = entryExcerpt(entry)
53
+ return h(
54
+ 'li',
55
+ { class: 'cg-list__row' },
56
+ renderCover(entry, ctx),
57
+ h(
58
+ 'div',
59
+ { class: 'cg-list__body' },
60
+ heading(
61
+ tag,
62
+ { class: 'cg-list__title' },
63
+ h('a', { class: 'cg-list__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
64
+ ),
65
+ excerpt === undefined ? null : h('p', { class: 'cg-list__excerpt' }, excerpt),
66
+ date === undefined
67
+ ? null
68
+ : h(
69
+ 'time',
70
+ { class: 'cg-list__date', datetime: date },
71
+ new Intl.DateTimeFormat(ctx.locale, { month: 'short', day: '2-digit' }).format(
72
+ new Date(date),
73
+ ),
74
+ ),
75
+ ),
76
+ )
77
+ }
78
+
79
+ export function renderCollectionList(
80
+ block: CollectionListBlock,
81
+ ctx: RenderContext,
82
+ entries: readonly ContentEntry[],
83
+ ): HtmlElement {
84
+ const hasTitle = block.title !== undefined
85
+ const entryTag = nestedHeadingTag('collectionList', hasTitle)
86
+ const items =
87
+ entries.length === 0
88
+ ? h('p', { class: 'cg-list__empty' }, ctx.t('collection.empty'))
89
+ : h(
90
+ 'ul',
91
+ { class: 'cg-list__items' },
92
+ entries.map((entry) => renderEntry(entry, ctx, entryTag)),
93
+ )
94
+
95
+ return h(
96
+ 'section',
97
+ {
98
+ class: 'cg-list',
99
+ 'data-block': 'collectionList',
100
+ 'data-layout': block.layout,
101
+ },
102
+ hasTitle
103
+ ? heading(
104
+ blockHeadingTag('collectionList') ?? 'h2',
105
+ { class: 'cg-list__title-heading', 'data-field': 'title' },
106
+ block.title ?? '',
107
+ )
108
+ : null,
109
+ block.layout === 'carousel'
110
+ ? h(
111
+ 'div',
112
+ {
113
+ class: 'cg-list__viewport',
114
+ role: 'region',
115
+ 'aria-label': block.title ?? ctx.t('collection.carousel'),
116
+ tabindex: '0',
117
+ },
118
+ items,
119
+ )
120
+ : items,
121
+ )
122
+ }
@@ -0,0 +1,48 @@
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
+ * A full-width flat band, not a garish sales strip: the ask on the left and
13
+ * the action on the right, separated by a vertical rule on wide screens —
14
+ * the layout a serious B2B site uses for "talk to us", never centred copy
15
+ * stacked above a button. `.cg-banner__inner` re-centres the ask/action
16
+ * pair to the page's own measure while the flat tinted fill itself goes
17
+ * edge to edge (`blocks.css`).
18
+ */
19
+ export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
20
+ return h(
21
+ 'section',
22
+ { class: 'cg-banner', 'data-block': 'cta' },
23
+ h(
24
+ 'div',
25
+ { class: 'cg-banner__inner' },
26
+ h(
27
+ 'div',
28
+ { class: 'cg-banner__content' },
29
+ heading(
30
+ blockHeadingTag('cta') ?? 'h2',
31
+ { class: 'cg-banner__title', 'data-field': 'title' },
32
+ block.title,
33
+ ),
34
+ block.text === undefined
35
+ ? null
36
+ : h('p', { class: 'cg-banner__text', 'data-field': 'text' }, block.text),
37
+ ),
38
+ h(
39
+ 'div',
40
+ { class: 'cg-banner__actions' },
41
+ // `actions` is required and non-empty for a `cta`, so the list is
42
+ // always rendered; the null branch stays reachable only through
43
+ // invalid data.
44
+ actionList(ctx, block.actions, block.title),
45
+ ),
46
+ ),
47
+ )
48
+ }
@@ -0,0 +1,115 @@
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, no poster
9
+ * image fetched from the provider. An embed that "only" loads a thumbnail
10
+ * has 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. Until that layer exists, the card is the honest
16
+ * rendering — it says what would be loaded and lets the visitor go there
17
+ * deliberately. The card is framed the same way `mediaFigure` and `gallery`
18
+ * are (a thin bordered plate with a small provider tag), so the three read
19
+ * as one family of "media placeholder" treatments.
20
+ */
21
+
22
+ /** `null` means "this provider has no embeddable frame we trust". */
23
+ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
24
+ const url = URL.parse(rawUrl)
25
+ if (url === null) return null
26
+ const segments = url.pathname.split('/').filter((segment) => segment !== '')
27
+
28
+ switch (provider) {
29
+ case 'youtube': {
30
+ // `youtube-nocookie.com` is the privacy-preserving host, and the only
31
+ // one this theme will point an iframe at.
32
+ const id = url.hostname.endsWith('youtu.be')
33
+ ? segments[0]
34
+ : (url.searchParams.get('v') ?? segments[1])
35
+ return id === undefined || id === null ? null : `https://www.youtube-nocookie.com/embed/${id}`
36
+ }
37
+ case 'vimeo': {
38
+ const id = segments.find((segment) => /^\d+$/.test(segment))
39
+ return id === undefined ? null : `https://player.vimeo.com/video/${id}`
40
+ }
41
+ case 'dailymotion': {
42
+ const id = url.hostname.endsWith('dai.ly') ? segments[0] : segments[segments.length - 1]
43
+ return id === undefined ? null : `https://geo.dailymotion.com/player.html?video=${id}`
44
+ }
45
+ case 'spotify': {
46
+ const [kind, id] = segments
47
+ return kind === undefined || id === undefined
48
+ ? null
49
+ : `https://open.spotify.com/embed/${kind}/${id}`
50
+ }
51
+ case 'soundcloud':
52
+ return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
53
+ default:
54
+ // bluesky, mastodon, other: a post embed is a script tag, which this
55
+ // theme does not load. The link card is the rendering.
56
+ return null
57
+ }
58
+ }
59
+
60
+ function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
61
+ return h(
62
+ 'div',
63
+ { class: 'cg-embed__placeholder' },
64
+ h('p', { class: 'cg-embed__provider', 'aria-hidden': 'true' }, block.provider),
65
+ h('p', { class: 'cg-embed__notice' }, reason),
66
+ h(
67
+ 'a',
68
+ {
69
+ class: 'cg-embed__link',
70
+ href: ctx.link(block.url),
71
+ rel: 'noopener noreferrer nofollow',
72
+ },
73
+ ctx.t('embed.open', { provider: block.provider }),
74
+ ),
75
+ )
76
+ }
77
+
78
+ export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
79
+ const source = block.consentRequired ? null : frameSource(block.provider, block.url)
80
+ // Video providers are 16:9 unless the editor framed them otherwise;
81
+ // without a ratio the frame would collapse and shift the layout as it
82
+ // loads.
83
+ const ratio = aspectRatio(block.ratio) ?? '16 / 9'
84
+
85
+ return h(
86
+ 'div',
87
+ {
88
+ class: 'cg-embed',
89
+ 'data-block': 'embed',
90
+ 'data-provider': block.provider,
91
+ 'data-consent': block.consentRequired ? 'required' : 'not-required',
92
+ style: `--cg-ratio:${ratio}`,
93
+ },
94
+ source === null
95
+ ? consentCard(
96
+ block,
97
+ ctx,
98
+ block.consentRequired
99
+ ? ctx.t('embed.consentRequired', { provider: block.provider })
100
+ : ctx.t('embed.unsupported', { provider: block.provider }),
101
+ )
102
+ : h('iframe', {
103
+ class: 'cg-embed__frame',
104
+ src: source,
105
+ // An iframe with no accessible name is announced as "frame" and
106
+ // nothing else. WCAG 4.1.2, and the single most common embed
107
+ // defect.
108
+ title: ctx.t('embed.title', { provider: block.provider }),
109
+ loading: 'lazy',
110
+ referrerpolicy: 'strict-origin-when-cross-origin',
111
+ allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
112
+ allowfullscreen: true,
113
+ }),
114
+ )
115
+ }
@@ -0,0 +1,61 @@
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, and the expanded state announced to assistive
14
+ * technology all come from the browser, at zero bytes of JavaScript.
15
+ *
16
+ * Each row carries a numeral (`01`, `02`, …) ahead of the question — the
17
+ * same indexing language `featureGrid` uses — and the disclosure affordance
18
+ * is a plus/minus mark drawn from two CSS borders, not an icon font.
19
+ *
20
+ * The question is plain text inside `<summary>`, never a heading: a heading
21
+ * nested in a `<summary>` renders inconsistently across screen readers, and
22
+ * the questions are already reachable as a list under the block's own
23
+ * heading.
24
+ */
25
+ function renderItem(item: FaqItem, ctx: RenderContext, index: number): HtmlElement {
26
+ const number = String(index + 1).padStart(2, '0')
27
+ return h(
28
+ 'li',
29
+ { class: 'cg-faq__item' },
30
+ h(
31
+ 'details',
32
+ { class: 'cg-faq__details' },
33
+ h(
34
+ 'summary',
35
+ { class: 'cg-faq__question' },
36
+ h('span', { class: 'cg-faq__index', 'aria-hidden': 'true' }, number),
37
+ h('span', { class: 'cg-faq__question-text' }, item.question),
38
+ ),
39
+ h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
40
+ ),
41
+ )
42
+ }
43
+
44
+ export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
45
+ return h(
46
+ 'section',
47
+ { class: 'cg-faq', 'data-block': 'faq' },
48
+ block.title === undefined
49
+ ? null
50
+ : heading(
51
+ blockHeadingTag('faq') ?? 'h2',
52
+ { class: 'cg-faq__title', 'data-field': 'title' },
53
+ block.title,
54
+ ),
55
+ h(
56
+ 'ul',
57
+ { class: 'cg-faq__items' },
58
+ block.items.map((item, index) => renderItem(item, ctx, index)),
59
+ ),
60
+ )
61
+ }
@@ -0,0 +1,73 @@
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
+ * This theme's "our services" section — a card grid, each card led by a
16
+ * real inline glyph (`renderIcon`) inside a flat, accent-tinted square, the
17
+ * B2B "capability card" register a consultancy or software vendor's
18
+ * services page uses, replacing the numbered ledger rows this block used to
19
+ * render (a plain list read as a table of contents, not as a services
20
+ * pitch a visitor scans in one pass).
21
+ *
22
+ * Capped at three columns on a wide screen — a six-item grid staying 3×2
23
+ * rather than drifting to four columns plus two orphans, the same
24
+ * discipline `theme-saas`'s own `featureGrid` holds.
25
+ *
26
+ * As in every other theme, the item's title carries the link so the link's
27
+ * accessible name is the feature's own name (WCAG 2.4.4) — the block has no
28
+ * separate label field to write a "learn more" with anyway.
29
+ */
30
+ function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
31
+ const title =
32
+ item.link === undefined
33
+ ? heading(tag, { class: 'cg-service__title' }, item.title)
34
+ : heading(
35
+ tag,
36
+ { class: 'cg-service__title' },
37
+ h('a', { class: 'cg-service__link', href: href(ctx, item.link) }, item.title),
38
+ )
39
+ return h(
40
+ 'li',
41
+ { class: 'cg-service' },
42
+ item.icon === undefined
43
+ ? null
44
+ : h(
45
+ 'span',
46
+ { class: 'cg-service__icon', 'data-icon': item.icon, 'aria-hidden': 'true' },
47
+ renderIcon(item.icon),
48
+ ),
49
+ title,
50
+ item.text === undefined ? null : h('p', { class: 'cg-service__text' }, item.text),
51
+ )
52
+ }
53
+
54
+ export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
55
+ const hasTitle = block.title !== undefined
56
+ const itemTag = nestedHeadingTag('featureGrid', hasTitle)
57
+ return h(
58
+ 'section',
59
+ { class: 'cg-services', 'data-block': 'featureGrid' },
60
+ hasTitle
61
+ ? heading(
62
+ blockHeadingTag('featureGrid') ?? 'h2',
63
+ { class: 'cg-services__title', 'data-field': 'title' },
64
+ block.title ?? '',
65
+ )
66
+ : null,
67
+ h(
68
+ 'ul',
69
+ { class: 'cg-services__items' },
70
+ block.items.map((item) => renderItem(item, ctx, itemTag)),
71
+ ),
72
+ )
73
+ }
@@ -0,0 +1,48 @@
1
+ import type { GalleryBlock } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * Ships **no JavaScript**. A scroll-snapping list gives touch swiping,
6
+ * trackpad scrolling and — because the list is focusable and labelled —
7
+ * arrow-key scrolling, with none of the failure modes of a scripted
8
+ * carousel: no auto-advance to fight (WCAG 2.2.2), no focus stolen from an
9
+ * off-screen slide, and it still works before any styling loads.
10
+ *
11
+ * Each tile is a thin-bordered plate rather than a shadowed, fully-rounded
12
+ * card — consistent with the framed treatment `mediaFigure` and `embed`
13
+ * use, so a page mixing all three reads as one system.
14
+ */
15
+ export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
16
+ const items = h(
17
+ 'ul',
18
+ { class: 'cg-gallery__items' },
19
+ block.items.map((item) =>
20
+ h(
21
+ 'li',
22
+ { class: 'cg-gallery__item' },
23
+ image(ctx, item.media, { sizes: '(min-width: 48rem) 22rem, 60vw' }),
24
+ ),
25
+ ),
26
+ )
27
+
28
+ return h(
29
+ 'section',
30
+ {
31
+ class: 'cg-gallery',
32
+ 'data-block': 'gallery',
33
+ 'data-layout': block.layout,
34
+ },
35
+ block.layout === 'carousel'
36
+ ? h(
37
+ 'div',
38
+ {
39
+ class: 'cg-gallery__viewport',
40
+ role: 'region',
41
+ 'aria-label': ctx.t('gallery.carousel'),
42
+ tabindex: '0',
43
+ },
44
+ items,
45
+ )
46
+ : items,
47
+ )
48
+ }
@@ -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
+ * Structurally distinct from a centred/split "marketing" hero: the eyebrow,
17
+ * title, subtitle and actions sit in a single left-aligned column with a
18
+ * vertical accent rule (`cg-hero__mark`) standing beside the copy — the
19
+ * "confident report cover" read the aesthetic direction asks for — while the
20
+ * media sits in its own bordered frame with corner marks, never behind a
21
+ * decorative gradient wash.
22
+ */
23
+ export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
24
+ const tag = blockHeadingTag('hero') ?? 'h1'
25
+ return h(
26
+ 'section',
27
+ { class: 'cg-hero', 'data-block': 'hero' },
28
+ h(
29
+ 'div',
30
+ { class: 'cg-hero__intro' },
31
+ h('span', { class: 'cg-hero__mark', 'aria-hidden': 'true' }),
32
+ h(
33
+ 'div',
34
+ { class: 'cg-hero__copy' },
35
+ block.eyebrow === undefined
36
+ ? null
37
+ : h('p', { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
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
+ ),
45
+ block.media === undefined
46
+ ? null
47
+ : h(
48
+ 'div',
49
+ { class: 'cg-hero__frame' },
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: 64rem) 46vw, 100vw',
57
+ }),
58
+ ),
59
+ )
60
+ }
@@ -0,0 +1,36 @@
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). The lighter-weight "as seen in" / social-proof row
6
+ * next to `logos`: no per-logo name or link, so no accessible-name field to
7
+ * require — each image's own alt text (set once, in the media library)
8
+ * names it, exactly as `logos` already falls back to when a link is absent.
9
+ *
10
+ * Unlike `logos`'s held-back-until-hovered grayscale treatment, this strip
11
+ * renders every mark at full colour and even weight in a single row — the
12
+ * quieter, denser "as seen in" convention, distinct from the client-cards
13
+ * feel `logos` is built for.
14
+ */
15
+ function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
16
+ return h(
17
+ 'li',
18
+ { class: 'cg-logo-band__item' },
19
+ image(ctx, item.media, { className: 'cg-logo-band__image', variant: { fit: 'contain' } }),
20
+ )
21
+ }
22
+
23
+ export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
24
+ return h(
25
+ 'figure',
26
+ { class: 'cg-logo-band', 'data-block': 'logoStrip' },
27
+ h(
28
+ 'ul',
29
+ { class: 'cg-logo-band__items' },
30
+ block.logos.map((item) => renderItem(item, ctx)),
31
+ ),
32
+ block.caption === undefined
33
+ ? null
34
+ : h('figcaption', { class: 'cg-logo-band__caption', 'data-field': 'caption' }, block.caption),
35
+ )
36
+ }
@@ -0,0 +1,60 @@
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
+ * A "trusted by" client strip. Contract B says the organisation's name **is**
13
+ * the accessible name of the link, so the logo image carries it as `alt`
14
+ * text when the media entity has none, and no visually hidden duplicate is
15
+ * added: a link whose only content is an image with `alt` text is already
16
+ * named.
17
+ *
18
+ * The restrained, grayscale-leaning treatment (`filter: grayscale()` in the
19
+ * stylesheet) is the "held back until hovered" client-logo language the
20
+ * aesthetic direction calls for — a `filter`, never `grayscale` baked into
21
+ * the media itself, since the underlying asset still arrives as a real
22
+ * `<img>` from `image()`.
23
+ */
24
+ function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
25
+ const logo = image(ctx, item.media, {
26
+ className: 'cg-clients__logo',
27
+ altFrom: item.name,
28
+ variant: { fit: 'contain' },
29
+ })
30
+ return h(
31
+ 'li',
32
+ { class: 'cg-clients__item' },
33
+ item.url === undefined
34
+ ? logo
35
+ : h(
36
+ 'a',
37
+ { class: 'cg-clients__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
38
+ logo,
39
+ ),
40
+ )
41
+ }
42
+
43
+ export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
44
+ return h(
45
+ 'section',
46
+ { class: 'cg-clients', 'data-block': 'logos' },
47
+ block.title === undefined
48
+ ? null
49
+ : heading(
50
+ blockHeadingTag('logos') ?? 'h2',
51
+ { class: 'cg-clients__title', 'data-field': 'title' },
52
+ block.title,
53
+ ),
54
+ h(
55
+ 'ul',
56
+ { class: 'cg-clients__items' },
57
+ block.items.map((item) => renderItem(item, ctx)),
58
+ ),
59
+ )
60
+ }