@cogenta/theme-portfolio 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 (128) hide show
  1. package/dist/src/index.d.ts +9 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +10 -1
  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 +10 -18
  7. package/dist/src/render/blocks/accordion.js.map +1 -1
  8. package/dist/src/render/blocks/collection-list.d.ts +0 -20
  9. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  10. package/dist/src/render/blocks/collection-list.js +59 -66
  11. package/dist/src/render/blocks/collection-list.js.map +1 -1
  12. package/dist/src/render/blocks/cta.d.ts +4 -4
  13. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  14. package/dist/src/render/blocks/cta.js +15 -10
  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 +16 -31
  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 +12 -17
  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 +26 -23
  24. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  25. package/dist/src/render/blocks/gallery.d.ts +0 -14
  26. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  27. package/dist/src/render/blocks/gallery.js +29 -21
  28. package/dist/src/render/blocks/gallery.js.map +1 -1
  29. package/dist/src/render/blocks/hero.d.ts +8 -8
  30. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  31. package/dist/src/render/blocks/hero.js +17 -18
  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 +9 -14
  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 +14 -19
  38. package/dist/src/render/blocks/logos.js.map +1 -1
  39. package/dist/src/render/blocks/media-figure.d.ts +8 -12
  40. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  41. package/dist/src/render/blocks/media-figure.js +20 -20
  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 +17 -20
  45. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  46. package/dist/src/render/blocks/prose.d.ts +10 -9
  47. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  48. package/dist/src/render/blocks/prose.js +29 -10
  49. package/dist/src/render/blocks/prose.js.map +1 -1
  50. package/dist/src/render/blocks/quote.d.ts +5 -12
  51. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  52. package/dist/src/render/blocks/quote.js +9 -15
  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 +10 -12
  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 +7 -15
  59. package/dist/src/render/blocks/stats.js.map +1 -1
  60. package/dist/src/render/blocks/testimonial.d.ts +4 -14
  61. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  62. package/dist/src/render/blocks/testimonial.js +9 -18
  63. package/dist/src/render/blocks/testimonial.js.map +1 -1
  64. package/dist/src/render/chrome.d.ts.map +1 -1
  65. package/dist/src/render/chrome.js +93 -90
  66. package/dist/src/render/chrome.js.map +1 -1
  67. package/dist/src/render/layout.d.ts +61 -0
  68. package/dist/src/render/layout.d.ts.map +1 -0
  69. package/dist/src/render/layout.js +134 -0
  70. package/dist/src/render/layout.js.map +1 -0
  71. package/dist/src/render/project.d.ts +27 -0
  72. package/dist/src/render/project.d.ts.map +1 -0
  73. package/dist/src/render/project.js +82 -0
  74. package/dist/src/render/project.js.map +1 -0
  75. package/dist/src/render/render-block.d.ts +17 -7
  76. package/dist/src/render/render-block.d.ts.map +1 -1
  77. package/dist/src/render/render-block.js +47 -34
  78. package/dist/src/render/render-block.js.map +1 -1
  79. package/dist/src/render/term-archive.d.ts +0 -11
  80. package/dist/src/render/term-archive.d.ts.map +1 -1
  81. package/dist/src/render/term-archive.js +36 -24
  82. package/dist/src/render/term-archive.js.map +1 -1
  83. package/dist/src/render/widget-areas.d.ts +14 -0
  84. package/dist/src/render/widget-areas.d.ts.map +1 -0
  85. package/dist/src/render/widget-areas.js +21 -0
  86. package/dist/src/render/widget-areas.js.map +1 -0
  87. package/dist/src/render/work.d.ts +37 -0
  88. package/dist/src/render/work.d.ts.map +1 -0
  89. package/dist/src/render/work.js +49 -0
  90. package/dist/src/render/work.js.map +1 -0
  91. package/dist/theme.config.d.ts +4 -7
  92. package/dist/theme.config.d.ts.map +1 -1
  93. package/dist/theme.config.js +6 -9
  94. package/dist/theme.config.js.map +1 -1
  95. package/package.json +4 -4
  96. package/src/index.ts +10 -1
  97. package/src/render/blocks/accordion.ts +22 -42
  98. package/src/render/blocks/collection-list.ts +83 -137
  99. package/src/render/blocks/cta.ts +24 -12
  100. package/src/render/blocks/embed.ts +39 -47
  101. package/src/render/blocks/faq.ts +24 -33
  102. package/src/render/blocks/feature-grid.ts +38 -41
  103. package/src/render/blocks/gallery.ts +34 -28
  104. package/src/render/blocks/hero.ts +31 -28
  105. package/src/render/blocks/logo-strip.ts +18 -20
  106. package/src/render/blocks/logos.ts +22 -43
  107. package/src/render/blocks/media-figure.ts +28 -20
  108. package/src/render/blocks/pricing-table.ts +31 -32
  109. package/src/render/blocks/prose.ts +38 -13
  110. package/src/render/blocks/quote.ts +18 -18
  111. package/src/render/blocks/stat-counter.ts +19 -37
  112. package/src/render/blocks/stats.ts +18 -33
  113. package/src/render/blocks/testimonial.ts +16 -22
  114. package/src/render/chrome.ts +99 -97
  115. package/src/render/layout.ts +177 -0
  116. package/src/render/project.ts +140 -0
  117. package/src/render/render-block.ts +49 -45
  118. package/src/render/term-archive.ts +92 -70
  119. package/src/render/widget-areas.ts +25 -0
  120. package/src/render/work.ts +124 -0
  121. package/src/styles/archive.css +61 -21
  122. package/src/styles/base.css +763 -648
  123. package/src/styles/blocks.css +697 -1093
  124. package/src/styles/theme.css +22 -10
  125. package/src/styles/tokens.css +174 -252
  126. package/src/styles/widgets.css +948 -0
  127. package/src/styles/work.css +405 -0
  128. package/tokens.json +19 -19
@@ -1,49 +1,31 @@
1
1
  import type { StatCounterBlock, StatCounterItem } from '@cogenta/blocks'
2
- import {
3
- blockHeadingTag,
4
- type HtmlElement,
5
- h,
6
- heading,
7
- type RenderContext,
8
- } from '@cogenta/theme-kit'
2
+ import { type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section, sectionHead } from '../layout.js'
9
4
 
10
5
  /**
11
- * Narrower than `stats` (no `unit`, RFC 0001's own reasoning), so this is not
12
- * `stats.ts`'s description list of labelled figures again with a field
13
- * dropped — it is set as a single running strip of oversized digits
14
- * separated by hairlines, the way a spec sheet's headline numbers run across
15
- * a masthead, each with its own zero-padded index the way `logos.ts` numbers
16
- * its marks. `stats.ts` stays the fuller, boxed "by the numbers" panel.
6
+ * A ruled table of figures from the fourth column, one row per figure: what
7
+ * it counts on the left in the text face, the value on the right in the
8
+ * display width with tabular numerals, so a column of numbers lines up.
9
+ * Where `stats` sets figures side by side, this sets them as a list read
10
+ * down the page. It carries no unit, the narrower shape contract B gives it.
17
11
  */
18
- function renderItem(item: StatCounterItem, index: number): HtmlElement {
12
+ function renderItem(item: StatCounterItem): HtmlElement {
19
13
  return h(
20
- 'li',
21
- { class: 'cg-counter__item' },
22
- h(
23
- 'span',
24
- { class: 'cg-counter__index', 'aria-hidden': 'true' },
25
- String(index + 1).padStart(2, '0'),
26
- ),
27
- h('span', { class: 'cg-counter__value' }, item.value),
28
- h('span', { class: 'cg-counter__label' }, item.label),
14
+ 'div',
15
+ { class: 'cg-tally__row' },
16
+ h('dt', { class: 'cg-tally__label' }, item.label),
17
+ h('dd', { class: 'cg-tally__value' }, item.value),
29
18
  )
30
19
  }
31
20
 
32
21
  export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
33
- return h(
22
+ return section(
34
23
  'section',
35
- { class: 'cg-block cg-counter', 'data-block': 'statCounter' },
36
- block.title === undefined
37
- ? null
38
- : heading(
39
- blockHeadingTag('statCounter') ?? 'h2',
40
- { class: 'cg-counter__title', 'data-field': 'title' },
41
- block.title,
42
- ),
43
- h(
44
- 'ul',
45
- { class: 'cg-counter__items' },
46
- block.stats.map((item, index) => renderItem(item, index)),
47
- ),
24
+ 'statCounter',
25
+ 'cg-tally cg-split',
26
+ { 'data-titled': block.title === undefined ? 'false' : 'true' },
27
+ 'div',
28
+ sectionHead('statCounter', block.title),
29
+ h('dl', { class: 'cg-tally__rows' }, block.stats.map(renderItem)),
48
30
  )
49
31
  }
@@ -1,49 +1,34 @@
1
1
  import type { StatItem, StatsBlock } from '@cogenta/blocks'
2
- import {
3
- blockHeadingTag,
4
- type HtmlElement,
5
- h,
6
- heading,
7
- type RenderContext,
8
- } from '@cogenta/theme-kit'
2
+ import { type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section, sectionHead } from '../layout.js'
9
4
 
10
5
  /**
11
- * A description list: each figure is the description of its label, which is
12
- * exactly what `<dt>`/`<dd>` mean, and it survives being read linearly.
13
- *
14
- * The label comes first in the markup and the skin puts the figure above it
15
- * — reading order and visual order stay independent, which is what WCAG
16
- * 1.3.2 asks for.
17
- *
18
- * Figures are set at the theme's biggest display scale and in tabular mono
19
- * numerals — a portfolio's "by the numbers" row reads like a spec sheet,
20
- * not a dashboard widget.
6
+ * Figures side by side, the way a studio states its record: each figure very
7
+ * large in the display width with tabular numerals, its unit after it at the
8
+ * text size, and what it counts under it, a hairline over every column.
21
9
  */
22
10
  function renderItem(item: StatItem): HtmlElement {
23
11
  return h(
24
12
  'div',
25
- { class: 'cg-stat' },
26
- h('dt', { class: 'cg-stat__label' }, item.label),
13
+ { class: 'cg-figures__item' },
27
14
  h(
28
- 'dd',
29
- { class: 'cg-stat__value' },
30
- item.value,
31
- item.unit === undefined ? null : h('span', { class: 'cg-stat__unit' }, item.unit),
15
+ 'dt',
16
+ { class: 'cg-figures__figure' },
17
+ h('span', { class: 'cg-figures__value' }, item.value),
18
+ item.unit === undefined ? null : h('span', { class: 'cg-figures__unit' }, item.unit),
32
19
  ),
20
+ h('dd', { class: 'cg-figures__label' }, item.label),
33
21
  )
34
22
  }
35
23
 
36
24
  export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
37
- return h(
25
+ return section(
38
26
  'section',
39
- { class: 'cg-block cg-stats', 'data-block': 'stats' },
40
- block.title === undefined
41
- ? null
42
- : heading(
43
- blockHeadingTag('stats') ?? 'h2',
44
- { class: 'cg-stats__title', 'data-field': 'title' },
45
- block.title,
46
- ),
47
- h('dl', { class: 'cg-stats__items' }, block.items.map(renderItem)),
27
+ 'stats',
28
+ 'cg-figures',
29
+ { 'data-count': String(Math.min(block.items.length, 4)) },
30
+ 'div',
31
+ sectionHead('stats', block.title),
32
+ h('dl', { class: 'cg-figures__items' }, block.items.map(renderItem)),
48
33
  )
49
34
  }
@@ -1,44 +1,38 @@
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
- * Distinct from `quote` on purpose (RFC 0001): `quote` is this theme's full-
6
- * bleed pull quote, set left-aligned at display scale with no frame. A wall
7
- * of testimonials needs to sit several-up without each one reading as a
8
- * headline, so this is a bordered card instead — the same "cut paper"
9
- * language `feature-grid.ts` uses for its items, at a quieter type scale.
10
- *
11
- * `attribution` arrives as one grouped field (RFC 0001's own reasoning for
12
- * shaping it that way), so its `name`/`role` are not marked `data-field`:
13
- * that attribute is reserved for a plain-text field the block schema
14
- * declares at the top level, and neither of these is one — only `attribution`
15
- * itself is, and it is not plain text.
16
- *
17
- * The avatar is decorative, exactly like `quote.ts`'s own — the name sits
18
- * right beside it in text, so its media entity's alt text is expected empty.
6
+ * A longer word from a client, quieter than `quote`: the words at the lead
7
+ * size from the fourth column, a hairline above them, and the client under
8
+ * them with their role. Where `quote` is a line set large, a testimonial is a
9
+ * paragraph meant to be read.
19
10
  */
20
11
  export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
21
12
  const { attribution } = block
22
- return h(
13
+ return section(
14
+ 'div',
15
+ 'testimonial',
16
+ 'cg-word',
17
+ {},
23
18
  'figure',
24
- { class: 'cg-block cg-testimonial', 'data-block': 'testimonial' },
25
- h('blockquote', { class: 'cg-testimonial__quote' }, renderRichText(ctx, block.quote)),
19
+ h('blockquote', { class: 'cg-word__quote' }, renderRichText(ctx, block.quote)),
26
20
  h(
27
21
  'figcaption',
28
- { class: 'cg-testimonial__attribution' },
22
+ { class: 'cg-word__attribution' },
29
23
  attribution.avatar === undefined
30
24
  ? null
31
25
  : image(ctx, attribution.avatar, {
32
- className: 'cg-testimonial__avatar',
26
+ className: 'cg-word__avatar',
33
27
  variant: { width: 96, height: 96, fit: 'cover' },
34
28
  }),
35
29
  h(
36
30
  'span',
37
- { class: 'cg-testimonial__names' },
38
- h('span', { class: 'cg-testimonial__name' }, attribution.name),
31
+ { class: 'cg-word__who' },
32
+ h('span', { class: 'cg-word__name' }, attribution.name),
39
33
  attribution.role === undefined
40
34
  ? null
41
- : h('span', { class: 'cg-testimonial__role' }, attribution.role),
35
+ : h('span', { class: 'cg-word__role' }, attribution.role),
42
36
  ),
43
37
  ),
44
38
  )
@@ -5,138 +5,140 @@ import {
5
5
  escapeAttribute,
6
6
  escapeText,
7
7
  renderBrandMark,
8
+ renderFooterWidgets,
8
9
  renderSocialLinks,
9
10
  renderThemeToggle,
10
11
  serialize,
11
12
  } from '@cogenta/theme-kit'
12
13
 
13
14
  /**
14
- * The chrome this theme ships (`theme@1.4`): a thin, sticky index bar up
15
- * top — the site name treated as a typographic mark rather than a logo
16
- * slot, a running index number, a real desktop `<nav>`, `headerAction`
17
- * (`"Let's talk"`, a flat filled button) and a CSS-only mobile menu — and a
18
- * footer that becomes a second headline: the site name set at display
19
- * scale, repeated as a closing statement, the `tagline` beneath it, then a
20
- * real row of footer navigation, social icons (`renderSocialLinks`) and the
21
- * `footerNote` beside the branding fragment.
15
+ * The header and the footer (`theme@1.4`), as quiet as a studio's own.
22
16
  *
23
- * The mobile menu is the same checkbox/label trick every L25 theme uses —
24
- * a visually-hidden `<input type="checkbox">` paired with a `<label>`
25
- * styled as a three-bar button; `base.css`'s sibling combinator is what
26
- * reveals the panel when the box is checked, so there is exactly one real
27
- * `<nav>` in the markup (the same one desktop and mobile both use, retiled
28
- * by CSS at the breakpoint), never two copies of the same links.
17
+ * Header, one row on the twelve columns: the studio's name on the left (or
18
+ * its uploaded logo), the `tagline` in the middle columns on a wide screen,
19
+ * the navigation on the right with the `headerAction` as its last link, and
20
+ * the light/dark control. No bar, no button, no sticky shadow. Below the
21
+ * breakpoint the navigation becomes a full-height panel of large links opened
22
+ * by a CSS-only toggle: a visually hidden checkbox paired with a `<label>`,
23
+ * placed before the `<nav>` so the sibling combinator in `base.css` reaches
24
+ * it. There is exactly one `<nav>` in the markup, retiled by CSS.
29
25
  *
30
- * Both fragments are built with the same string-escaping discipline
31
- * `theme-kit`'s own reference chrome uses (`escapeText`/`escapeAttribute`) —
32
- * `headerNav`/`footerNav` arrive pre-resolved and are rendered as given,
33
- * never invented or dropped, and `brandingHtml` is placed, never altered.
34
- * Every `theme@1.4` field (`tagline`/`social`/`footerNote`/`headerAction`)
35
- * is optional and additive: a host or a site that never sets one gets
36
- * exactly the `1.3` markup in that spot, byte for byte (`chrome.test.ts`'s
37
- * "without the new fields" cases).
38
- *
39
- * The manual light/dark/system toggle (`renderThemeToggle`, `@cogenta/
40
- * theme-kit`, L26) is unconditional — every render carries it, `theme@1.4`
41
- * or not — placed as its own sibling after the nav/hamburger pair so it
42
- * stays visible whether or not the mobile menu is open, styled in this
43
- * theme's own hairline-bordered, sharp-cornered register in `base.css`
44
- * rather than canonical's pill button.
26
+ * Footer, a hairline and three columns: the name, the tagline and the
27
+ * `footerNote`; the footer navigation; the social profiles with their names.
28
+ * Before them, the footer widget columns (`theme@1.6`) when a site has placed
29
+ * any: a tier of their own right under the footer's hairline, on the same
30
+ * twelve columns, so an invitation to get in touch opens the footer the way a
31
+ * studio's own does. Then the legal line: the copyright year and the studio's
32
+ * name beside Cogenta's credit (`brandingHtml`, placed once, as received). No
33
+ * column carries a heading of the theme's own: a heading is a word, and a
34
+ * theme has no translation for it (a widget's title is the site's own words).
35
+ * Every `theme@1.4` field renders only when present, so a host that predates
36
+ * them still gets the name and the navigation.
45
37
  */
46
38
 
47
- function renderNavLinks(links: readonly ChromeNavLink[], className: string): string {
48
- if (links.length === 0) return ''
49
- const items = links
39
+ function navItems(links: readonly ChromeNavLink[], className: string): string {
40
+ return links
50
41
  .filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
51
- .map((link, index) => {
42
+ .map((link) => {
52
43
  const label = escapeText(link.label)
53
44
  const titleAttr = link.title === null ? '' : ` title="${escapeAttribute(link.title)}"`
54
- const number = String(index + 1).padStart(2, '0')
55
- if (link.href === null) {
56
- return `<li><span class="cg-nav__index" aria-hidden="true">${number}</span><span${titleAttr}>${label}</span></li>`
57
- }
45
+ if (link.href === null)
46
+ return `<li class="${className}"><span${titleAttr}>${label}</span></li>`
58
47
  const href = escapeAttribute(link.href)
59
48
  const target = link.openInNewTab ? ' target="_blank" rel="noopener"' : ''
60
- return (
61
- `<li><span class="cg-nav__index" aria-hidden="true">${number}</span>` +
62
- `<a href="${href}"${target}${titleAttr}>${label}</a></li>`
63
- )
49
+ return `<li class="${className}"><a href="${href}"${target}${titleAttr}>${label}</a></li>`
64
50
  })
65
51
  .join('')
66
- return items === '' ? '' : `<ul class="${className}">${items}</ul>`
67
- }
68
-
69
- /** The header's own call-to-action link (`theme@1.4`) — a flat filled button, the theme's one accent-coloured control, at the tail of the nav list. */
70
- function renderHeaderAction(action: ChromeInput['headerAction']): string {
71
- if (action === undefined) return ''
72
- return (
73
- `<a class="cg-action cg-site-header__action" data-emphasis="primary" ` +
74
- `href="${escapeAttribute(action.href)}">${escapeText(action.label)}</a>`
75
- )
76
52
  }
77
53
 
78
- /** A hamburger mark drawn from three stacked bars — no icon font, no glyph a font might not ship. */
79
- function menuGlyph(): string {
80
- return '<span class="cg-nav-toggle-bar"></span><span class="cg-nav-toggle-bar"></span><span class="cg-nav-toggle-bar"></span>'
54
+ /** `footerNote` as paragraphs: a blank line starts a new one. */
55
+ function renderFooterNote(note: string): string {
56
+ const paragraphs = note
57
+ .replace(/\r\n?/g, '\n')
58
+ .split(/\n\s*\n/)
59
+ .map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ').trim())
60
+ .filter((paragraph) => paragraph !== '')
61
+ if (paragraphs.length === 0) return ''
62
+ return `<div class="cg-colophon__note">${paragraphs
63
+ .map((paragraph) => `<p>${escapeText(paragraph)}</p>`)
64
+ .join('')}</div>`
81
65
  }
82
66
 
83
67
  export function renderChrome(input: ChromeInput): ChromeResult {
84
68
  const siteName = escapeText(input.site.name)
85
69
  const home = escapeAttribute(input.homeHref)
86
- const headerNav = renderNavLinks(input.headerNav, 'cg-nav')
87
- const footerNav = renderNavLinks(input.footerNav, 'cg-nav cg-nav--footer')
88
- const headerAction = renderHeaderAction(input.headerAction)
89
- const themeToggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
90
- // The asterisk glyph is this theme's own typographic mark. It belongs to
91
- // the *wordmark* treatment, so an uploaded logo replaces both together —
92
- // an asterisk stapled to somebody else's logo is not a design decision
93
- // this theme gets to make on their behalf.
94
- const logo = renderBrandMark(input.brand, { className: 'cg-site-header__logo' })
95
- const mark = logo ?? `<span class="cg-site-header__glyph" aria-hidden="true">*</span>${siteName}`
70
+ const links = navItems(input.headerNav, 'cg-masthead__item')
71
+ const action =
72
+ input.headerAction === undefined
73
+ ? ''
74
+ : `<a class="cg-masthead__action" href="${escapeAttribute(input.headerAction.href)}">${escapeText(
75
+ input.headerAction.label,
76
+ )}</a>`
77
+ const toggle = serialize(renderThemeToggle(input.locale, { className: 'cg-theme-toggle' }))
78
+ const logo = renderBrandMark(input.brand, { className: 'cg-masthead__logo' })
79
+ const mark = logo ?? `<span class="cg-masthead__wordmark">${siteName}</span>`
80
+ const tagline =
81
+ input.tagline === undefined
82
+ ? ''
83
+ : `<p class="cg-masthead__tagline">${escapeText(input.tagline)}</p>`
96
84
 
97
- const hasMenu = headerNav !== '' || headerAction !== ''
98
- const nav = hasMenu
99
- ? `<nav class="cg-site-header__nav" id="cg-nav" aria-label="Primary">${headerNav}${headerAction}</nav>`
100
- : ''
101
- const toggle = hasMenu
85
+ const hasMenu = links !== '' || action !== ''
86
+ const menu = hasMenu
102
87
  ? `<input type="checkbox" id="cg-nav-toggle" class="cg-nav-toggle-input" aria-label="Menu">` +
103
- `<label for="cg-nav-toggle" class="cg-nav-toggle-label" aria-hidden="true">${menuGlyph()}</label>`
88
+ `<label for="cg-nav-toggle" class="cg-nav-toggle-label" aria-hidden="true">` +
89
+ `<span class="cg-nav-toggle-bar"></span><span class="cg-nav-toggle-bar"></span>` +
90
+ `</label>` +
91
+ `<nav class="cg-masthead__nav" id="cg-nav" aria-label="Primary">` +
92
+ `${links === '' ? '' : `<ul class="cg-masthead__links">${links}</ul>`}` +
93
+ `${action}` +
94
+ `</nav>`
104
95
  : ''
105
96
 
106
97
  const header =
107
- `<header class="cg-site-header"><div class="cg-site-header__inner">` +
108
- `<a class="cg-site-header__mark" href="${home}">${mark}</a>` +
109
- `${toggle}${nav}` +
110
- `${themeToggle}` +
98
+ `<header class="cg-masthead" data-nav="${hasMenu ? 'links' : 'none'}">` +
99
+ `<div class="cg-masthead__inner">` +
100
+ `<a class="cg-masthead__home" href="${home}">${mark}</a>` +
101
+ `${tagline}` +
102
+ `${menu}` +
103
+ `${toggle}` +
111
104
  `</div></header>`
112
105
 
113
- const tagline =
106
+ const footerTagline =
114
107
  input.tagline === undefined
115
108
  ? ''
116
- : `<p class="cg-site-footer__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
117
- const social =
118
- input.social === undefined
119
- ? ''
120
- : serialize(
121
- renderSocialLinks(input.social, {
122
- className: 'cg-site-footer__social',
123
- itemClassName: 'cg-site-footer__social-item',
124
- }) ?? { kind: 'text', value: '' },
125
- )
126
- const footerNote =
127
- input.footerNote === undefined
128
- ? ''
129
- : `<p class="cg-site-footer__note">${escapeText(input.footerNote)}</p>`
109
+ : `<p class="cg-colophon__tagline" data-field="tagline">${escapeText(input.tagline)}</p>`
110
+ const note = input.footerNote === undefined ? '' : renderFooterNote(input.footerNote)
111
+ const footerLinks = navItems(input.footerNav, 'cg-colophon__item')
112
+ const social = renderSocialLinks(input.social, {
113
+ className: 'cg-colophon__social',
114
+ itemClassName: 'cg-colophon__social-item',
115
+ })
116
+ const year = new Date().getFullYear()
117
+ // The footer widget columns (`theme@1.6`): a tier above the name, the pages
118
+ // and the profiles, under the footer's hairline, on the footer's own grid.
119
+ const footerWidgets = renderFooterWidgets(input.widgets, {
120
+ className: 'cg-colophon__widgets',
121
+ headingLevel: 'h2',
122
+ })
130
123
 
131
124
  const footer =
132
- `<footer class="cg-site-footer"><div class="cg-site-footer__inner">` +
133
- `<a class="cg-site-footer__statement" href="${home}">${siteName}</a>` +
134
- `${tagline}` +
135
- `<div class="cg-site-footer__row">` +
136
- `${footerNav === '' ? '' : `<nav aria-label="Footer">${footerNav}</nav>`}` +
137
- `${social === '' ? '' : `<div class="cg-site-footer__social-col">${social}</div>`}` +
138
- `<div class="cg-site-footer__meta">${footerNote}<div class="cg-site-footer__branding">${input.brandingHtml}</div></div>` +
139
- `</div></div></footer>`
125
+ `<footer class="cg-colophon"><div class="cg-colophon__inner">` +
126
+ `${footerWidgets === null ? '' : serialize(footerWidgets)}` +
127
+ `<div class="cg-colophon__about">` +
128
+ `<a class="cg-colophon__name" href="${home}">${siteName}</a>` +
129
+ `${footerTagline}${note}` +
130
+ `</div>` +
131
+ `${
132
+ footerLinks === ''
133
+ ? ''
134
+ : `<nav class="cg-colophon__nav" aria-label="Footer"><ul class="cg-colophon__links">${footerLinks}</ul></nav>`
135
+ }` +
136
+ `${social === null ? '' : `<div class="cg-colophon__follow">${serialize(social)}</div>`}` +
137
+ `<div class="cg-colophon__legal">` +
138
+ `<p class="cg-colophon__copyright">© ${year} ${siteName}</p>` +
139
+ `<div class="cg-colophon__branding">${input.brandingHtml}</div>` +
140
+ `</div>` +
141
+ `</div></footer>`
140
142
 
141
143
  return { header, footer }
142
144
  }
@@ -0,0 +1,177 @@
1
+ import {
2
+ type Attributes,
3
+ blockHeadingTag,
4
+ type Child,
5
+ type ContentEntry,
6
+ entryDate,
7
+ type HtmlElement,
8
+ h,
9
+ heading,
10
+ } from '@cogenta/theme-kit'
11
+
12
+ /**
13
+ * The frame every block of this theme renders into, and the small pieces of
14
+ * typography the blocks share.
15
+ *
16
+ * The outer element carries the one section rhythm (`--cg-section`, the same
17
+ * for every block) and paints edge to edge when a variant asks for a band.
18
+ * The inner element is the twelve-column container, so the left edge of a
19
+ * statement, a project image, a caption and a list of services is always the
20
+ * same line.
21
+ *
22
+ * `inner` is `figure` for a block whose semantics are a figure (a quotation
23
+ * and its attribution, an image and its caption): `<figcaption>` must be a
24
+ * direct child of its `<figure>`.
25
+ */
26
+ export function section(
27
+ outer: 'section' | 'div',
28
+ block: string,
29
+ className: string,
30
+ attrs: Attributes,
31
+ inner: 'div' | 'figure',
32
+ ...children: readonly Child[]
33
+ ): HtmlElement {
34
+ return h(
35
+ outer,
36
+ { class: `cg-section ${className}`, 'data-block': block, ...attrs },
37
+ h(inner, { class: `cg-container ${className}__inner` }, ...children),
38
+ )
39
+ }
40
+
41
+ /**
42
+ * The label of a titled block: a hairline across the whole container and the
43
+ * title under it, small, in the text face. A studio lets the work carry the
44
+ * page, so a section says what it is and gets out of the way. On a wide
45
+ * screen the label sits in the first three columns and the content starts at
46
+ * the fourth (`.cg-split`), or it runs above a full-width grid.
47
+ */
48
+ export function sectionHead(blockName: string, title: string | undefined): HtmlElement | null {
49
+ if (title === undefined) return null
50
+ return h(
51
+ 'div',
52
+ { class: 'cg-head' },
53
+ heading(
54
+ blockHeadingTag(blockName) ?? 'h2',
55
+ { class: 'cg-head__title', 'data-field': 'title' },
56
+ title,
57
+ ),
58
+ )
59
+ }
60
+
61
+ /**
62
+ * A taxonomy field stores a term id, never a label: a value shaped like one
63
+ * is not something a visitor can be shown.
64
+ */
65
+ const TERM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
66
+
67
+ function plainText(entry: ContentEntry, fields: readonly string[]): string | undefined {
68
+ for (const field of fields) {
69
+ const value = entry[field]
70
+ if (typeof value === 'string' && value.trim() !== '' && !TERM_ID.test(value.trim())) {
71
+ return value.trim()
72
+ }
73
+ }
74
+ return undefined
75
+ }
76
+
77
+ const CLIENT_FIELDS = ['client', 'clientName', 'customer', 'organisation', 'organization'] as const
78
+ const DISCIPLINE_FIELDS = ['discipline', 'role', 'service', 'category', 'kicker'] as const
79
+
80
+ /** A four-digit year, from a `year` field when the collection keeps one, or from the entry's date. */
81
+ export function entryYear(entry: ContentEntry): string | undefined {
82
+ const declared = entry.year
83
+ if (typeof declared === 'string' && /^\d{4}$/.test(declared.trim())) return declared.trim()
84
+ if (typeof declared === 'number' && Number.isInteger(declared)) return String(declared)
85
+ const iso = entryDate(entry)
86
+ return iso === undefined ? undefined : yearOf(iso)
87
+ }
88
+
89
+ /** `2025` from an ISO 8601 date, or `undefined` when the value is not one. */
90
+ export function yearOf(iso: string): string | undefined {
91
+ const date = new Date(iso)
92
+ return Number.isNaN(date.getTime()) ? undefined : String(date.getUTCFullYear())
93
+ }
94
+
95
+ /**
96
+ * The caption line a studio sets under a piece of work: client, discipline,
97
+ * year, in that order, each only when the entry carries it. Read from the
98
+ * plain-text fields a collection declares, never from a taxonomy id and never
99
+ * invented.
100
+ */
101
+ export interface WorkCaption {
102
+ readonly client?: string
103
+ readonly discipline?: string
104
+ readonly year?: string
105
+ }
106
+
107
+ export function entryCaption(entry: ContentEntry): WorkCaption {
108
+ const client = plainText(entry, CLIENT_FIELDS)
109
+ const discipline = plainText(entry, DISCIPLINE_FIELDS)
110
+ const year = entryYear(entry)
111
+ return {
112
+ ...(client === undefined ? {} : { client }),
113
+ ...(discipline === undefined ? {} : { discipline }),
114
+ ...(year === undefined ? {} : { year }),
115
+ }
116
+ }
117
+
118
+ /**
119
+ * A caption never repeats the title it sits under: a project named after its
120
+ * client ("Fenmore Building Society") keeps the discipline and the year only.
121
+ * The index, where the client has a column of its own, keeps it.
122
+ */
123
+ export function captionUnder(caption: WorkCaption, title: string): WorkCaption {
124
+ if (caption.client === undefined) return caption
125
+ if (!title.toLocaleLowerCase().includes(caption.client.toLocaleLowerCase())) return caption
126
+ const { client: _repeated, ...rest } = caption
127
+ return rest
128
+ }
129
+
130
+ /** The caption as markup: one paragraph, one span per part, the separators drawn by the stylesheet. */
131
+ export function renderCaption(caption: WorkCaption, className: string): HtmlElement | null {
132
+ const parts = [
133
+ caption.client === undefined
134
+ ? null
135
+ : h('span', { class: `${className}-part`, 'data-part': 'client' }, caption.client),
136
+ caption.discipline === undefined
137
+ ? null
138
+ : h('span', { class: `${className}-part`, 'data-part': 'discipline' }, caption.discipline),
139
+ caption.year === undefined
140
+ ? null
141
+ : h('span', { class: `${className}-part`, 'data-part': 'year' }, caption.year),
142
+ ].filter((part): part is HtmlElement => part !== null)
143
+ if (parts.length === 0) return null
144
+ return h('p', { class: className }, parts)
145
+ }
146
+
147
+ /**
148
+ * A taxonomy's own name, as a label: `disciplines` becomes `Disciplines`,
149
+ * `project_team` becomes `Project team`. The name is the one the site owner
150
+ * gave the taxonomy; this only sets it in sentence case.
151
+ */
152
+ export function taxonomyLabel(name: string, locale: string): string {
153
+ const words = name
154
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
155
+ .replace(/[_-]+/g, ' ')
156
+ .trim()
157
+ .toLowerCase()
158
+ if (words === '') return name
159
+ return words.charAt(0).toLocaleUpperCase(locale) + words.slice(1)
160
+ }
161
+
162
+ /**
163
+ * The word for "year" in the page's own language, from the platform's own
164
+ * data (`Intl.DisplayNames`), so the fact sheet of a project page labels its
165
+ * date without a translation table this theme would have to carry.
166
+ */
167
+ export function yearLabel(locale: string): string {
168
+ try {
169
+ const name = new Intl.DisplayNames([locale], { type: 'dateTimeField' }).of('year')
170
+ if (name !== undefined && name !== '') {
171
+ return name.charAt(0).toLocaleUpperCase(locale) + name.slice(1)
172
+ }
173
+ } catch {
174
+ // An invalid locale tag: fall through to the English field name.
175
+ }
176
+ return 'Year'
177
+ }