@cogenta/theme-portfolio 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +373 -0
  2. package/dist/src/index.d.ts +17 -0
  3. package/dist/src/index.d.ts.map +1 -0
  4. package/dist/src/index.js +20 -0
  5. package/dist/src/index.js.map +1 -0
  6. package/dist/src/render/blocks/accordion.d.ts +4 -0
  7. package/dist/src/render/blocks/accordion.d.ts.map +1 -0
  8. package/dist/src/render/blocks/accordion.js +24 -0
  9. package/dist/src/render/blocks/accordion.js.map +1 -0
  10. package/dist/src/render/blocks/collection-list.d.ts +25 -0
  11. package/dist/src/render/blocks/collection-list.d.ts.map +1 -0
  12. package/dist/src/render/blocks/collection-list.js +77 -0
  13. package/dist/src/render/blocks/collection-list.js.map +1 -0
  14. package/dist/src/render/blocks/cta.d.ts +10 -0
  15. package/dist/src/render/blocks/cta.d.ts.map +1 -0
  16. package/dist/src/render/blocks/cta.js +16 -0
  17. package/dist/src/render/blocks/cta.js.map +1 -0
  18. package/dist/src/render/blocks/embed.d.ts +4 -0
  19. package/dist/src/render/blocks/embed.d.ts.map +1 -0
  20. package/dist/src/render/blocks/embed.js +88 -0
  21. package/dist/src/render/blocks/embed.js.map +1 -0
  22. package/dist/src/render/blocks/faq.d.ts +4 -0
  23. package/dist/src/render/blocks/faq.d.ts.map +1 -0
  24. package/dist/src/render/blocks/faq.js +23 -0
  25. package/dist/src/render/blocks/faq.js.map +1 -0
  26. package/dist/src/render/blocks/feature-grid.d.ts +4 -0
  27. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -0
  28. package/dist/src/render/blocks/feature-grid.js +30 -0
  29. package/dist/src/render/blocks/feature-grid.js.map +1 -0
  30. package/dist/src/render/blocks/gallery.d.ts +18 -0
  31. package/dist/src/render/blocks/gallery.d.ts.map +1 -0
  32. package/dist/src/render/blocks/gallery.js +34 -0
  33. package/dist/src/render/blocks/gallery.js.map +1 -0
  34. package/dist/src/render/blocks/hero.d.ts +15 -0
  35. package/dist/src/render/blocks/hero.d.ts.map +1 -0
  36. package/dist/src/render/blocks/hero.js +31 -0
  37. package/dist/src/render/blocks/hero.js.map +1 -0
  38. package/dist/src/render/blocks/logo-strip.d.ts +4 -0
  39. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -0
  40. package/dist/src/render/blocks/logo-strip.js +23 -0
  41. package/dist/src/render/blocks/logo-strip.js.map +1 -0
  42. package/dist/src/render/blocks/logos.d.ts +4 -0
  43. package/dist/src/render/blocks/logos.d.ts.map +1 -0
  44. package/dist/src/render/blocks/logos.js +28 -0
  45. package/dist/src/render/blocks/logos.js.map +1 -0
  46. package/dist/src/render/blocks/media-figure.d.ts +19 -0
  47. package/dist/src/render/blocks/media-figure.d.ts.map +1 -0
  48. package/dist/src/render/blocks/media-figure.js +36 -0
  49. package/dist/src/render/blocks/media-figure.js.map +1 -0
  50. package/dist/src/render/blocks/pricing-table.d.ts +4 -0
  51. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -0
  52. package/dist/src/render/blocks/pricing-table.js +29 -0
  53. package/dist/src/render/blocks/pricing-table.js.map +1 -0
  54. package/dist/src/render/blocks/prose.d.ts +16 -0
  55. package/dist/src/render/blocks/prose.d.ts.map +1 -0
  56. package/dist/src/render/blocks/prose.js +17 -0
  57. package/dist/src/render/blocks/prose.js.map +1 -0
  58. package/dist/src/render/blocks/quote.d.ts +18 -0
  59. package/dist/src/render/blocks/quote.d.ts.map +1 -0
  60. package/dist/src/render/blocks/quote.js +31 -0
  61. package/dist/src/render/blocks/quote.js.map +1 -0
  62. package/dist/src/render/blocks/stat-counter.d.ts +4 -0
  63. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -0
  64. package/dist/src/render/blocks/stat-counter.js +18 -0
  65. package/dist/src/render/blocks/stat-counter.js.map +1 -0
  66. package/dist/src/render/blocks/stats.d.ts +4 -0
  67. package/dist/src/render/blocks/stats.d.ts.map +1 -0
  68. package/dist/src/render/blocks/stats.js +22 -0
  69. package/dist/src/render/blocks/stats.js.map +1 -0
  70. package/dist/src/render/blocks/testimonial.d.ts +20 -0
  71. package/dist/src/render/blocks/testimonial.d.ts.map +1 -0
  72. package/dist/src/render/blocks/testimonial.js +29 -0
  73. package/dist/src/render/blocks/testimonial.js.map +1 -0
  74. package/dist/src/render/chrome.d.ts +3 -0
  75. package/dist/src/render/chrome.d.ts.map +1 -0
  76. package/dist/src/render/chrome.js +114 -0
  77. package/dist/src/render/chrome.js.map +1 -0
  78. package/dist/src/render/render-block.d.ts +12 -0
  79. package/dist/src/render/render-block.d.ts.map +1 -0
  80. package/dist/src/render/render-block.js +111 -0
  81. package/dist/src/render/render-block.js.map +1 -0
  82. package/dist/src/render/term-archive.d.ts +14 -0
  83. package/dist/src/render/term-archive.d.ts.map +1 -0
  84. package/dist/src/render/term-archive.js +36 -0
  85. package/dist/src/render/term-archive.js.map +1 -0
  86. package/dist/theme.config.d.ts +26 -0
  87. package/dist/theme.config.d.ts.map +1 -0
  88. package/dist/theme.config.js +56 -0
  89. package/dist/theme.config.js.map +1 -0
  90. package/package.json +57 -0
  91. package/src/index.ts +19 -0
  92. package/src/render/blocks/accordion.ts +64 -0
  93. package/src/render/blocks/collection-list.ts +181 -0
  94. package/src/render/blocks/cta.ts +37 -0
  95. package/src/render/blocks/embed.ts +111 -0
  96. package/src/render/blocks/faq.ts +54 -0
  97. package/src/render/blocks/feature-grid.ts +68 -0
  98. package/src/render/blocks/gallery.ts +55 -0
  99. package/src/render/blocks/hero.ts +55 -0
  100. package/src/render/blocks/logo-strip.ts +38 -0
  101. package/src/render/blocks/logos.ts +63 -0
  102. package/src/render/blocks/media-figure.ts +51 -0
  103. package/src/render/blocks/pricing-table.ts +69 -0
  104. package/src/render/blocks/prose.ts +22 -0
  105. package/src/render/blocks/quote.ts +48 -0
  106. package/src/render/blocks/stat-counter.ts +49 -0
  107. package/src/render/blocks/stats.ts +49 -0
  108. package/src/render/blocks/testimonial.ts +45 -0
  109. package/src/render/chrome.ts +142 -0
  110. package/src/render/render-block.ts +155 -0
  111. package/src/render/term-archive.ts +88 -0
  112. package/src/styles/archive.css +40 -0
  113. package/src/styles/base.css +1056 -0
  114. package/src/styles/blocks.css +1468 -0
  115. package/src/styles/theme.css +20 -0
  116. package/src/styles/tokens.css +363 -0
  117. package/tokens.json +36 -0
@@ -0,0 +1,63 @@
1
+ import type { LogoItem, LogosBlock } from '@cogenta/blocks'
2
+ import {
3
+ blockHeadingTag,
4
+ type HtmlElement,
5
+ h,
6
+ heading,
7
+ image,
8
+ type RenderContext,
9
+ } from '@cogenta/theme-kit'
10
+
11
+ /**
12
+ * Contract B says the organisation's name **is** the accessible name of the
13
+ * link. So the logo image carries it as alt text when the media entity has
14
+ * none, and no visually hidden duplicate is added: a link whose only content
15
+ * is an image with alt text is already named.
16
+ *
17
+ * Rendered as a single-row ledger rather than a grid of tiles — a running
18
+ * mono index beside each mark, in a desaturated register that resolves to
19
+ * full ink only on hover or focus (`blocks.css`), which is the theme's own
20
+ * take on the "selected clients" strip.
21
+ */
22
+ function renderItem(item: LogoItem, ctx: RenderContext, index: number): HtmlElement {
23
+ const logo = image(ctx, item.media, {
24
+ className: 'cg-logo__image',
25
+ altFrom: item.name,
26
+ variant: { fit: 'contain' },
27
+ })
28
+ return h(
29
+ 'li',
30
+ { class: 'cg-logo' },
31
+ h(
32
+ 'span',
33
+ { class: 'cg-logo__index', 'aria-hidden': 'true' },
34
+ String(index + 1).padStart(2, '0'),
35
+ ),
36
+ item.url === undefined
37
+ ? logo
38
+ : h(
39
+ 'a',
40
+ { class: 'cg-logo__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
41
+ logo,
42
+ ),
43
+ )
44
+ }
45
+
46
+ export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
47
+ return h(
48
+ 'section',
49
+ { class: 'cg-block cg-logos', 'data-block': 'logos' },
50
+ block.title === undefined
51
+ ? null
52
+ : heading(
53
+ blockHeadingTag('logos') ?? 'h2',
54
+ { class: 'cg-logos__title', 'data-field': 'title' },
55
+ block.title,
56
+ ),
57
+ h(
58
+ 'ul',
59
+ { class: 'cg-logos__items' },
60
+ block.items.map((item, index) => renderItem(item, ctx, index)),
61
+ ),
62
+ )
63
+ }
@@ -0,0 +1,51 @@
1
+ import type { MediaFigureBlock } from '@cogenta/blocks'
2
+ import { aspectRatio, type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * `<figure>`/`<figcaption>` rather than a div and a paragraph: the
6
+ * association between the picture and its caption is then in the markup,
7
+ * and a screen reader announces the caption as belonging to the image
8
+ * instead of as loose text after it.
9
+ *
10
+ * The "Fig. 01" plate number that precedes the caption is drawn entirely
11
+ * from a CSS counter (`counter-increment: cg-figure` in `blocks.css`) — it
12
+ * is not stored data, so it renumbers itself as figures are added or
13
+ * removed and never needs updating here.
14
+ *
15
+ * `align` is written as a data attribute, never as a class: contract B's
16
+ * values are `start`/`end`, an intent that mirrors in right-to-left
17
+ * locales, and the skin decides what it means.
18
+ */
19
+ export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext): HtmlElement {
20
+ const ratio = aspectRatio(block.ratio)
21
+ const hasCaption = block.caption !== undefined || block.credit !== undefined
22
+ return h(
23
+ 'figure',
24
+ {
25
+ class: 'cg-block cg-figure',
26
+ 'data-block': 'mediaFigure',
27
+ 'data-align': block.align ?? 'center',
28
+ style: ratio === undefined ? undefined : `--cg-ratio:${ratio}`,
29
+ },
30
+ h(
31
+ 'div',
32
+ { class: 'cg-figure__frame' },
33
+ image(ctx, block.media, {
34
+ className: 'cg-figure__media',
35
+ sizes: '(min-width: 48rem) 42rem, 100vw',
36
+ }),
37
+ ),
38
+ hasCaption
39
+ ? h(
40
+ 'figcaption',
41
+ { class: 'cg-figure__caption' },
42
+ block.caption === undefined
43
+ ? null
44
+ : h('span', { class: 'cg-figure__plate' }, block.caption),
45
+ block.credit === undefined
46
+ ? null
47
+ : h('span', { class: 'cg-figure__credit', 'data-field': 'credit' }, block.credit),
48
+ )
49
+ : null,
50
+ )
51
+ }
@@ -0,0 +1,69 @@
1
+ import type { PricingTableBlock, PricingTier } from '@cogenta/blocks'
2
+ import {
3
+ actionLink,
4
+ blockHeadingTag,
5
+ type HeadingTag,
6
+ type HtmlElement,
7
+ h,
8
+ heading,
9
+ nestedHeadingTag,
10
+ type RenderContext,
11
+ } from '@cogenta/theme-kit'
12
+
13
+ /**
14
+ * `highlighted` is an editorial signal, never a colour (RFC 0001's own
15
+ * comment on the field): it is written as `data-highlighted="true"` for the
16
+ * skin to key off, and the tier additionally carries `aria-current="true"`
17
+ * — the closest native semantic for "the one the author means you to pick"
18
+ * among a set of siblings.
19
+ */
20
+ function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
21
+ return h(
22
+ 'li',
23
+ {
24
+ class: 'cg-pricing__tier',
25
+ 'data-highlighted': tier.highlighted === true ? 'true' : undefined,
26
+ 'aria-current': tier.highlighted === true ? 'true' : undefined,
27
+ },
28
+ heading(tag, { class: 'cg-pricing__name' }, tier.name),
29
+ h(
30
+ 'p',
31
+ { class: 'cg-pricing__price' },
32
+ tier.price,
33
+ tier.interval === undefined
34
+ ? null
35
+ : h('span', { class: 'cg-pricing__interval' }, tier.interval),
36
+ ),
37
+ tier.features.length === 0
38
+ ? null
39
+ : h(
40
+ 'ul',
41
+ { class: 'cg-pricing__features' },
42
+ tier.features.map((feature) => h('li', {}, feature)),
43
+ ),
44
+ tier.action === undefined
45
+ ? null
46
+ : h('div', { class: 'cg-pricing__action' }, actionLink(ctx, tier.action)),
47
+ )
48
+ }
49
+
50
+ export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
51
+ const hasTitle = block.title !== undefined
52
+ const tierTag = nestedHeadingTag('pricingTable', hasTitle)
53
+ return h(
54
+ 'section',
55
+ { class: 'cg-block cg-pricing', 'data-block': 'pricingTable' },
56
+ hasTitle
57
+ ? heading(
58
+ blockHeadingTag('pricingTable') ?? 'h2',
59
+ { class: 'cg-pricing__title', 'data-field': 'title' },
60
+ block.title ?? '',
61
+ )
62
+ : null,
63
+ h(
64
+ 'ul',
65
+ { class: 'cg-pricing__tiers' },
66
+ block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
67
+ ),
68
+ )
69
+ }
@@ -0,0 +1,22 @@
1
+ import type { ProseBlock } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, type RenderContext, renderRichText } from '@cogenta/theme-kit'
3
+
4
+ /**
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`.
8
+ *
9
+ * The wrapper is deliberately plain — a rich text document is arbitrary
10
+ * editorial content, and the only honest DOM shape for arbitrary content is
11
+ * "here it is". The distinctive editorial treatment (a dropped first
12
+ * capital, an oversized `h2`, a hairline-and-number rule before each
13
+ * heading) lives entirely in `blocks.css`, driven off the very structure
14
+ * `renderRichText` already produces.
15
+ */
16
+ export function renderProse(block: ProseBlock, ctx: RenderContext): HtmlElement {
17
+ return h(
18
+ 'div',
19
+ { class: 'cg-block cg-prose', 'data-block': 'prose' },
20
+ renderRichText(ctx, block.body),
21
+ )
22
+ }
@@ -0,0 +1,48 @@
1
+ import type { QuoteBlock } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+
4
+ /**
5
+ * `<figure><blockquote>…</blockquote><figcaption>` is the attribution
6
+ * pattern the HTML spec prescribes: putting the author inside the
7
+ * `<blockquote>` would claim the author's name is part of what was said.
8
+ *
9
+ * Set left-aligned at display scale rather than centred — a pull quote that
10
+ * fills the column edge to edge, the way a magazine breaks one out of a
11
+ * feature. The attribution is set in the mono register, the theme's
12
+ * register for credits and metadata.
13
+ *
14
+ * The avatar is decorative here — the name is right beside it in text — so
15
+ * its media entity's alt text is expected to be empty. `image` still writes
16
+ * the attribute either way.
17
+ */
18
+ export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement {
19
+ const hasAttribution =
20
+ block.author !== undefined || block.role !== undefined || block.avatar !== undefined
21
+ return h(
22
+ 'figure',
23
+ { class: 'cg-block cg-quote', 'data-block': 'quote' },
24
+ h('blockquote', { class: 'cg-quote__text' }, h('p', { 'data-field': 'text' }, block.text)),
25
+ hasAttribution
26
+ ? h(
27
+ 'figcaption',
28
+ { class: 'cg-quote__attribution' },
29
+ block.avatar === undefined
30
+ ? null
31
+ : image(ctx, block.avatar, {
32
+ className: 'cg-quote__avatar',
33
+ variant: { width: 96, height: 96, fit: 'cover' },
34
+ }),
35
+ h(
36
+ 'span',
37
+ { class: 'cg-quote__names' },
38
+ block.author === undefined
39
+ ? null
40
+ : h('span', { class: 'cg-quote__author', 'data-field': 'author' }, block.author),
41
+ block.role === undefined
42
+ ? null
43
+ : h('span', { class: 'cg-quote__role', 'data-field': 'role' }, block.role),
44
+ ),
45
+ )
46
+ : null,
47
+ )
48
+ }
@@ -0,0 +1,49 @@
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'
9
+
10
+ /**
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.
17
+ */
18
+ function renderItem(item: StatCounterItem, index: number): HtmlElement {
19
+ 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),
29
+ )
30
+ }
31
+
32
+ export function renderStatCounter(block: StatCounterBlock, _ctx: RenderContext): HtmlElement {
33
+ return h(
34
+ '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
+ ),
48
+ )
49
+ }
@@ -0,0 +1,49 @@
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'
9
+
10
+ /**
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.
21
+ */
22
+ function renderItem(item: StatItem): HtmlElement {
23
+ return h(
24
+ 'div',
25
+ { class: 'cg-stat' },
26
+ h('dt', { class: 'cg-stat__label' }, item.label),
27
+ h(
28
+ 'dd',
29
+ { class: 'cg-stat__value' },
30
+ item.value,
31
+ item.unit === undefined ? null : h('span', { class: 'cg-stat__unit' }, item.unit),
32
+ ),
33
+ )
34
+ }
35
+
36
+ export function renderStats(block: StatsBlock, _ctx: RenderContext): HtmlElement {
37
+ return h(
38
+ '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)),
48
+ )
49
+ }
@@ -0,0 +1,45 @@
1
+ import type { TestimonialBlock } from '@cogenta/blocks'
2
+ import { type HtmlElement, h, image, type RenderContext, renderRichText } from '@cogenta/theme-kit'
3
+
4
+ /**
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.
19
+ */
20
+ export function renderTestimonial(block: TestimonialBlock, ctx: RenderContext): HtmlElement {
21
+ const { attribution } = block
22
+ return h(
23
+ 'figure',
24
+ { class: 'cg-block cg-testimonial', 'data-block': 'testimonial' },
25
+ h('blockquote', { class: 'cg-testimonial__quote' }, renderRichText(ctx, block.quote)),
26
+ h(
27
+ 'figcaption',
28
+ { class: 'cg-testimonial__attribution' },
29
+ attribution.avatar === undefined
30
+ ? null
31
+ : image(ctx, attribution.avatar, {
32
+ className: 'cg-testimonial__avatar',
33
+ variant: { width: 96, height: 96, fit: 'cover' },
34
+ }),
35
+ h(
36
+ 'span',
37
+ { class: 'cg-testimonial__names' },
38
+ h('span', { class: 'cg-testimonial__name' }, attribution.name),
39
+ attribution.role === undefined
40
+ ? null
41
+ : h('span', { class: 'cg-testimonial__role' }, attribution.role),
42
+ ),
43
+ ),
44
+ )
45
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ type ChromeInput,
3
+ type ChromeNavLink,
4
+ type ChromeResult,
5
+ escapeAttribute,
6
+ escapeText,
7
+ renderBrandMark,
8
+ renderSocialLinks,
9
+ renderThemeToggle,
10
+ serialize,
11
+ } from '@cogenta/theme-kit'
12
+
13
+ /**
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.
22
+ *
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.
29
+ *
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.
45
+ */
46
+
47
+ function renderNavLinks(links: readonly ChromeNavLink[], className: string): string {
48
+ if (links.length === 0) return ''
49
+ const items = links
50
+ .filter((link) => link.href !== null || link.kind === 'submenu-placeholder')
51
+ .map((link, index) => {
52
+ const label = escapeText(link.label)
53
+ 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
+ }
58
+ const href = escapeAttribute(link.href)
59
+ 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
+ )
64
+ })
65
+ .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
+ }
77
+
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>'
81
+ }
82
+
83
+ export function renderChrome(input: ChromeInput): ChromeResult {
84
+ const siteName = escapeText(input.site.name)
85
+ 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}`
96
+
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
102
+ ? `<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>`
104
+ : ''
105
+
106
+ 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}` +
111
+ `</div></header>`
112
+
113
+ const tagline =
114
+ input.tagline === undefined
115
+ ? ''
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>`
130
+
131
+ 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>`
140
+
141
+ return { header, footer }
142
+ }