@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,39 +1,47 @@
1
1
  import type { MediaFigureBlock } from '@cogenta/blocks'
2
2
  import { aspectRatio, type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * `<figure>`/`<figcaption>`, matching the pattern the HTML spec prescribes for
6
- * an image and its own caption — a `<figcaption>` outside the `<figure>` would
7
- * lose that association for assistive technology.
6
+ * An image and its caption, placed on the grid by `align`.
8
7
  *
9
- * The frame reads as a bordered plate with a small caption rule underneath,
10
- * distinct from a floated/rounded treatment: the "structured, edged"
11
- * language this theme uses everywhere a picture appears.
8
+ * `wide` gives the image nine columns and sets the caption in the three to
9
+ * its right, under a hairline, aligned to the image's foot, the way an
10
+ * annual report places an exhibit and its note. `start` and `end` do the
11
+ * same on a narrower image; `center` keeps the caption under the image;
12
+ * `full` lets the image run the full width of the page. No frame, no shadow,
13
+ * no rounded corner: the picture is the object.
14
+ *
15
+ * `<figure>`/`<figcaption>` keep the caption associated with the image for
16
+ * assistive technology, which is why the figure is the grid container itself.
12
17
  */
13
18
  export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext): HtmlElement {
14
19
  const ratio = aspectRatio(block.ratio)
15
20
  const hasCaption = block.caption !== undefined || block.credit !== undefined
16
- return h(
17
- 'figure',
21
+ return section(
22
+ 'div',
23
+ 'mediaFigure',
24
+ 'cg-figure',
18
25
  {
19
- class: 'cg-figure',
20
- 'data-block': 'mediaFigure',
21
26
  'data-align': block.align ?? 'center',
22
27
  style: ratio === undefined ? undefined : `--cg-ratio:${ratio}`,
23
28
  },
29
+ 'figure',
24
30
  h(
25
31
  'div',
26
- { class: 'cg-figure__frame' },
32
+ { class: 'cg-figure__media' },
27
33
  image(ctx, block.media, {
28
- className: 'cg-figure__media',
29
- sizes: '(min-width: 48rem) 42rem, 100vw',
34
+ className: 'cg-figure__image',
35
+ sizes: block.align === 'full' ? '100vw' : '(min-width: 64rem) 75vw, 100vw',
30
36
  }),
31
37
  ),
32
38
  hasCaption
33
39
  ? h(
34
40
  'figcaption',
35
41
  { class: 'cg-figure__caption' },
36
- block.caption,
42
+ block.caption === undefined
43
+ ? null
44
+ : h('span', { class: 'cg-figure__text', 'data-field': 'caption' }, block.caption),
37
45
  block.credit === undefined
38
46
  ? null
39
47
  : h('span', { class: 'cg-figure__credit', 'data-field': 'credit' }, block.credit),
@@ -9,17 +9,18 @@ import {
9
9
  nestedHeadingTag,
10
10
  type RenderContext,
11
11
  } from '@cogenta/theme-kit'
12
+ import { section } from '../layout.js'
12
13
 
13
14
  /**
14
- * `blocks@2.0` (RFC 0001). A side-by-side plan comparison — the ledger-row
15
- * language this theme uses for `featureGrid` would flatten the very
16
- * distinction a pricing table exists to show, so tiers instead sit as a row
17
- * of bordered plates, one plate lifted (border + a small "recommended" rule)
18
- * when `highlighted` is set.
15
+ * `blocks@2.0` (RFC 0001). A comparison set as a ruled table rather than a
16
+ * row of cards: tiers stand side by side between vertical hairlines, each
17
+ * with its name in small capitals, the price in the display serif with
18
+ * tabular numerals, the inclusions as a ruled list and the action at the
19
+ * foot, aligned across tiers.
19
20
  *
20
21
  * `highlighted` is an editorial signal, never a colour: it becomes
21
- * `data-highlighted` for the stylesheet to key off, and `aria-current` so the
22
- * emphasis is announced, not only shown.
22
+ * `data-highlighted` (a heavier rule above that tier, in ink) and
23
+ * `aria-current`, so the emphasis is announced as well as shown.
23
24
  */
24
25
  function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
25
26
  return h(
@@ -50,7 +51,10 @@ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): Htm
50
51
  : h(
51
52
  'div',
52
53
  { class: 'cg-pricing__action' },
53
- actionLink(ctx, { ...tier.action, emphasis: tier.action.emphasis ?? 'primary' }),
54
+ actionLink(ctx, {
55
+ ...tier.action,
56
+ emphasis: tier.action.emphasis ?? (tier.highlighted === true ? 'primary' : 'secondary'),
57
+ }),
54
58
  ),
55
59
  )
56
60
  }
@@ -58,19 +62,26 @@ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): Htm
58
62
  export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
59
63
  const hasTitle = block.title !== undefined
60
64
  const tierTag = nestedHeadingTag('pricingTable', hasTitle)
61
- return h(
65
+ return section(
62
66
  'section',
63
- { class: 'cg-pricing', 'data-block': 'pricingTable' },
67
+ 'pricingTable',
68
+ 'cg-pricing',
69
+ {},
70
+ 'div',
64
71
  hasTitle
65
- ? heading(
66
- blockHeadingTag('pricingTable') ?? 'h2',
67
- { class: 'cg-pricing__title', 'data-field': 'title' },
68
- block.title ?? '',
72
+ ? h(
73
+ 'div',
74
+ { class: 'cg-head' },
75
+ heading(
76
+ blockHeadingTag('pricingTable') ?? 'h2',
77
+ { class: 'cg-head__title', 'data-field': 'title' },
78
+ block.title ?? '',
79
+ ),
69
80
  )
70
81
  : null,
71
82
  h(
72
83
  'ul',
73
- { class: 'cg-pricing__tiers' },
84
+ { class: 'cg-pricing__tiers', 'data-count': String(Math.min(block.tiers.length, 4)) },
74
85
  block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
75
86
  ),
76
87
  )
@@ -1,11 +1,25 @@
1
1
  import type { ProseBlock } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, type RenderContext, renderRichText } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * `prose` declares `headingLevel: 'none'`: it contributes no heading of its
6
- * own. Whatever headings appear come from the rich text document, whose
7
- * vocabulary starts at `h2`.
6
+ * A reading column set on the right-hand two thirds of the grid.
7
+ *
8
+ * On a wide screen the text starts at the fifth column and holds a 66ch
9
+ * measure; a second-level heading inside the document hangs out into the
10
+ * four columns to its left (`blocks.css`), so a long page reads as a report,
11
+ * with its section titles in the margin, rather than as one centred column.
12
+ *
13
+ * `prose` declares `headingLevel: 'none'`: whatever headings appear come
14
+ * from the rich text document, whose vocabulary starts at `h2`.
8
15
  */
9
16
  export function renderProse(block: ProseBlock, ctx: RenderContext): HtmlElement {
10
- return h('div', { class: 'cg-prose', 'data-block': 'prose' }, renderRichText(ctx, block.body))
17
+ return section(
18
+ 'div',
19
+ 'prose',
20
+ 'cg-text',
21
+ {},
22
+ 'div',
23
+ h('div', { class: 'cg-prose' }, renderRichText(ctx, block.body)),
24
+ )
11
25
  }
@@ -1,35 +1,39 @@
1
1
  import type { QuoteBlock } 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
- * A testimonial card, not a centred pull-quote: a bordered panel with a
6
- * vertical accent rule beside the quotation and the attribution set below a
7
- * thin divider — the shape a case-study or client-testimonial section takes
8
- * on a serious B2B site.
6
+ * An editorial quotation inside the reading column: the display serif in
7
+ * italic, under a hairline, with the attribution set small beneath it.
9
8
  *
10
- * `<figure><blockquote>…</blockquote><figcaption>` is the attribution
11
- * pattern the HTML spec prescribes: putting the author inside the
12
- * `<blockquote>` would claim the author's name was part of what was said.
9
+ * Quieter than `testimonial`, which is this theme's large pull quote: a
10
+ * `quote` belongs to the flow of a page, aligned with the prose column
11
+ * rather than spread across the grid, and never in a shaded box.
13
12
  *
14
- * The avatar is decorative here — the name sits right beside it in text —
15
- * so its media entity's alt text is expected to be empty; `image` still
16
- * writes the attribute either way (WCAG 1.1.1).
13
+ * `<figure><blockquote>…</blockquote><figcaption>` is the attribution pattern
14
+ * the HTML spec prescribes: putting the name inside the `<blockquote>` would
15
+ * claim it was part of what was said. The portrait is decorative (the name
16
+ * is right beside it), so its media entity's alt text is expected to be
17
+ * empty; `image` writes the attribute either way (WCAG 1.1.1).
17
18
  */
18
19
  export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement {
19
20
  const hasAttribution =
20
21
  block.author !== undefined || block.role !== undefined || block.avatar !== undefined
21
- return h(
22
+ return section(
23
+ 'div',
24
+ 'quote',
25
+ 'cg-quote',
26
+ {},
22
27
  'figure',
23
- { class: 'cg-quote', 'data-block': 'quote' },
24
28
  h('blockquote', { class: 'cg-quote__text' }, h('p', { 'data-field': 'text' }, block.text)),
25
29
  hasAttribution
26
30
  ? h(
27
31
  'figcaption',
28
- { class: 'cg-quote__attribution' },
32
+ { class: 'cg-quote__cite' },
29
33
  block.avatar === undefined
30
34
  ? null
31
35
  : image(ctx, block.avatar, {
32
- className: 'cg-quote__avatar',
36
+ className: 'cg-quote__portrait',
33
37
  variant: { width: 96, height: 96, fit: 'cover' },
34
38
  }),
35
39
  h(
@@ -6,40 +6,45 @@ import {
6
6
  heading,
7
7
  type RenderContext,
8
8
  } from '@cogenta/theme-kit'
9
+ import { section } from '../layout.js'
9
10
 
10
11
  /**
11
- * `blocks@2.0` (RFC 0001). Narrower than `stats` (no `unit`), and treated
12
- * that way here: `stats` reads as a quiet KPI strip on the page's own
13
- * surface, while `statCounter` is this theme's confident "impact numbers"
14
- * band — an accent-tinted panel of big, tabular figures, the instinct the
15
- * aesthetic direction already asks for on `stats` pushed one step further
16
- * for the narrower, single-figure shape this block offers.
12
+ * `blocks@2.0` (RFC 0001). Narrower than `stats` (no `unit`), and laid out as
13
+ * its counterpart: where `stats` is a single row between vertical rules,
14
+ * `statCounter` keeps its title in the left four columns and sets its
15
+ * figures in a two-by-two table on the right eight, each under a horizontal
16
+ * hairline, the way an annual report lists its headline numbers.
17
17
  *
18
- * A `<dl>`, exactly like `stats`: each figure is the description of its
19
- * label, which is what `<dt>`/`<dd>` mean. The label sits before the figure
20
- * in markup, the same reading-order guarantee `stats` gives (WCAG 1.3.2);
21
- * the stylesheet is free to paint the big figure first.
18
+ * A `<dl>`, with the label first in the markup for the same reading-order
19
+ * reason as `stats`.
22
20
  */
23
21
  function renderItem(item: StatCounterItem): HtmlElement {
24
22
  return h(
25
23
  'div',
26
- { class: 'cg-kpi' },
27
- h('dt', { class: 'cg-kpi__label' }, item.label),
28
- h('dd', { class: 'cg-kpi__value' }, item.value),
24
+ { class: 'cg-counter' },
25
+ h('dt', { class: 'cg-counter__label' }, item.label),
26
+ h('dd', { class: 'cg-counter__value' }, item.value),
29
27
  )
30
28
  }
31
29
 
32
30
  export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
33
- return h(
31
+ return section(
34
32
  'section',
35
- { class: 'cg-kpis', 'data-block': 'statCounter' },
33
+ 'statCounter',
34
+ 'cg-counters',
35
+ { 'data-titled': block.title === undefined ? 'false' : 'true' },
36
+ 'div',
36
37
  block.title === undefined
37
38
  ? null
38
- : heading(
39
- blockHeadingTag('statCounter') ?? 'h2',
40
- { class: 'cg-kpis__title', 'data-field': 'title' },
41
- block.title,
39
+ : h(
40
+ 'div',
41
+ { class: 'cg-head cg-head--aside' },
42
+ heading(
43
+ blockHeadingTag('statCounter') ?? 'h2',
44
+ { class: 'cg-head__title', 'data-field': 'title' },
45
+ block.title,
46
+ ),
42
47
  ),
43
- h('dl', { class: 'cg-kpis__items' }, block.stats.map(renderItem)),
48
+ h('dl', { class: 'cg-counters__items' }, block.stats.map(renderItem)),
44
49
  )
45
50
  }
@@ -6,45 +6,55 @@ import {
6
6
  heading,
7
7
  type RenderContext,
8
8
  } from '@cogenta/theme-kit'
9
+ import { section } from '../layout.js'
9
10
 
10
11
  /**
11
- * The "by the numbers" strip. A description list, exactly as the canonical
12
- * theme uses — each figure is the description of its label, which is what
13
- * `<dt>`/`<dd>` mean, and it survives being read linearly — but laid out as
14
- * one continuous row of big, confident figures separated by vertical rules
15
- * (`border-inline-start` on every item but the first) rather than as
16
- * separate shadowed tiles: the "real KPI strip" read the aesthetic
17
- * direction asks for.
12
+ * Key figures as one typographic row between vertical rules: the figure in
13
+ * the display serif with lining, tabular numerals, its unit set smaller in
14
+ * the same face, and the label in small text beneath. No coloured band and
15
+ * no tiles.
18
16
  *
19
- * The label sits above the figure in the markup and the skin is free to
20
- * repaint the order visually; reading order and visual order stay
21
- * independent either way (WCAG 1.3.2).
17
+ * A description list, because each figure is the description of its label.
18
+ * The label comes first in the markup, so a screen reader announces what a
19
+ * number means before the number (WCAG 1.3.2); the stylesheet paints the
20
+ * figure above it.
22
21
  */
23
22
  function renderItem(item: StatItem): HtmlElement {
24
23
  return h(
25
24
  'div',
26
- { class: 'cg-metric' },
27
- h('dt', { class: 'cg-metric__label' }, item.label),
25
+ { class: 'cg-stat' },
26
+ h('dt', { class: 'cg-stat__label' }, item.label),
28
27
  h(
29
28
  'dd',
30
- { class: 'cg-metric__value' },
29
+ { class: 'cg-stat__value' },
31
30
  item.value,
32
- item.unit === undefined ? null : h('span', { class: 'cg-metric__unit' }, item.unit),
31
+ item.unit === undefined ? null : h('span', { class: 'cg-stat__unit' }, item.unit),
33
32
  ),
34
33
  )
35
34
  }
36
35
 
37
36
  export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
38
- return h(
37
+ return section(
39
38
  'section',
40
- { class: 'cg-metrics', 'data-block': 'stats' },
39
+ 'stats',
40
+ 'cg-stats',
41
+ {},
42
+ 'div',
41
43
  block.title === undefined
42
44
  ? null
43
- : heading(
44
- blockHeadingTag('stats') ?? 'h2',
45
- { class: 'cg-metrics__title', 'data-field': 'title' },
46
- block.title,
45
+ : h(
46
+ 'div',
47
+ { class: 'cg-head' },
48
+ heading(
49
+ blockHeadingTag('stats') ?? 'h2',
50
+ { class: 'cg-head__title', 'data-field': 'title' },
51
+ block.title,
52
+ ),
47
53
  ),
48
- h('dl', { class: 'cg-metrics__items' }, block.items.map(renderItem)),
54
+ h(
55
+ 'dl',
56
+ { class: 'cg-stats__items', 'data-count': String(Math.min(block.items.length, 5)) },
57
+ block.items.map(renderItem),
58
+ ),
49
59
  )
50
60
  }
@@ -1,40 +1,44 @@
1
1
  import type { TestimonialBlock } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, image, type RenderContext, renderRichText } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * `blocks@2.0` (RFC 0001). This theme's "client success" panel — the same
6
- * `<figure><blockquote>…</blockquote><figcaption>` pattern `quote` uses, kept
7
- * consistent so the two read as siblings, but the quote itself is rich text
8
- * (a testimonial may carry a link or emphasis the shorter, plain-text `quote`
9
- * block cannot) and the attribution is the block's single grouped
10
- * `attribution` field rather than three loose ones.
6
+ * `blocks@2.0` (RFC 0001). The large pull quote: one client's words set in
7
+ * the display serif across ten columns, with typographic quotation marks
8
+ * hung in the margin, and a small square portrait beside the name.
11
9
  *
12
- * The avatar is decorative, exactly like `quote`'s own: the name sits right
13
- * beside it in text, so its media entity's alt text is expected to be empty;
14
- * `image` still writes the attribute either way (WCAG 1.1.1).
10
+ * No tinted panel, no border on one side, no italic box: the size of the
11
+ * type and the space around it are the emphasis. The quote is rich text, so
12
+ * a testimonial may carry a link or emphasis the plain-text `quote` cannot.
13
+ *
14
+ * The portrait is decorative (the name is beside it in text), so its alt
15
+ * text is expected to be empty; `image` writes the attribute either way.
15
16
  */
16
17
  export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
17
18
  const { attribution } = block
18
- return h(
19
+ return section(
20
+ 'div',
21
+ 'testimonial',
22
+ 'cg-pullquote',
23
+ {},
19
24
  'figure',
20
- { class: 'cg-testimonial', 'data-block': 'testimonial' },
21
- h('blockquote', { class: 'cg-testimonial__quote' }, renderRichText(ctx, block.quote)),
25
+ h('blockquote', { class: 'cg-pullquote__quote' }, renderRichText(ctx, block.quote)),
22
26
  h(
23
27
  'figcaption',
24
- { class: 'cg-testimonial__attribution' },
28
+ { class: 'cg-pullquote__cite' },
25
29
  attribution.avatar === undefined
26
30
  ? null
27
31
  : image(ctx, attribution.avatar, {
28
- className: 'cg-testimonial__avatar',
29
- variant: { width: 96, height: 96, fit: 'cover' },
32
+ className: 'cg-pullquote__portrait',
33
+ variant: { width: 144, height: 144, fit: 'cover' },
30
34
  }),
31
35
  h(
32
36
  'span',
33
- { class: 'cg-testimonial__who' },
34
- h('span', { class: 'cg-testimonial__name' }, attribution.name),
37
+ { class: 'cg-pullquote__who' },
38
+ h('span', { class: 'cg-pullquote__name' }, attribution.name),
35
39
  attribution.role === undefined
36
40
  ? null
37
- : h('span', { class: 'cg-testimonial__role' }, attribution.role),
41
+ : h('span', { class: 'cg-pullquote__role' }, attribution.role),
38
42
  ),
39
43
  ),
40
44
  )
@@ -4,42 +4,39 @@ import {
4
4
  type ChromeResult,
5
5
  escapeAttribute,
6
6
  escapeText,
7
+ type HtmlElement,
8
+ h,
7
9
  renderBrandMark,
10
+ renderFooterWidgets,
8
11
  renderSocialLinks,
9
12
  renderThemeToggle,
10
13
  serialize,
11
14
  } from '@cogenta/theme-kit'
12
15
 
13
16
  /**
14
- * This theme's own header and footer (`theme@1.4`) — a structurally
15
- * different chrome from the canonical theme's, not a recolour of it.
17
+ * This theme's own header and footer (`theme@1.4`), drawn the way a firm's
18
+ * website is: a quiet bar and a proper colophon.
16
19
  *
17
- * Header: a sticky bar with the corporate wordmark/logo on the left, a real
18
- * desktop `<nav>`, `headerAction` (`theme@1.4`) rendered as a filled button
19
- * — the one place this hairline-first chrome spends a solid accent surface
20
- * — and, below the breakpoint the stylesheet sets, a CSS-only mobile menu:
21
- * a checkbox (`#cg-nav-toggle`, visually hidden but still focusable and
22
- * Space-operable) paired with a `<label>` styled as a three-bar button. No
23
- * `<script>` anywhere: the sibling combinator in `base.css` is what shows
24
- * the nav panel when the box is checked. This replaces the previous
25
- * "the last header link doubles as the call to action" convention — with a
26
- * real `headerAction` field, inventing a role for a plain nav link is no
27
- * longer the honest reading of the data.
20
+ * Header: the firm's name set as a wordmark in the display serif (or its
21
+ * uploaded logo), the primary navigation in small interface text, the
22
+ * `headerAction` as the one filled control, and the light/dark control last.
23
+ * Below the breakpoint the navigation collapses into a panel opened by a
24
+ * CSS-only toggle: a visually hidden checkbox paired with a `<label>`, no
25
+ * `<script>`. The label must precede the `<nav>` for the sibling combinator
26
+ * in `base.css` to reach it; the theme toggle follows the `<nav>`, outside
27
+ * the panel it does not control, so a visitor can switch appearance without
28
+ * opening the menu first.
28
29
  *
29
- * Footer: a real four-column layout — a brand column (site name + `tagline`,
30
- * `theme@1.4`), the site's own footer navigation, `social` (`theme@1.4`, via
31
- * `renderSocialLinks`), and a fourth column carrying `footerNote`
32
- * (`theme@1.4`) above Cogenta's own credit (or its white-label replacement).
33
- * `brandingHtml` is placed exactly once, inside the footer, exactly as
34
- * received: never altered, never dropped.
30
+ * Footer: a twelve-column colophon. The name and `tagline` on the left, the
31
+ * footer navigation, the `footerNote` and the social profiles (real icons,
32
+ * via `renderSocialLinks`), then a legal line under a hairline with the
33
+ * copyright year and the site's name beside Cogenta's credit
34
+ * (`brandingHtml`, placed exactly once, exactly as received).
35
35
  *
36
- * The manual light/dark/system control (`renderThemeToggle`, `@cogenta/
37
- * theme-kit`, L26) sits beside the mobile menu button, outside the `<nav>`
38
- * it controls the collapse of — the `<nav>` panel is hidden under the
39
- * breakpoint until the checkbox is checked, but a visitor must be able to
40
- * flip appearance without opening that panel first, on desktop or mobile.
41
- * This module ships no CSS of its own for it (`base.css` owns
42
- * `.cg-theme-toggle`, in this theme's own visual register, not canonical's).
36
+ * `footerNote` is plain text. When an editor separates it into paragraphs
37
+ * with blank lines, each paragraph becomes its own block, and a paragraph of
38
+ * several lines gets its first line set as a label: an office city above its
39
+ * address, a heading above a legal mention. One line stays one paragraph.
43
40
  */
44
41
 
45
42
  function navItems(links: readonly ChromeNavLink[]): string {
@@ -61,7 +58,7 @@ function renderNavList(links: readonly ChromeNavLink[]): string {
61
58
  return items === '' ? '' : `<ul class="cg-nav__items">${items}</ul>`
62
59
  }
63
60
 
64
- /** The header's own call-to-action link (`theme@1.4`) — a filled button, distinct from the ordinary nav list. */
61
+ /** The header's own call to action (`theme@1.4`): the one filled control in the bar. */
65
62
  function renderHeaderAction(action: ChromeInput['headerAction']): string {
66
63
  if (action === undefined) return ''
67
64
  return (
@@ -70,13 +67,43 @@ function renderHeaderAction(action: ChromeInput['headerAction']): string {
70
67
  )
71
68
  }
72
69
 
70
+ /** `footerNote` as paragraphs: blank lines separate them, and a multi-line paragraph's first line is its label. */
71
+ export function renderFooterNote(note: string): HtmlElement | null {
72
+ const paragraphs = note
73
+ .replace(/\r\n?/g, '\n')
74
+ .split(/\n\s*\n/)
75
+ .map((paragraph) =>
76
+ paragraph
77
+ .split('\n')
78
+ .map((line) => line.trim())
79
+ .filter((line) => line !== ''),
80
+ )
81
+ .filter((lines) => lines.length > 0)
82
+ if (paragraphs.length === 0) return null
83
+ return h(
84
+ 'div',
85
+ { class: 'cg-site-footer__note', 'data-paragraphs': String(Math.min(paragraphs.length, 4)) },
86
+ paragraphs.map((lines) => {
87
+ const [first, ...rest] = lines as [string, ...string[]]
88
+ if (rest.length === 0) return h('p', { class: 'cg-site-footer__note-text' }, first)
89
+ return h(
90
+ 'p',
91
+ { class: 'cg-site-footer__note-text' },
92
+ h('span', { class: 'cg-site-footer__note-label' }, first),
93
+ rest.flatMap((line, index) => (index === 0 ? [line] : [h('br'), line])),
94
+ )
95
+ }),
96
+ )
97
+ }
98
+
73
99
  export function renderChrome(input: ChromeInput): ChromeResult {
74
100
  const siteNameText = escapeText(input.site.name)
75
101
  const headerNavList = renderNavList(input.headerNav)
76
102
  const headerAction = renderHeaderAction(input.headerAction)
77
103
  const footerNavList = renderNavList(input.footerNav)
78
- // A B2B header is a corporate identity slot before it is a wordmark slot.
79
- const mark = renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ?? siteNameText
104
+ const mark =
105
+ renderBrandMark(input.brand, { className: 'cg-site-header__logo' }) ??
106
+ `<span class="cg-site-header__name">${siteNameText}</span>`
80
107
  const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
81
108
 
82
109
  const header =
@@ -84,45 +111,53 @@ export function renderChrome(input: ChromeInput): ChromeResult {
84
111
  `<a class="cg-site-header__home" href="${escapeAttribute(input.homeHref)}">${mark}</a>` +
85
112
  `<input type="checkbox" id="cg-nav-toggle" class="cg-nav-toggle-input" aria-label="Menu">` +
86
113
  `<label for="cg-nav-toggle" class="cg-nav-toggle-label" aria-hidden="true">` +
87
- `<span class="cg-nav-toggle-bar"></span><span class="cg-nav-toggle-bar"></span><span class="cg-nav-toggle-bar"></span>` +
114
+ `<span class="cg-nav-toggle-bar"></span><span class="cg-nav-toggle-bar"></span>` +
88
115
  `</label>` +
89
- `${themeToggle}` +
90
116
  `<nav class="cg-site-header__nav" id="cg-nav" aria-label="Primary">` +
91
117
  `${headerNavList}${headerAction}` +
92
118
  `</nav>` +
119
+ `${themeToggle}` +
93
120
  `</div></header>`
94
121
 
95
122
  const tagline =
96
123
  input.tagline === undefined
97
124
  ? ''
98
125
  : `<p class="cg-site-footer__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
99
- const social =
100
- input.social === undefined
101
- ? ''
102
- : serialize(
103
- renderSocialLinks(input.social, {
104
- className: 'cg-site-footer__social',
105
- itemClassName: 'cg-site-footer__social-item',
106
- }) ?? { kind: 'text', value: '' },
107
- )
108
- const footerNote =
109
- input.footerNote === undefined
110
- ? ''
111
- : `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
126
+ const socialList = renderSocialLinks(input.social, {
127
+ className: 'cg-site-footer__social',
128
+ itemClassName: 'cg-site-footer__social-item',
129
+ })
130
+ const note = input.footerNote === undefined ? null : renderFooterNote(input.footerNote)
131
+ // The copyright year is the year the page is rendered, which is what a
132
+ // legal line on a live site means.
133
+ const year = new Date().getFullYear()
134
+ // The footer widget columns (`theme@1.6`) are one row under the colophon's
135
+ // own columns and above the legal line, in the same small type.
136
+ const footerWidgets = renderFooterWidgets(input.widgets, {
137
+ className: 'cg-site-footer__widgets',
138
+ headingLevel: 'h2',
139
+ })
112
140
 
113
141
  const footer =
114
- `<footer class="cg-site-footer"><div class="cg-site-footer__grid">` +
142
+ `<footer class="cg-site-footer"><div class="cg-site-footer__inner">` +
143
+ `<div class="cg-site-footer__top">` +
115
144
  `<div class="cg-site-footer__brand">` +
116
- `<a href="${escapeAttribute(input.homeHref)}">${siteNameText}</a>${tagline}` +
145
+ `<a class="cg-site-footer__name" href="${escapeAttribute(input.homeHref)}">${siteNameText}</a>${tagline}` +
117
146
  `</div>` +
118
147
  `${
119
148
  footerNavList === ''
120
149
  ? ''
121
150
  : `<nav class="cg-site-footer__nav" aria-label="Footer">${footerNavList}</nav>`
122
151
  }` +
123
- `${social === '' ? '' : `<div class="cg-site-footer__social-col">${social}</div>`}` +
124
- `<div class="cg-site-footer__meta">${footerNote}<div class="cg-site-footer__branding">${input.brandingHtml}</div></div>` +
125
- `</div><div class="cg-site-footer__bottom"><span>${siteNameText}</span></div></footer>`
152
+ `${note === null ? '' : serialize(note)}` +
153
+ `${socialList === null ? '' : `<div class="cg-site-footer__social-col">${serialize(socialList)}</div>`}` +
154
+ `</div>` +
155
+ `${footerWidgets === null ? '' : serialize(footerWidgets)}` +
156
+ `<div class="cg-site-footer__bottom">` +
157
+ `<p class="cg-site-footer__legal">© ${year} ${siteNameText}</p>` +
158
+ `<div class="cg-site-footer__branding">${input.brandingHtml}</div>` +
159
+ `</div>` +
160
+ `</div></footer>`
126
161
 
127
162
  return { header, footer }
128
163
  }