@cogenta/theme-association 0.3.3 → 0.5.4

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 (148) hide show
  1. package/dist/src/index.d.ts +6 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +9 -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 +13 -10
  7. package/dist/src/render/blocks/accordion.js.map +1 -1
  8. package/dist/src/render/blocks/breakdown.d.ts +14 -0
  9. package/dist/src/render/blocks/breakdown.d.ts.map +1 -0
  10. package/dist/src/render/blocks/breakdown.js +17 -0
  11. package/dist/src/render/blocks/breakdown.js.map +1 -0
  12. package/dist/src/render/blocks/collection-list.d.ts +4 -10
  13. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  14. package/dist/src/render/blocks/collection-list.js +89 -58
  15. package/dist/src/render/blocks/collection-list.js.map +1 -1
  16. package/dist/src/render/blocks/cta.d.ts +0 -5
  17. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  18. package/dist/src/render/blocks/cta.js +22 -10
  19. package/dist/src/render/blocks/cta.js.map +1 -1
  20. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  21. package/dist/src/render/blocks/embed.js +30 -22
  22. package/dist/src/render/blocks/embed.js.map +1 -1
  23. package/dist/src/render/blocks/faq.d.ts.map +1 -1
  24. package/dist/src/render/blocks/faq.js +11 -15
  25. package/dist/src/render/blocks/faq.js.map +1 -1
  26. package/dist/src/render/blocks/feature-grid.d.ts +6 -0
  27. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  28. package/dist/src/render/blocks/feature-grid.js +42 -20
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  30. package/dist/src/render/blocks/gallery.d.ts +9 -7
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  32. package/dist/src/render/blocks/gallery.js +22 -16
  33. package/dist/src/render/blocks/gallery.js.map +1 -1
  34. package/dist/src/render/blocks/hero.d.ts +23 -9
  35. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  36. package/dist/src/render/blocks/hero.js +40 -23
  37. package/dist/src/render/blocks/hero.js.map +1 -1
  38. package/dist/src/render/blocks/logo-strip.d.ts +6 -0
  39. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
  40. package/dist/src/render/blocks/logo-strip.js +7 -9
  41. package/dist/src/render/blocks/logo-strip.js.map +1 -1
  42. package/dist/src/render/blocks/logos.d.ts +14 -0
  43. package/dist/src/render/blocks/logos.d.ts.map +1 -1
  44. package/dist/src/render/blocks/logos.js +30 -16
  45. package/dist/src/render/blocks/logos.js.map +1 -1
  46. package/dist/src/render/blocks/media-figure.d.ts +12 -5
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  48. package/dist/src/render/blocks/media-figure.js +32 -18
  49. package/dist/src/render/blocks/media-figure.js.map +1 -1
  50. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  51. package/dist/src/render/blocks/pricing-table.js +18 -20
  52. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  53. package/dist/src/render/blocks/prose.d.ts +9 -3
  54. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  55. package/dist/src/render/blocks/prose.js +11 -4
  56. package/dist/src/render/blocks/prose.js.map +1 -1
  57. package/dist/src/render/blocks/quote.d.ts +4 -7
  58. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  59. package/dist/src/render/blocks/quote.js +12 -17
  60. package/dist/src/render/blocks/quote.js.map +1 -1
  61. package/dist/src/render/blocks/stat-counter.d.ts +8 -1
  62. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
  63. package/dist/src/render/blocks/stat-counter.js +18 -10
  64. package/dist/src/render/blocks/stat-counter.js.map +1 -1
  65. package/dist/src/render/blocks/stats.d.ts +12 -1
  66. package/dist/src/render/blocks/stats.d.ts.map +1 -1
  67. package/dist/src/render/blocks/stats.js +24 -14
  68. package/dist/src/render/blocks/stats.js.map +1 -1
  69. package/dist/src/render/blocks/testimonial.d.ts +6 -5
  70. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  71. package/dist/src/render/blocks/testimonial.js +13 -11
  72. package/dist/src/render/blocks/testimonial.js.map +1 -1
  73. package/dist/src/render/chrome.d.ts +38 -1
  74. package/dist/src/render/chrome.d.ts.map +1 -1
  75. package/dist/src/render/chrome.js +117 -91
  76. package/dist/src/render/chrome.js.map +1 -1
  77. package/dist/src/render/details.d.ts +79 -0
  78. package/dist/src/render/details.d.ts.map +1 -0
  79. package/dist/src/render/details.js +221 -0
  80. package/dist/src/render/details.js.map +1 -0
  81. package/dist/src/render/figures.d.ts +29 -0
  82. package/dist/src/render/figures.d.ts.map +1 -0
  83. package/dist/src/render/figures.js +59 -0
  84. package/dist/src/render/figures.js.map +1 -0
  85. package/dist/src/render/layout.d.ts +31 -0
  86. package/dist/src/render/layout.d.ts.map +1 -0
  87. package/dist/src/render/layout.js +46 -0
  88. package/dist/src/render/layout.js.map +1 -0
  89. package/dist/src/render/page-head.d.ts +7 -0
  90. package/dist/src/render/page-head.d.ts.map +1 -0
  91. package/dist/src/render/page-head.js +95 -0
  92. package/dist/src/render/page-head.js.map +1 -0
  93. package/dist/src/render/render-block.d.ts +8 -12
  94. package/dist/src/render/render-block.d.ts.map +1 -1
  95. package/dist/src/render/render-block.js +40 -29
  96. package/dist/src/render/render-block.js.map +1 -1
  97. package/dist/src/render/strings.d.ts +15 -0
  98. package/dist/src/render/strings.d.ts.map +1 -0
  99. package/dist/src/render/strings.js +39 -0
  100. package/dist/src/render/strings.js.map +1 -0
  101. package/dist/src/render/term-archive.d.ts.map +1 -1
  102. package/dist/src/render/term-archive.js +33 -25
  103. package/dist/src/render/term-archive.js.map +1 -1
  104. package/dist/src/render/widget-areas.d.ts +14 -0
  105. package/dist/src/render/widget-areas.d.ts.map +1 -0
  106. package/dist/src/render/widget-areas.js +21 -0
  107. package/dist/src/render/widget-areas.js.map +1 -0
  108. package/dist/theme.config.d.ts +1 -1
  109. package/dist/theme.config.js +3 -3
  110. package/dist/theme.config.js.map +1 -1
  111. package/package.json +5 -5
  112. package/src/index.ts +9 -0
  113. package/src/render/blocks/accordion.ts +28 -22
  114. package/src/render/blocks/breakdown.ts +42 -0
  115. package/src/render/blocks/collection-list.ts +234 -103
  116. package/src/render/blocks/cta.ts +48 -19
  117. package/src/render/blocks/embed.ts +47 -29
  118. package/src/render/blocks/faq.ts +22 -31
  119. package/src/render/blocks/feature-grid.ts +73 -41
  120. package/src/render/blocks/gallery.ts +32 -26
  121. package/src/render/blocks/hero.ts +56 -43
  122. package/src/render/blocks/logo-strip.ts +23 -21
  123. package/src/render/blocks/logos.ts +45 -40
  124. package/src/render/blocks/media-figure.ts +49 -24
  125. package/src/render/blocks/pricing-table.ts +31 -40
  126. package/src/render/blocks/prose.ts +16 -6
  127. package/src/render/blocks/quote.ts +34 -27
  128. package/src/render/blocks/stat-counter.ts +35 -29
  129. package/src/render/blocks/stats.ts +46 -38
  130. package/src/render/blocks/testimonial.ts +29 -20
  131. package/src/render/chrome.ts +165 -91
  132. package/src/render/details.ts +276 -0
  133. package/src/render/figures.ts +68 -0
  134. package/src/render/layout.ts +78 -0
  135. package/src/render/page-head.ts +193 -0
  136. package/src/render/render-block.ts +45 -32
  137. package/src/render/strings.ts +74 -0
  138. package/src/render/term-archive.ts +94 -58
  139. package/src/render/widget-areas.ts +24 -0
  140. package/src/styles/archive.css +38 -102
  141. package/src/styles/base.css +503 -554
  142. package/src/styles/blocks.css +913 -666
  143. package/src/styles/chrome.css +506 -0
  144. package/src/styles/listings.css +591 -0
  145. package/src/styles/theme.css +25 -19
  146. package/src/styles/tokens.css +284 -234
  147. package/src/styles/widgets.css +1059 -0
  148. package/tokens.json +18 -18
@@ -7,31 +7,60 @@ import {
7
7
  heading,
8
8
  type RenderContext,
9
9
  } from '@cogenta/theme-kit'
10
+ import { giftsOf } from '../figures.js'
11
+ import { optionalText, section } from '../layout.js'
10
12
 
11
13
  /**
12
- * The "Every gift counts" donate band — a full-width, tinted panel (the
13
- * skin's own accent, softened) rather than a plain section, so the one block
14
- * whose entire job is a call to act reads as the loudest thing on the page.
14
+ * The ask. On a charity's site the band that asks is the donation ask, so
15
+ * this theme gives it the signal yellow, across the whole width of the
16
+ * window: the title large on the first six columns, and on the other six the
17
+ * sentence and the actions, the first one a filled ink button.
18
+ *
19
+ * When the text says what a gift pays for, sentence by sentence, each
20
+ * sentence starting with an amount, the amounts are set large beside what
21
+ * they buy (`giftsOf`); any other text stays one paragraph.
22
+ *
23
+ * An invitation that is not a donation ask (a volunteer evening, a
24
+ * newsletter) asks for the quieter band with the `background` variant, which
25
+ * lays the same layout on the paper's darker stock.
15
26
  */
16
- export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
27
+ function body(block: CtaBlock): HtmlElement | null {
28
+ const gifts = giftsOf(block.text)
29
+ if (gifts === undefined) {
30
+ return optionalText('p', 'ca-cta__text', block.text, { 'data-field': 'text' })
31
+ }
17
32
  return h(
18
- 'section',
19
- { class: 'cg-block cg-cta', 'data-block': 'cta' },
33
+ 'div',
34
+ { class: 'ca-cta__gifts' },
35
+ gifts.lead === undefined ? null : h('p', { class: 'ca-cta__text' }, gifts.lead),
20
36
  h(
21
- 'div',
22
- { class: 'cg-cta__inner' },
23
- heading(
24
- blockHeadingTag('cta') ?? 'h2',
25
- { class: 'cg-cta__title', 'data-field': 'title' },
26
- block.title,
37
+ 'dl',
38
+ { class: 'ca-cta__ladder' },
39
+ gifts.gifts.map((gift) =>
40
+ h(
41
+ 'div',
42
+ { class: 'ca-cta__gift' },
43
+ h('dt', { class: 'ca-cta__amount' }, gift.amount),
44
+ h('dd', { class: 'ca-cta__buys' }, gift.text),
45
+ ),
27
46
  ),
28
- block.text === undefined
29
- ? null
30
- : h('p', { class: 'cg-cta__text', 'data-field': 'text' }, block.text),
31
- // `actions` is required and non-empty for a `cta`, so the list is
32
- // always rendered; the null branch stays reachable only through
33
- // invalid data.
34
- actionList(ctx, block.actions, block.title),
35
47
  ),
48
+ gifts.tail === undefined ? null : h('p', { class: 'ca-cta__text' }, gifts.tail),
49
+ )
50
+ }
51
+
52
+ export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
53
+ return section(
54
+ 'section',
55
+ 'cta',
56
+ 'ca-cta',
57
+ { 'data-band': 'signal' },
58
+ 'div',
59
+ heading(
60
+ blockHeadingTag('cta') ?? 'h2',
61
+ { class: 'ca-cta__title', 'data-field': 'title' },
62
+ block.title,
63
+ ),
64
+ h('div', { class: 'ca-cta__body' }, body(block), actionList(ctx, block.actions, block.title)),
36
65
  )
37
66
  }
@@ -1,17 +1,20 @@
1
1
  import type { EmbedBlock } from '@cogenta/blocks'
2
2
  import { aspectRatio, type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
3
+ import { arrowWords, section } from '../layout.js'
3
4
 
4
5
  /**
5
- * Nothing here contacts a third party before the visitor has consented.
6
+ * A film about the food bank, a recording of the annual meeting, a post from
7
+ * a partner: eight columns of the reading width.
6
8
  *
7
- * When `consentRequired` is true the block renders a self-contained card and
8
- * an outbound link — no `<iframe>`, no `<script>`, no preconnect. The theme
9
- * ships no consent *button*, because it ships no JavaScript at all: granting
10
- * consent is a site-wide decision that belongs to the consent layer, not to
11
- * a block.
9
+ * Nothing here contacts a third party before the visitor consents: when
10
+ * `consentRequired` is set, or the provider has no cookie-free frame (a map,
11
+ * a post), a notice takes the frame's place. The notice is a short card
12
+ * inside a hairline, not an empty frame kept at the video's ratio: without a
13
+ * script there is nothing to load into that frame later, and a large blank
14
+ * rectangle on a page is a hole. A label, one sentence, and one arrow link to
15
+ * the original. No preconnect, no poster fetched from the provider, no
16
+ * consent button (this theme ships no script).
12
17
  */
13
-
14
- /** `null` means "this provider has no embeddable frame we trust". */
15
18
  function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
16
19
  const url = URL.parse(rawUrl)
17
20
  if (url === null) return null
@@ -41,58 +44,73 @@ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string |
41
44
  case 'soundcloud':
42
45
  return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
43
46
  default:
44
- // bluesky, mastodon, other: a post embed is a script tag, which this
45
- // theme does not load. The link card is the rendering.
47
+ // bluesky, mastodon, other: a post embed is a script tag and a map is
48
+ // a third-party page, neither of which this theme loads. The notice is
49
+ // the rendering.
46
50
  return null
47
51
  }
48
52
  }
49
53
 
50
- function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
54
+ function notice(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
51
55
  return h(
52
56
  'div',
53
- { class: 'cg-embed__placeholder' },
54
- h('p', { class: 'cg-embed__notice' }, reason),
57
+ { class: 'ca-embed__notice' },
58
+ h('p', { class: 'ca-embed__label' }, ctx.t('embed.label')),
59
+ h('p', { class: 'ca-embed__reason' }, reason),
55
60
  h(
56
61
  'a',
57
62
  {
58
- class: 'cg-embed__link',
63
+ class: 'ca-arrow-link ca-embed__link',
59
64
  href: ctx.link(block.url),
60
65
  rel: 'noopener noreferrer nofollow',
61
66
  },
62
- ctx.t('embed.open', { provider: block.provider }),
67
+ arrowWords(
68
+ block.provider === 'other'
69
+ ? ctx.t('embed.openOther')
70
+ : ctx.t('embed.open', { provider: block.provider }),
71
+ ),
63
72
  ),
64
73
  )
65
74
  }
66
75
 
67
76
  export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
68
77
  const source = block.consentRequired ? null : frameSource(block.provider, block.url)
78
+ // Without a ratio the frame would collapse and shift the layout as it loads.
69
79
  const ratio = aspectRatio(block.ratio) ?? '16 / 9'
70
80
 
71
- return h(
81
+ return section(
72
82
  'div',
83
+ 'embed',
84
+ 'ca-embed',
73
85
  {
74
- class: 'cg-block cg-embed',
75
- 'data-block': 'embed',
76
86
  'data-provider': block.provider,
77
87
  'data-consent': block.consentRequired ? 'required' : 'not-required',
78
- style: `--cg-ratio:${ratio}`,
88
+ 'data-frame': source === null ? 'notice' : 'player',
79
89
  },
90
+ 'div',
80
91
  source === null
81
- ? consentCard(
92
+ ? notice(
82
93
  block,
83
94
  ctx,
84
95
  block.consentRequired
85
96
  ? ctx.t('embed.consentRequired', { provider: block.provider })
86
97
  : ctx.t('embed.unsupported', { provider: block.provider }),
87
98
  )
88
- : h('iframe', {
89
- class: 'cg-embed__frame',
90
- src: source,
91
- title: ctx.t('embed.title', { provider: block.provider }),
92
- loading: 'lazy',
93
- referrerpolicy: 'strict-origin-when-cross-origin',
94
- allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
95
- allowfullscreen: true,
96
- }),
99
+ : h(
100
+ 'div',
101
+ { class: 'ca-embed__frame', style: `aspect-ratio:${ratio}` },
102
+ h('iframe', {
103
+ class: 'ca-embed__player',
104
+ src: source,
105
+ // A frame with no accessible name is announced as "frame" and
106
+ // nothing else (WCAG 4.1.2).
107
+ title: ctx.t('embed.title', { provider: block.provider }),
108
+ loading: 'lazy',
109
+ referrerpolicy: 'strict-origin-when-cross-origin',
110
+ allow:
111
+ 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
112
+ allowfullscreen: true,
113
+ }),
114
+ ),
97
115
  )
98
116
  }
@@ -1,52 +1,43 @@
1
1
  import type { FaqBlock, FaqItem } from '@cogenta/blocks'
2
2
  import {
3
- blockHeadingTag,
4
3
  type HtmlElement,
5
4
  h,
6
5
  heading,
6
+ nestedHeadingTag,
7
7
  type RenderContext,
8
8
  renderRichText,
9
9
  } from '@cogenta/theme-kit'
10
+ import { section, sectionHead } from '../layout.js'
10
11
 
11
12
  /**
12
- * `<details>`/`<summary>` rather than a scripted accordion: expanding,
13
- * keyboard operation, the expanded state announced to assistive technology
14
- * and in-page search all come from the browser, at zero bytes of
15
- * JavaScript — the same mechanism this theme's own mobile nav toggle uses.
16
- *
17
- * The question is plain text inside `<summary>`, not a heading: a heading
18
- * nested in a summary is rendered inconsistently across screen readers, and
19
- * the questions are already reachable as a list under the block's own
20
- * heading.
13
+ * The questions people ask before they give an hour or a pound, answered in
14
+ * the open: every answer is printed under its question, two columns of them
15
+ * on a wide screen, each pair under a hairline. A charity's answers are short
16
+ * and they are the point; hiding them behind a click makes a visitor work for
17
+ * reassurance. (The `accordion` block is the collapsible one.)
21
18
  */
22
- function renderItem(item: FaqItem, ctx: RenderContext): HtmlElement {
19
+ function pair(item: FaqItem, ctx: RenderContext, titled: boolean): HtmlElement {
23
20
  return h(
24
- 'li',
25
- { class: 'cg-faq__item' },
26
- h(
27
- 'details',
28
- { class: 'cg-faq__details' },
29
- h('summary', { class: 'cg-faq__question' }, item.question),
30
- h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
31
- ),
21
+ 'div',
22
+ { class: 'ca-faq__item' },
23
+ heading(nestedHeadingTag('faq', titled), { class: 'ca-faq__question' }, item.question),
24
+ h('div', { class: 'ca-faq__answer' }, renderRichText(ctx, item.answer)),
32
25
  )
33
26
  }
34
27
 
35
28
  export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
36
- return h(
29
+ const titled = block.title !== undefined
30
+ return section(
37
31
  'section',
38
- { class: 'cg-block cg-faq', 'data-block': 'faq' },
39
- block.title === undefined
40
- ? null
41
- : heading(
42
- blockHeadingTag('faq') ?? 'h2',
43
- { class: 'cg-faq__title', 'data-field': 'title' },
44
- block.title,
45
- ),
32
+ 'faq',
33
+ 'ca-faq',
34
+ { 'data-titled': String(titled) },
35
+ 'div',
36
+ sectionHead('faq', block.title),
46
37
  h(
47
- 'ul',
48
- { class: 'cg-faq__items' },
49
- block.items.map((item) => renderItem(item, ctx)),
38
+ 'div',
39
+ { class: 'ca-faq__items' },
40
+ block.items.map((item) => pair(item, ctx, titled)),
50
41
  ),
51
42
  )
52
43
  }
@@ -1,7 +1,5 @@
1
1
  import type { FeatureGridBlock, FeatureItem } from '@cogenta/blocks'
2
2
  import {
3
- blockHeadingTag,
4
- type HeadingTag,
5
3
  type HtmlElement,
6
4
  h,
7
5
  heading,
@@ -10,58 +8,92 @@ import {
10
8
  type RenderContext,
11
9
  renderIcon,
12
10
  } from '@cogenta/theme-kit'
11
+ import { arrowWords, section, sectionHead } from '../layout.js'
13
12
 
14
13
  /**
15
- * "What we do" — three (or more) programme cards, each a rounded, softly
16
- * shadowed tile with its icon in a round chip. The item's title carries the
17
- * link, so the link's accessible name is the programme's own name (WCAG
18
- * 2.4.4) — the block has no separate "learn more" field to write.
14
+ * Two readings of the same block, chosen by its content rather than by a
15
+ * setting:
19
16
  *
20
- * `icon` names a symbol, never markup: it is exposed as a data attribute for
21
- * the skin and, when the name is one `renderIcon` recognises, a real inline
22
- * glyph — `aria-hidden`, since the title already names the programme.
17
+ * - **Ways in**, when an item names an icon this theme can draw: ruled
18
+ * columns, each with a small line icon in the organisation's green, the
19
+ * name, its sentence, and the name as an arrow link when it leads somewhere.
20
+ * Never a tinted tile behind an icon, never a card.
21
+ * - **Steps**, when no item names an icon: an ordered list, each step under a
22
+ * rule with its number set large in the display face, because a list of
23
+ * things without pictures on a charity's site is almost always "how it
24
+ * works": how to sign up, what happens after you give, the first evening.
23
25
  */
24
- function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
25
- const title =
26
- item.link === undefined
27
- ? heading(tag, { class: 'cg-feature__title' }, item.title)
28
- : heading(
29
- tag,
30
- { class: 'cg-feature__title' },
31
- h('a', { class: 'cg-feature__link', href: href(ctx, item.link) }, item.title),
32
- )
26
+ function hasIcons(block: FeatureGridBlock): boolean {
27
+ return block.items.some((item) => item.icon !== undefined)
28
+ }
29
+
30
+ /**
31
+ * How many columns a wide screen gets: as many as there are items up to
32
+ * four, then three when the count divides by three, so six items make two
33
+ * full rows of three rather than a row of four and a row of two.
34
+ */
35
+ export function columnsFor(count: number): number {
36
+ if (count <= 4) return count
37
+ return count % 3 === 0 ? 3 : 4
38
+ }
39
+
40
+ function name(item: FeatureItem, ctx: RenderContext): HtmlElement | string {
41
+ return item.link === undefined
42
+ ? item.title
43
+ : h('a', { class: 'ca-arrow-link', href: href(ctx, item.link) }, arrowWords(item.title))
44
+ }
45
+
46
+ function column(item: FeatureItem, ctx: RenderContext, titled: boolean): HtmlElement {
47
+ const icon =
48
+ item.icon === undefined ? null : renderIcon(item.icon, { className: 'ca-ways__icon', size: 28 })
33
49
  return h(
34
50
  'li',
35
- { class: 'cg-feature' },
36
- item.icon === undefined
37
- ? null
38
- : h(
39
- 'span',
40
- { class: 'cg-feature__icon', 'data-icon': item.icon, 'aria-hidden': 'true' },
41
- renderIcon(item.icon),
42
- ),
43
- title,
44
- item.text === undefined ? null : h('p', { class: 'cg-feature__text' }, item.text),
51
+ { class: 'ca-ways__item' },
52
+ icon,
53
+ heading(nestedHeadingTag('featureGrid', titled), { class: 'ca-ways__name' }, name(item, ctx)),
54
+ item.text === undefined ? null : h('p', { class: 'ca-ways__text' }, item.text),
45
55
  )
46
56
  }
47
57
 
48
- export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
49
- const hasTitle = block.title !== undefined
50
- const itemTag = nestedHeadingTag('featureGrid', hasTitle)
58
+ function step(item: FeatureItem, ctx: RenderContext, titled: boolean, index: number): HtmlElement {
51
59
  return h(
60
+ 'li',
61
+ { class: 'ca-steps__item' },
62
+ h('span', { class: 'ca-steps__number', 'aria-hidden': 'true' }, String(index + 1)),
63
+ heading(nestedHeadingTag('featureGrid', titled), { class: 'ca-steps__name' }, name(item, ctx)),
64
+ item.text === undefined ? null : h('p', { class: 'ca-steps__text' }, item.text),
65
+ )
66
+ }
67
+
68
+ export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
69
+ const titled = block.title !== undefined
70
+ const count = String(columnsFor(block.items.length))
71
+ if (!hasIcons(block)) {
72
+ return section(
73
+ 'section',
74
+ 'featureGrid',
75
+ 'ca-steps',
76
+ { 'data-titled': String(titled), 'data-count': count },
77
+ 'div',
78
+ sectionHead('featureGrid', block.title),
79
+ h(
80
+ 'ol',
81
+ { class: 'ca-steps__items' },
82
+ block.items.map((item, index) => step(item, ctx, titled, index)),
83
+ ),
84
+ )
85
+ }
86
+ return section(
52
87
  'section',
53
- { class: 'cg-block cg-features', 'data-block': 'featureGrid' },
54
- hasTitle
55
- ? heading(
56
- blockHeadingTag('featureGrid') ?? 'h2',
57
- { class: 'cg-features__title', 'data-field': 'title' },
58
- block.title ?? '',
59
- )
60
- : null,
88
+ 'featureGrid',
89
+ 'ca-ways',
90
+ { 'data-titled': String(titled), 'data-count': count },
91
+ 'div',
92
+ sectionHead('featureGrid', block.title),
61
93
  h(
62
94
  'ul',
63
- { class: 'cg-features__items' },
64
- block.items.map((item) => renderItem(item, ctx, itemTag)),
95
+ { class: 'ca-ways__items' },
96
+ block.items.map((item) => column(item, ctx, titled)),
65
97
  ),
66
98
  )
67
99
  }
@@ -1,47 +1,53 @@
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
- * The carousel ships **no JavaScript**. It is a scroll-snapping list, which
6
- * gives touch swiping, trackpad scrolling and — because the list is focusable
7
- * and labelled — arrow-key scrolling, with none of the failure modes of a
8
- * scripted carousel (WCAG 2.2.2).
6
+ * Photographs of a year at the hall, in the three layouts contract B names:
9
7
  *
10
- * `grid` (the layout the association home page's photo gallery uses) shows
11
- * six rounded, evenly sized tiles — a warm contact sheet rather than a rigid
12
- * masonry wall.
8
+ * - `grid`: one picture takes the row at 16:9; two share it at 3:2; three
9
+ * make one large picture on the first eight columns with the other two
10
+ * stacked beside it; four make two rows of two at 3:2; five and more make
11
+ * rows of three at 4:3. No row is ever left with a gap in it.
12
+ * - `masonry`: three columns of pictures at their own proportions.
13
+ * - `carousel`: a row of 4:3 crops that scrolls sideways, with native scroll
14
+ * snapping. It is a focusable, labelled region so the row can be scrolled
15
+ * from the keyboard; no script drives it.
13
16
  */
14
17
  export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
15
- const items = h(
16
- 'ul',
17
- { class: 'cg-gallery__items' },
18
- block.items.map((item) =>
19
- h(
20
- 'li',
21
- { class: 'cg-gallery__item' },
22
- image(ctx, item.media, { sizes: '(min-width: 45rem) 20rem, 60vw' }),
23
- ),
18
+ const count = block.items.length
19
+ const items = block.items.map((item, index) =>
20
+ h(
21
+ 'li',
22
+ { class: 'ca-gallery__item' },
23
+ image(ctx, item.media, {
24
+ className: 'ca-gallery__image',
25
+ sizes:
26
+ block.layout === 'grid' && index === 0
27
+ ? '(min-width: 64rem) 52vw, 100vw'
28
+ : '(min-width: 64rem) 30vw, 80vw',
29
+ }),
24
30
  ),
25
31
  )
32
+ const list = h('ul', { class: 'ca-gallery__items' }, items)
26
33
 
27
- return h(
28
- 'section',
29
- {
30
- class: 'cg-block cg-gallery',
31
- 'data-block': 'gallery',
32
- 'data-layout': block.layout,
33
- },
34
+ return section(
35
+ 'div',
36
+ 'gallery',
37
+ 'ca-gallery',
38
+ { 'data-layout': block.layout, 'data-count': count >= 5 ? 'many' : String(count) },
39
+ 'div',
34
40
  block.layout === 'carousel'
35
41
  ? h(
36
42
  'div',
37
43
  {
38
- class: 'cg-gallery__viewport',
44
+ class: 'ca-gallery__viewport',
39
45
  role: 'region',
40
46
  'aria-label': ctx.t('gallery.carousel'),
41
47
  tabindex: '0',
42
48
  },
43
- items,
49
+ list,
44
50
  )
45
- : items,
51
+ : list,
46
52
  )
47
53
  }
@@ -1,60 +1,73 @@
1
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'
2
+ import { actionList, type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { optionalText } from '../layout.js'
11
4
 
12
5
  /**
13
- * The hero carries the page's `h1` (contract B: `headingLevel: 'h1'`), so
14
- * `renderPage` relies on it to avoid emitting a second one.
6
+ * The opening of a page: a photograph of the people the organisation works
7
+ * with, across the whole width of the window, and the statement of the cause
8
+ * set large on a sheet of the page's own paper that starts at the window's
9
+ * left edge and rises into the bottom of the photograph.
15
10
  *
16
- * The warm, community read this theme opens with: a rounded "eyebrow" pill
17
- * (the cause, not a generic label), a big friendly title in the display
18
- * face, and — when there is one — the media sitting in its own big rounded
19
- * card with a soft halo glow behind it (`cg-hero__halo`, purely decorative,
20
- * `aria-hidden`), rather than a hard-edged frame. Centred copy on mobile,
21
- * two columns from `md` up.
11
+ * On a wide screen the sheet holds the eyebrow and the statement on the first
12
+ * seven columns; the subtitle and the actions sit under the photograph on
13
+ * the last four, starting where the photograph ends, so the two halves of the
14
+ * opening read as one composition rather than a box with a hole beside it.
15
+ *
16
+ * The words are never laid over the picture. A headline floated in the middle
17
+ * of a dimmed photograph needs a veil to stay legible on every picture an
18
+ * editor may choose, and that veil is the look of every generated charity
19
+ * template; on the paper the statement is legible in both schemes whatever
20
+ * the photograph, and the faces keep their own light.
21
+ *
22
+ * The first action is the donation ask, drawn in the signal yellow; the
23
+ * others are underlined words. An eyebrow, when there is one, is a short line
24
+ * in the organisation's green, never a badge. Without media the block is the
25
+ * sheet alone.
26
+ *
27
+ * The image is the only one on a page loaded eagerly: it is above the fold
28
+ * by construction.
22
29
  */
23
30
  export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
24
- const tag = blockHeadingTag('hero') ?? 'h1'
31
+ const hasMedia = block.media !== undefined
32
+ const actions = actionList(ctx, block.actions, ctx.t('hero.actions'))
33
+ const hasAside = block.subtitle !== undefined || actions !== null
25
34
  return h(
26
35
  'section',
27
- { class: 'cg-block cg-hero', 'data-block': 'hero' },
28
- h(
29
- 'div',
30
- { class: 'cg-hero__copy' },
31
- block.eyebrow === undefined
32
- ? null
33
- : h(
34
- 'p',
35
- { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' },
36
- h('span', { class: 'cg-pill' }, block.eyebrow),
37
- ),
38
- heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
39
- block.subtitle === undefined
40
- ? null
41
- : h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
42
- actionList(ctx, block.actions, ctx.t('hero.actions')),
43
- ),
36
+ {
37
+ class: 'ca-section ca-hero ca-ask',
38
+ 'data-block': 'hero',
39
+ 'data-media': String(hasMedia),
40
+ },
44
41
  block.media === undefined
45
42
  ? null
46
43
  : h(
47
- 'div',
48
- { class: 'cg-hero__frame' },
49
- h('span', { class: 'cg-hero__halo', 'aria-hidden': 'true' }),
50
- // The only image above the fold by construction, so the only one
51
- // that must not be lazy — a lazy-loaded LCP element is a measured
52
- // Lighthouse regression, not a theoretical one.
44
+ 'figure',
45
+ { class: 'ca-hero__media' },
53
46
  image(ctx, block.media, {
54
- className: 'cg-hero__media',
47
+ className: 'ca-hero__image',
55
48
  loading: 'eager',
56
- sizes: '(min-width: 60rem) 44vw, 100vw',
49
+ sizes: '100vw',
57
50
  }),
58
51
  ),
52
+ h(
53
+ 'div',
54
+ { class: 'ca-container ca-hero__inner' },
55
+ h(
56
+ 'div',
57
+ { class: 'ca-hero__panel' },
58
+ optionalText('p', 'ca-kicker ca-hero__eyebrow', block.eyebrow, {
59
+ 'data-field': 'eyebrow',
60
+ }),
61
+ h('h1', { class: 'ca-hero__title', 'data-field': 'title' }, block.title),
62
+ ),
63
+ hasAside
64
+ ? h(
65
+ 'div',
66
+ { class: 'ca-hero__aside' },
67
+ optionalText('p', 'ca-hero__subtitle', block.subtitle, { 'data-field': 'subtitle' }),
68
+ actions,
69
+ )
70
+ : null,
71
+ ),
59
72
  )
60
73
  }