@cogenta/theme-entreprise 1.1.2 → 1.3.3

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 (115) hide show
  1. package/dist/src/index.d.ts +1 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +1 -0
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/render/blocks/accordion.d.ts.map +1 -1
  6. package/dist/src/render/blocks/accordion.js +12 -15
  7. package/dist/src/render/blocks/accordion.js.map +1 -1
  8. package/dist/src/render/blocks/collection-list.d.ts +5 -7
  9. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  10. package/dist/src/render/blocks/collection-list.js +48 -32
  11. package/dist/src/render/blocks/collection-list.js.map +1 -1
  12. package/dist/src/render/blocks/cta.d.ts +6 -6
  13. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  14. package/dist/src/render/blocks/cta.js +12 -12
  15. package/dist/src/render/blocks/cta.js.map +1 -1
  16. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  17. package/dist/src/render/blocks/embed.js +22 -27
  18. package/dist/src/render/blocks/embed.js.map +1 -1
  19. package/dist/src/render/blocks/faq.d.ts.map +1 -1
  20. package/dist/src/render/blocks/faq.js +13 -16
  21. package/dist/src/render/blocks/faq.js.map +1 -1
  22. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  23. package/dist/src/render/blocks/feature-grid.js +22 -22
  24. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  25. package/dist/src/render/blocks/gallery.d.ts +7 -8
  26. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  27. package/dist/src/render/blocks/gallery.js +16 -14
  28. package/dist/src/render/blocks/gallery.js.map +1 -1
  29. package/dist/src/render/blocks/hero.d.ts +13 -8
  30. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  31. package/dist/src/render/blocks/hero.js +27 -17
  32. package/dist/src/render/blocks/hero.js.map +1 -1
  33. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
  34. package/dist/src/render/blocks/logo-strip.js +14 -11
  35. package/dist/src/render/blocks/logo-strip.js.map +1 -1
  36. package/dist/src/render/blocks/logos.d.ts.map +1 -1
  37. package/dist/src/render/blocks/logos.js +21 -12
  38. package/dist/src/render/blocks/logos.js.map +1 -1
  39. package/dist/src/render/blocks/media-figure.d.ts +10 -6
  40. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  41. package/dist/src/render/blocks/media-figure.js +18 -13
  42. package/dist/src/render/blocks/media-figure.js.map +1 -1
  43. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  44. package/dist/src/render/blocks/pricing-table.js +15 -11
  45. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  46. package/dist/src/render/blocks/prose.d.ts +9 -3
  47. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  48. package/dist/src/render/blocks/prose.js +11 -4
  49. package/dist/src/render/blocks/prose.js.map +1 -1
  50. package/dist/src/render/blocks/quote.d.ts +10 -10
  51. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  52. package/dist/src/render/blocks/quote.js +14 -13
  53. package/dist/src/render/blocks/quote.js.map +1 -1
  54. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
  55. package/dist/src/render/blocks/stat-counter.js +11 -13
  56. package/dist/src/render/blocks/stat-counter.js.map +1 -1
  57. package/dist/src/render/blocks/stats.d.ts.map +1 -1
  58. package/dist/src/render/blocks/stats.js +12 -13
  59. package/dist/src/render/blocks/stats.js.map +1 -1
  60. package/dist/src/render/blocks/testimonial.d.ts +9 -9
  61. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  62. package/dist/src/render/blocks/testimonial.js +15 -14
  63. package/dist/src/render/blocks/testimonial.js.map +1 -1
  64. package/dist/src/render/chrome.d.ts +3 -1
  65. package/dist/src/render/chrome.d.ts.map +1 -1
  66. package/dist/src/render/chrome.js +71 -46
  67. package/dist/src/render/chrome.js.map +1 -1
  68. package/dist/src/render/layout.d.ts +26 -0
  69. package/dist/src/render/layout.d.ts.map +1 -0
  70. package/dist/src/render/layout.js +51 -0
  71. package/dist/src/render/layout.js.map +1 -0
  72. package/dist/src/render/render-block.d.ts.map +1 -1
  73. package/dist/src/render/render-block.js +23 -2
  74. package/dist/src/render/render-block.js.map +1 -1
  75. package/dist/src/render/term-archive.d.ts +8 -6
  76. package/dist/src/render/term-archive.d.ts.map +1 -1
  77. package/dist/src/render/term-archive.js +22 -16
  78. package/dist/src/render/term-archive.js.map +1 -1
  79. package/dist/src/render/widget-areas.d.ts +13 -0
  80. package/dist/src/render/widget-areas.d.ts.map +1 -0
  81. package/dist/src/render/widget-areas.js +20 -0
  82. package/dist/src/render/widget-areas.js.map +1 -0
  83. package/dist/theme.config.js +2 -2
  84. package/dist/theme.config.js.map +1 -1
  85. package/package.json +5 -5
  86. package/src/index.ts +1 -0
  87. package/src/render/blocks/accordion.ts +32 -27
  88. package/src/render/blocks/collection-list.ts +84 -53
  89. package/src/render/blocks/cta.ts +27 -28
  90. package/src/render/blocks/embed.ts +46 -43
  91. package/src/render/blocks/faq.ts +25 -21
  92. package/src/render/blocks/feature-grid.ts +42 -35
  93. package/src/render/blocks/gallery.ts +21 -16
  94. package/src/render/blocks/hero.ts +40 -28
  95. package/src/render/blocks/logo-strip.ts +26 -16
  96. package/src/render/blocks/logos.ts +33 -17
  97. package/src/render/blocks/media-figure.ts +22 -14
  98. package/src/render/blocks/pricing-table.ts +26 -15
  99. package/src/render/blocks/prose.ts +18 -4
  100. package/src/render/blocks/quote.ts +18 -14
  101. package/src/render/blocks/stat-counter.ts +25 -20
  102. package/src/render/blocks/stats.ts +31 -21
  103. package/src/render/blocks/testimonial.ts +22 -18
  104. package/src/render/chrome.ts +84 -49
  105. package/src/render/layout.ts +67 -0
  106. package/src/render/render-block.ts +24 -1
  107. package/src/render/term-archive.ts +54 -50
  108. package/src/render/widget-areas.ts +23 -0
  109. package/src/styles/archive.css +135 -38
  110. package/src/styles/base.css +557 -430
  111. package/src/styles/blocks.css +1192 -1000
  112. package/src/styles/theme.css +18 -24
  113. package/src/styles/tokens.css +162 -192
  114. package/src/styles/widgets.css +913 -0
  115. package/tokens.json +17 -17
@@ -1,22 +1,21 @@
1
1
  import type { EmbedBlock } from '@cogenta/blocks'
2
2
  import { aspectRatio, type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
6
  * Nothing here contacts a third party before the visitor has consented.
6
7
  *
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.
8
+ * When `consentRequired` is true the block renders a notice and an outbound
9
+ * link: no `<iframe>`, no `<script>`, no preconnect, no poster fetched from
10
+ * the provider. An embed that "only" loads a thumbnail has already handed
11
+ * the visitor's IP address to the provider, which is the transfer consent
12
+ * was supposed to gate.
12
13
  *
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.
14
+ * The theme ships no consent button, because it ships no JavaScript:
15
+ * granting consent is a site-wide decision for the consent layer, not a
16
+ * block. The notice is the honest rendering until that layer exists. It
17
+ * holds the same eight central columns and the same ratio the frame would,
18
+ * so the page does not reflow when consent is later granted.
20
19
  */
21
20
 
22
21
  /** `null` means "this provider has no embeddable frame we trust". */
@@ -28,7 +27,7 @@ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string |
28
27
  switch (provider) {
29
28
  case 'youtube': {
30
29
  // `youtube-nocookie.com` is the privacy-preserving host, and the only
31
- // one this theme will point an iframe at.
30
+ // one this theme points an iframe at.
32
31
  const id = url.hostname.endsWith('youtu.be')
33
32
  ? segments[0]
34
33
  : (url.searchParams.get('v') ?? segments[1])
@@ -52,17 +51,17 @@ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string |
52
51
  return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
53
52
  default:
54
53
  // bluesky, mastodon, other: a post embed is a script tag, which this
55
- // theme does not load. The link card is the rendering.
54
+ // theme does not load. The notice is the rendering.
56
55
  return null
57
56
  }
58
57
  }
59
58
 
60
- function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
59
+ function consentNotice(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
61
60
  return h(
62
61
  'div',
63
- { class: 'cg-embed__placeholder' },
62
+ { class: 'cg-embed__notice' },
64
63
  h('p', { class: 'cg-embed__provider', 'aria-hidden': 'true' }, block.provider),
65
- h('p', { class: 'cg-embed__notice' }, reason),
64
+ h('p', { class: 'cg-embed__reason' }, reason),
66
65
  h(
67
66
  'a',
68
67
  {
@@ -77,39 +76,43 @@ function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): Htm
77
76
 
78
77
  export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
79
78
  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.
79
+ // Video providers are 16:9 unless the editor framed them otherwise; without
80
+ // a ratio the frame would collapse and shift the layout as it loads.
83
81
  const ratio = aspectRatio(block.ratio) ?? '16 / 9'
84
82
 
85
- return h(
83
+ return section(
86
84
  'div',
85
+ 'embed',
86
+ 'cg-embed',
87
87
  {
88
- class: 'cg-embed',
89
- 'data-block': 'embed',
90
88
  'data-provider': block.provider,
91
89
  'data-consent': block.consentRequired ? 'required' : 'not-required',
92
90
  style: `--cg-ratio:${ratio}`,
93
91
  },
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
- }),
92
+ 'div',
93
+ h(
94
+ 'div',
95
+ { class: 'cg-embed__frame-box' },
96
+ source === null
97
+ ? consentNotice(
98
+ block,
99
+ ctx,
100
+ block.consentRequired
101
+ ? ctx.t('embed.consentRequired', { provider: block.provider })
102
+ : ctx.t('embed.unsupported', { provider: block.provider }),
103
+ )
104
+ : h('iframe', {
105
+ class: 'cg-embed__frame',
106
+ src: source,
107
+ // An iframe with no accessible name is announced as "frame" and
108
+ // nothing else (WCAG 4.1.2).
109
+ title: ctx.t('embed.title', { provider: block.provider }),
110
+ loading: 'lazy',
111
+ referrerpolicy: 'strict-origin-when-cross-origin',
112
+ allow:
113
+ 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
114
+ allowfullscreen: true,
115
+ }),
116
+ ),
114
117
  )
115
118
  }
@@ -7,23 +7,20 @@ import {
7
7
  type RenderContext,
8
8
  renderRichText,
9
9
  } from '@cogenta/theme-kit'
10
+ import { section } from '../layout.js'
10
11
 
11
12
  /**
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.
13
+ * Two columns: the title holds the left four and stays in view while the
14
+ * questions, on the right eight, scroll past it. Each question is a row
15
+ * between hairlines with a plus that turns into a minus when open.
19
16
  *
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.
17
+ * `<details>`/`<summary>` rather than a scripted accordion: expanding,
18
+ * keyboard operation and the expanded state announced to assistive
19
+ * technology all come from the browser, at zero bytes of JavaScript. The
20
+ * question is plain text inside `<summary>`, never a heading: a heading
21
+ * nested in a `<summary>` is announced inconsistently across screen readers.
24
22
  */
25
- function renderItem(item: FaqItem, ctx: RenderContext, index: number): HtmlElement {
26
- const number = String(index + 1).padStart(2, '0')
23
+ function renderItem(item: FaqItem, ctx: RenderContext): HtmlElement {
27
24
  return h(
28
25
  'li',
29
26
  { class: 'cg-faq__item' },
@@ -33,8 +30,8 @@ function renderItem(item: FaqItem, ctx: RenderContext, index: number): HtmlEleme
33
30
  h(
34
31
  'summary',
35
32
  { class: 'cg-faq__question' },
36
- h('span', { class: 'cg-faq__index', 'aria-hidden': 'true' }, number),
37
33
  h('span', { class: 'cg-faq__question-text' }, item.question),
34
+ h('span', { class: 'cg-toggle-mark', 'aria-hidden': 'true' }),
38
35
  ),
39
36
  h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
40
37
  ),
@@ -42,20 +39,27 @@ function renderItem(item: FaqItem, ctx: RenderContext, index: number): HtmlEleme
42
39
  }
43
40
 
44
41
  export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
45
- return h(
42
+ return section(
46
43
  'section',
47
- { class: 'cg-faq', 'data-block': 'faq' },
44
+ 'faq',
45
+ 'cg-faq',
46
+ { 'data-titled': block.title === undefined ? 'false' : 'true' },
47
+ 'div',
48
48
  block.title === undefined
49
49
  ? null
50
- : heading(
51
- blockHeadingTag('faq') ?? 'h2',
52
- { class: 'cg-faq__title', 'data-field': 'title' },
53
- block.title,
50
+ : h(
51
+ 'div',
52
+ { class: 'cg-head cg-head--aside cg-head--sticky' },
53
+ heading(
54
+ blockHeadingTag('faq') ?? 'h2',
55
+ { class: 'cg-head__title', 'data-field': 'title' },
56
+ block.title,
57
+ ),
54
58
  ),
55
59
  h(
56
60
  'ul',
57
61
  { class: 'cg-faq__items' },
58
- block.items.map((item, index) => renderItem(item, ctx, index)),
62
+ block.items.map((item) => renderItem(item, ctx)),
59
63
  ),
60
64
  )
61
65
  }
@@ -8,66 +8,73 @@ import {
8
8
  href,
9
9
  nestedHeadingTag,
10
10
  type RenderContext,
11
- renderIcon,
12
11
  } from '@cogenta/theme-kit'
12
+ import { arrow, ordinal, section } from '../layout.js'
13
13
 
14
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).
15
+ * A numbered list of capabilities, ruled by hairlines: `01` to `06`, each
16
+ * row a name in the display serif and a short description beside it.
21
17
  *
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.
18
+ * This is how a consultancy presents its practices, and deliberately not a
19
+ * grid of identical cards with icon tiles. The block's own title holds the
20
+ * left four columns (and stays in view while the list scrolls past it on a
21
+ * wide screen); the list takes the right eight.
25
22
  *
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.
23
+ * `icon` is stored by contract B as a symbol name, and this theme chooses not
24
+ * to draw it: in a numbered, typographic list an icon is decoration, and the
25
+ * ordinal already does the work of marking each row.
26
+ *
27
+ * A row with a link is clickable as a whole, but the link itself stays on the
28
+ * item's title, so its accessible name is the practice's own name
29
+ * (WCAG 2.4.4).
29
30
  */
30
- function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
31
+ function renderItem(
32
+ item: FeatureItem,
33
+ index: number,
34
+ ctx: RenderContext,
35
+ tag: HeadingTag,
36
+ ): HtmlElement {
31
37
  const title =
32
38
  item.link === undefined
33
- ? heading(tag, { class: 'cg-service__title' }, item.title)
39
+ ? heading(tag, { class: 'cg-practice__title' }, item.title)
34
40
  : heading(
35
41
  tag,
36
- { class: 'cg-service__title' },
37
- h('a', { class: 'cg-service__link', href: href(ctx, item.link) }, item.title),
42
+ { class: 'cg-practice__title' },
43
+ h('a', { class: 'cg-practice__link', href: href(ctx, item.link) }, item.title, arrow()),
38
44
  )
39
45
  return h(
40
46
  '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
- ),
47
+ { class: 'cg-practice', 'data-linked': item.link === undefined ? undefined : 'true' },
48
+ h('span', { class: 'cg-practice__index', 'aria-hidden': 'true' }, ordinal(index)),
49
49
  title,
50
- item.text === undefined ? null : h('p', { class: 'cg-service__text' }, item.text),
50
+ item.text === undefined ? null : h('p', { class: 'cg-practice__text' }, item.text),
51
51
  )
52
52
  }
53
53
 
54
54
  export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
55
55
  const hasTitle = block.title !== undefined
56
56
  const itemTag = nestedHeadingTag('featureGrid', hasTitle)
57
- return h(
57
+ return section(
58
58
  'section',
59
- { class: 'cg-services', 'data-block': 'featureGrid' },
59
+ 'featureGrid',
60
+ 'cg-practices',
61
+ { 'data-titled': hasTitle ? 'true' : 'false' },
62
+ 'div',
60
63
  hasTitle
61
- ? heading(
62
- blockHeadingTag('featureGrid') ?? 'h2',
63
- { class: 'cg-services__title', 'data-field': 'title' },
64
- block.title ?? '',
64
+ ? h(
65
+ 'div',
66
+ { class: 'cg-head cg-head--aside' },
67
+ heading(
68
+ blockHeadingTag('featureGrid') ?? 'h2',
69
+ { class: 'cg-head__title', 'data-field': 'title' },
70
+ block.title ?? '',
71
+ ),
65
72
  )
66
73
  : null,
67
74
  h(
68
- 'ul',
69
- { class: 'cg-services__items' },
70
- block.items.map((item) => renderItem(item, ctx, itemTag)),
75
+ 'ol',
76
+ { class: 'cg-practices__items' },
77
+ block.items.map((item, index) => renderItem(item, index, ctx, itemTag)),
71
78
  ),
72
79
  )
73
80
  }
@@ -1,37 +1,42 @@
1
1
  import type { GalleryBlock } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
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.
6
+ * Photographs on the grid, with no frames and no rounded plates.
10
7
  *
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.
8
+ * `grid` crops every picture to the same 4:3 ratio in rows of three (two for
9
+ * a pair), so a set reads as a considered series. `masonry` keeps each
10
+ * picture's own proportions in three flowing columns. `carousel` is a
11
+ * scroll-snapping row: touch, trackpad and arrow keys all work because the
12
+ * list is focusable and labelled, with no JavaScript, no auto-advance
13
+ * (WCAG 2.2.2) and no focus stolen from an off-screen slide.
14
14
  */
15
15
  export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
16
16
  const items = h(
17
17
  'ul',
18
- { class: 'cg-gallery__items' },
18
+ {
19
+ class: 'cg-gallery__items',
20
+ 'data-count': String(Math.min(block.items.length, 6)),
21
+ },
19
22
  block.items.map((item) =>
20
23
  h(
21
24
  'li',
22
25
  { class: 'cg-gallery__item' },
23
- image(ctx, item.media, { sizes: '(min-width: 48rem) 22rem, 60vw' }),
26
+ image(ctx, item.media, {
27
+ className: 'cg-gallery__image',
28
+ sizes: '(min-width: 64rem) 30vw, (min-width: 40rem) 45vw, 85vw',
29
+ }),
24
30
  ),
25
31
  ),
26
32
  )
27
33
 
28
- return h(
34
+ return section(
29
35
  'section',
30
- {
31
- class: 'cg-gallery',
32
- 'data-block': 'gallery',
33
- 'data-layout': block.layout,
34
- },
36
+ 'gallery',
37
+ 'cg-gallery',
38
+ { 'data-layout': block.layout },
39
+ 'div',
35
40
  block.layout === 'carousel'
36
41
  ? h(
37
42
  'div',
@@ -8,52 +8,64 @@ import {
8
8
  image,
9
9
  type RenderContext,
10
10
  } from '@cogenta/theme-kit'
11
+ import { section } from '../layout.js'
11
12
 
12
13
  /**
13
- * The hero carries the page's `h1` (contract B: `headingLevel: 'h1'`), so
14
- * `renderPage` relies on it to avoid emitting a second one.
14
+ * A typographic hero on the twelve-column grid.
15
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.
16
+ * The title leads, set large in the display serif across ten columns. Below
17
+ * it the page splits: the subtitle and the actions hold a narrow column on
18
+ * the left, and the photograph takes the right-hand seven columns, cropped
19
+ * frankly, with no frame, no shadow and no shape behind it. The eyebrow is
20
+ * a line of small capitals after a short rule, never a pill.
21
+ *
22
+ * With no media the subtitle moves to the right half of the grid instead, so
23
+ * a hero without a picture is still an asymmetric composition rather than a
24
+ * column of centred text.
25
+ *
26
+ * The hero carries the page's `h1` (contract B: `headingLevel: 'h1'`), which
27
+ * is why `renderPage` never adds a second one.
22
28
  */
23
29
  export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
24
30
  const tag = blockHeadingTag('hero') ?? 'h1'
25
- return h(
31
+ const actions = actionList(ctx, block.actions, ctx.t('hero.actions'))
32
+ const hasAside = block.subtitle !== undefined || actions !== null
33
+ return section(
26
34
  'section',
27
- { class: 'cg-hero', 'data-block': 'hero' },
35
+ 'hero',
36
+ 'cg-hero',
37
+ { 'data-media': block.media === undefined ? 'none' : 'present' },
38
+ 'div',
28
39
  h(
29
40
  'div',
30
41
  { 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
- ),
42
+ block.eyebrow === undefined
43
+ ? null
44
+ : h('p', { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
45
+ heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
44
46
  ),
47
+ hasAside
48
+ ? h(
49
+ 'div',
50
+ { class: 'cg-hero__aside' },
51
+ block.subtitle === undefined
52
+ ? null
53
+ : h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
54
+ actions,
55
+ )
56
+ : null,
45
57
  block.media === undefined
46
58
  ? null
47
59
  : h(
48
60
  'div',
49
- { class: 'cg-hero__frame' },
61
+ { class: 'cg-hero__media' },
50
62
  // 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.
63
+ // that must not be lazy: a lazy-loaded LCP element is a measured
64
+ // Lighthouse regression.
53
65
  image(ctx, block.media, {
54
- className: 'cg-hero__media',
66
+ className: 'cg-hero__image',
55
67
  loading: 'eager',
56
- sizes: '(min-width: 64rem) 46vw, 100vw',
68
+ sizes: '(min-width: 64rem) 56vw, 100vw',
57
69
  }),
58
70
  ),
59
71
  )
@@ -1,36 +1,46 @@
1
1
  import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
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.
6
+ * `blocks@2.0` (RFC 0001). The lighter "selected clients" line under a hero:
7
+ * the caption as a small-capital label in the left columns, then the marks
8
+ * in one even row across the rest, in greyscale, between two hairlines.
9
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.
10
+ * The `<figcaption>` comes first in the figure so the label reads before the
11
+ * marks it introduces. No per-logo name or link: each image's own alt text
12
+ * (set once, in the media library) names it.
14
13
  */
15
14
  function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
16
15
  return h(
17
16
  'li',
18
- { class: 'cg-logo-band__item' },
19
- image(ctx, item.media, { className: 'cg-logo-band__image', variant: { fit: 'contain' } }),
17
+ { class: 'cg-logo-strip__item' },
18
+ image(ctx, item.media, {
19
+ className: 'cg-logo-strip__logo',
20
+ variant: { fit: 'contain' },
21
+ sizes: '12rem',
22
+ }),
20
23
  )
21
24
  }
22
25
 
23
26
  export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
24
- return h(
27
+ return section(
28
+ 'div',
29
+ 'logoStrip',
30
+ 'cg-logo-strip',
31
+ { 'data-count': String(Math.min(block.logos.length, 8)) },
25
32
  'figure',
26
- { class: 'cg-logo-band', 'data-block': 'logoStrip' },
33
+ block.caption === undefined
34
+ ? null
35
+ : h(
36
+ 'figcaption',
37
+ { class: 'cg-logo-strip__caption', 'data-field': 'caption' },
38
+ block.caption,
39
+ ),
27
40
  h(
28
41
  'ul',
29
- { class: 'cg-logo-band__items' },
42
+ { class: 'cg-logo-strip__items' },
30
43
  block.logos.map((item) => renderItem(item, ctx)),
31
44
  ),
32
- block.caption === undefined
33
- ? null
34
- : h('figcaption', { class: 'cg-logo-band__caption', 'data-field': 'caption' }, block.caption),
35
45
  )
36
46
  }
@@ -7,25 +7,24 @@ import {
7
7
  image,
8
8
  type RenderContext,
9
9
  } from '@cogenta/theme-kit'
10
+ import { section } from '../layout.js'
10
11
 
11
12
  /**
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.
13
+ * A client register: wordmarks in greyscale, each in its own cell of a ruled
14
+ * table, so a dozen marks of different shapes and weights still line up.
17
15
  *
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()`.
16
+ * Contract B says the organisation's name is the accessible name of the
17
+ * link, so the image carries it as `alt` when the media entity has none, and
18
+ * no visually hidden duplicate is added. Greyscale is a `filter` in the
19
+ * stylesheet, never baked into the media: the same asset still renders in
20
+ * colour wherever a site owner uses it elsewhere.
23
21
  */
24
22
  function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
25
23
  const logo = image(ctx, item.media, {
26
24
  className: 'cg-clients__logo',
27
25
  altFrom: item.name,
28
26
  variant: { fit: 'contain' },
27
+ sizes: '12rem',
29
28
  })
30
29
  return h(
31
30
  'li',
@@ -41,20 +40,37 @@ function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
41
40
  }
42
41
 
43
42
  export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
44
- return h(
43
+ return section(
45
44
  'section',
46
- { class: 'cg-clients', 'data-block': 'logos' },
45
+ 'logos',
46
+ 'cg-clients',
47
+ {},
48
+ 'div',
47
49
  block.title === undefined
48
50
  ? null
49
- : heading(
50
- blockHeadingTag('logos') ?? 'h2',
51
- { class: 'cg-clients__title', 'data-field': 'title' },
52
- block.title,
51
+ : h(
52
+ 'div',
53
+ { class: 'cg-head' },
54
+ heading(
55
+ blockHeadingTag('logos') ?? 'h2',
56
+ { class: 'cg-head__title', 'data-field': 'title' },
57
+ block.title,
58
+ ),
53
59
  ),
54
60
  h(
55
61
  'ul',
56
- { class: 'cg-clients__items' },
62
+ { class: 'cg-clients__items', 'data-columns': String(columnsFor(block.items.length)) },
57
63
  block.items.map((item) => renderItem(item, ctx)),
58
64
  ),
59
65
  )
60
66
  }
67
+
68
+ /**
69
+ * How many columns the register uses on a wide screen: four when the marks
70
+ * fill whole rows of four, otherwise three, so a register never ends on a
71
+ * row of empty ruled cells when a whole-row layout exists.
72
+ */
73
+ function columnsFor(count: number): number {
74
+ if (count <= 3) return Math.max(count, 1)
75
+ return count % 4 === 0 ? 4 : 3
76
+ }