@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
@@ -0,0 +1,140 @@
1
+ import {
2
+ type HtmlElement,
3
+ h,
4
+ type PageContent,
5
+ type PageEntryMeta,
6
+ type PageEntryTerm,
7
+ renderImageSource,
8
+ } from '@cogenta/theme-kit'
9
+ import { taxonomyLabel, yearLabel, yearOf } from './layout.js'
10
+
11
+ /**
12
+ * The page of one project, and the plain page title everything else gets.
13
+ *
14
+ * A project page opens the way a studio presents a case study:
15
+ *
16
+ * 1. the title, very large, across the grid;
17
+ * 2. the statement (the entry's excerpt) in the lead size, across eight
18
+ * columns;
19
+ * 3. the lead visual, the project's own cover, full width at its own shape;
20
+ * 4. the fact sheet: one column per fact, a hairline over the row. The year
21
+ * comes from the entry's publication date, labelled with the platform's
22
+ * own word for "year" in the page's language; every other column is one
23
+ * taxonomy the collection declares (a client, the disciplines, the team),
24
+ * labelled with the taxonomy's own name and listing its terms, each linked
25
+ * to its archive.
26
+ *
27
+ * The body that follows is the editor's own blocks. On a project page a
28
+ * `prose` block sets its text in two columns on a wide screen (`work.css`,
29
+ * keyed on the `cg-project` class of `<main>`).
30
+ */
31
+
32
+ /** What makes a page a project: it has an entry with a cover image and is not a plain page. */
33
+ export function isProject(entry: PageEntryMeta | undefined): entry is PageEntryMeta {
34
+ return entry !== undefined && entry.image !== undefined && entry.collection !== 'page'
35
+ }
36
+
37
+ interface Fact {
38
+ readonly key: string
39
+ readonly label: string
40
+ readonly values: readonly HtmlElement[]
41
+ }
42
+
43
+ function termValue(term: PageEntryTerm): HtmlElement {
44
+ return h(
45
+ 'li',
46
+ { class: 'cg-facts__value' },
47
+ term.href === null
48
+ ? term.label
49
+ : h('a', { class: 'cg-facts__link', href: term.href }, term.label),
50
+ )
51
+ }
52
+
53
+ function facts(entry: PageEntryMeta, locale: string): readonly Fact[] {
54
+ const out: Fact[] = []
55
+ const iso = entry.publishedAt
56
+ const year = iso === undefined ? undefined : yearOf(iso)
57
+ if (iso !== undefined && year !== undefined) {
58
+ out.push({
59
+ key: 'year',
60
+ label: yearLabel(locale),
61
+ values: [h('li', { class: 'cg-facts__value' }, h('time', { datetime: iso }, year))],
62
+ })
63
+ }
64
+ const groups = new Map<string, PageEntryTerm[]>()
65
+ for (const term of entry.terms ?? []) {
66
+ const group = groups.get(term.taxonomy)
67
+ if (group === undefined) groups.set(term.taxonomy, [term])
68
+ else group.push(term)
69
+ }
70
+ for (const [taxonomy, terms] of groups) {
71
+ out.push({
72
+ key: taxonomy,
73
+ label: taxonomyLabel(taxonomy, locale),
74
+ values: terms.map(termValue),
75
+ })
76
+ }
77
+ return out
78
+ }
79
+
80
+ function renderFacts(entry: PageEntryMeta, locale: string): HtmlElement | null {
81
+ const list = facts(entry, locale)
82
+ if (list.length === 0) return null
83
+ return h(
84
+ 'dl',
85
+ { class: 'cg-facts', 'data-count': String(Math.min(list.length, 4)) },
86
+ list.map((fact) =>
87
+ h(
88
+ 'div',
89
+ { class: 'cg-facts__item', 'data-fact': fact.key },
90
+ h('dt', { class: 'cg-facts__label' }, fact.label),
91
+ h('dd', { class: 'cg-facts__values' }, h('ul', { class: 'cg-facts__list' }, fact.values)),
92
+ ),
93
+ ),
94
+ )
95
+ }
96
+
97
+ export function renderProjectHeader(
98
+ page: PageContent,
99
+ entry: PageEntryMeta,
100
+ locale: string,
101
+ ): HtmlElement {
102
+ const image = entry.image
103
+ return h(
104
+ 'header',
105
+ { class: 'cg-project-head' },
106
+ h(
107
+ 'div',
108
+ { class: 'cg-container cg-project-head__inner' },
109
+ h('h1', { class: 'cg-project-head__title' }, page.title),
110
+ entry.excerpt === undefined
111
+ ? null
112
+ : h('p', { class: 'cg-project-head__statement' }, entry.excerpt),
113
+ image === undefined
114
+ ? null
115
+ : h(
116
+ 'figure',
117
+ { class: 'cg-project-head__lead' },
118
+ renderImageSource(image, {
119
+ className: 'cg-project-head__image',
120
+ loading: 'eager',
121
+ sizes: '(min-width: 96rem) 92rem, 100vw',
122
+ }),
123
+ ),
124
+ renderFacts(entry, locale),
125
+ ),
126
+ )
127
+ }
128
+
129
+ /** The title of any other page: set large on the grid, with nothing around it. */
130
+ export function renderPageHeader(page: PageContent): HtmlElement {
131
+ return h(
132
+ 'header',
133
+ { class: 'cg-page-head' },
134
+ h(
135
+ 'div',
136
+ { class: 'cg-container cg-page-head__inner' },
137
+ h('h1', { class: 'cg-page-head__title' }, page.title),
138
+ ),
139
+ )
140
+ }
@@ -5,8 +5,8 @@ import {
5
5
  h,
6
6
  type PageContent,
7
7
  pageHasOwnHeading,
8
+ providedBlockNode,
8
9
  type RenderContext,
9
- renderEntryHeader,
10
10
  resolveBlockForRender,
11
11
  withBlockKey,
12
12
  withBlockVariant,
@@ -28,14 +28,10 @@ import { renderQuote } from './blocks/quote.js'
28
28
  import { renderStatCounter } from './blocks/stat-counter.js'
29
29
  import { renderStats } from './blocks/stats.js'
30
30
  import { renderTestimonial } from './blocks/testimonial.js'
31
+ import { isProject, renderPageHeader, renderProjectHeader } from './project.js'
31
32
 
32
- /**
33
- * `PageContent`/`FetchedEntries`/`pageHasOwnHeading`/`withBlockKey` are
34
- * `@cogenta/theme-kit`'s own — re-exported here so this package's public
35
- * surface names them, the same shape every theme package shares them under.
36
- */
37
33
  export type { FetchedEntries, PageContent }
38
- export { pageHasOwnHeading, renderEntryHeader, withBlockKey }
34
+ export { pageHasOwnHeading, withBlockKey }
39
35
 
40
36
  export function renderBlock(
41
37
  block: VocabularyBlock,
@@ -43,17 +39,19 @@ export function renderBlock(
43
39
  entries: FetchedEntries = {},
44
40
  registry?: BlockRegistry,
45
41
  ): HtmlElement | null {
46
- // `block`'s type says `VocabularyBlock`, but the value crossing this
47
- // boundary from stored content is not always literally one of the shared
48
- // vocabulary — resolving here is what turns an unimplemented theme-private
49
- // block into its declared fallback instead of a silently blank slot.
42
+ // A block a plugin provides arrives already rendered by the host, in a
43
+ // process of its own (contract D theme@1.7). Nothing else in this theme
44
+ // needs to know such blocks exist.
45
+ const provided = providedBlockNode(block, ctx)
46
+ if (provided !== null) return withBlockVariant(provided, block.variant)
47
+ // A stored block is not always literally one of the shared vocabulary:
48
+ // resolving here turns an unimplemented theme-private block into its
49
+ // declared fallback instead of a silently blank slot.
50
50
  const resolved = resolveBlockForRender(block, VOCABULARY_NAMES, registry)
51
51
  if (resolved === null) return null
52
52
  const known = resolved as unknown as VocabularyBlock
53
- // `withBlockVariant` (blocks@2.0, RFC 0002) is applied once here, after
54
- // dispatch, rather than inside each of the seventeen block renderers —
55
- // `variant` is envelope data every block carries identically, exactly the
56
- // same reasoning `withBlockKey` in `renderPage` already follows for `_key`.
53
+ // `variant` is envelope data every block carries identically, applied once
54
+ // here rather than by each of the seventeen renderers.
57
55
  return withBlockVariant(renderKnownBlock(known, ctx, entries), known.variant)
58
56
  }
59
57
 
@@ -98,8 +96,9 @@ function renderKnownBlock(
98
96
  case 'logoStrip':
99
97
  return renderLogoStrip(known, ctx)
100
98
  default: {
101
- // Exhaustive over contract B's vocabulary: `known` is `never` here, so a
102
- // block this package does not implement stops it compiling until it is.
99
+ // Exhaustive over contract B: `known` is `never` here, so a block this
100
+ // package does not implement stops it compiling until it is. `null`
101
+ // rather than a throw: choosing a fallback is the render layer's job.
103
102
  const unreachable: never = known
104
103
  void unreachable
105
104
  return null
@@ -108,48 +107,53 @@ function renderKnownBlock(
108
107
  }
109
108
 
110
109
  /**
111
- * `renderEntryHeader` (`theme@1.4`) draws the eyebrow/title/excerpt/meta/
112
- * cover furniture for a page backed by a real content entry — a project —
113
- * and already returns `null` for a page with no `entry` meta *and* for one
114
- * whose blocks draw their own heading (a `hero`), so the bare
115
- * `<h1 class="cg-page__title">` fallback below is the right markup in both
116
- * of those cases and only those — never a double heading, and never a page
117
- * with none at all. A page rendered before `theme@1.4` (`page.entry`
118
- * absent) gets exactly the pre-1.4 markup, byte for byte.
119
- */
120
- /**
121
- * `withBlockKey` stamps `data-block-key` (the L16 builder's own hook); this
122
- * theme additionally stamps a real `id`, the block's own `_key`, on the same
123
- * element — `_key` is already unique within a page (contract B), so it
124
- * doubles as a genuine in-page anchor. The header/footer's own single-page
125
- * links (the `portfolio` blueprint's "Work"/"Services" nav) resolve to a
126
- * real target this way, rather than a `#fragment` nothing on the page
127
- * answers to.
110
+ * The block's own `_key` doubles as an in-page anchor: it is unique within a
111
+ * page (contract B), so a menu item such as `/#home-work` lands on the block
112
+ * it names rather than on a fragment nothing answers to.
128
113
  */
129
- function withBlockAnchor(element: HtmlElement | null, key: string): HtmlElement | null {
114
+ function withAnchor(element: HtmlElement | null, key: string): HtmlElement | null {
130
115
  if (element === null) return null
131
116
  return { ...element, attrs: { ...element.attrs, id: key } }
132
117
  }
133
118
 
119
+ /**
120
+ * `<main id="cg-main">` is mandatory: it is the skip link's target, written
121
+ * by `@cogenta/cli`'s `theme-render.ts` outside any theme's control.
122
+ *
123
+ * Three openings, and exactly one `h1` in each:
124
+ *
125
+ * - a page whose blocks include a `hero` lets the hero carry the title (the
126
+ * studio's statement, on a home page);
127
+ * - a project (`isProject`: an entry with a cover that is not a plain page)
128
+ * gets the project header: title, statement, lead visual, fact sheet;
129
+ * - anything else gets its title set large on the grid.
130
+ *
131
+ * `withBlockKey` stamps every rendered block with its contract-B `_key`, so
132
+ * the visual page builder (L16) can map a clicked element back to its block.
133
+ */
134
134
  export function renderPage(
135
135
  page: PageContent,
136
136
  ctx: RenderContext,
137
137
  entries: FetchedEntries = {},
138
138
  registry?: BlockRegistry,
139
139
  ): HtmlElement {
140
- const entryHeader = renderEntryHeader(page, ctx)
140
+ const ownHeading = pageHasOwnHeading(page.blocks)
141
+ const project = !ownHeading && isProject(page.entry) ? page.entry : undefined
142
+ const opening = ownHeading
143
+ ? null
144
+ : project !== undefined
145
+ ? renderProjectHeader(page, project, ctx.locale)
146
+ : renderPageHeader(page)
147
+
141
148
  return h(
142
149
  'main',
143
- { class: 'cg-main', id: 'cg-main' },
144
- entryHeader,
145
- entryHeader === null && !pageHasOwnHeading(page.blocks)
146
- ? h('h1', { class: 'cg-page__title' }, page.title)
147
- : null,
150
+ {
151
+ class: project !== undefined ? 'cg-main cg-project' : 'cg-main',
152
+ id: 'cg-main',
153
+ },
154
+ opening,
148
155
  page.blocks.map((block) =>
149
- withBlockAnchor(
150
- withBlockKey(renderBlock(block, ctx, entries, registry), block._key),
151
- block._key,
152
- ),
156
+ withAnchor(withBlockKey(renderBlock(block, ctx, entries, registry), block._key), block._key),
153
157
  ),
154
158
  )
155
159
  }
@@ -1,88 +1,110 @@
1
- import { type HtmlElement, h, type TermArchiveInput } from '@cogenta/theme-kit'
1
+ import {
2
+ type HtmlElement,
3
+ h,
4
+ type TermArchiveEntry,
5
+ type TermArchiveInput,
6
+ } from '@cogenta/theme-kit'
7
+ import { taxonomyLabel, yearOf } from './layout.js'
8
+ import { renderIndexRow, type Work } from './work.js'
2
9
 
3
10
  /**
4
- * The taxonomy-term archive (contract D `theme@1.3`), in this theme's own
5
- * brutalist-editorial voice: the term set as a display heading with the
6
- * taxonomy name as a kicker above it, and the entries as the same numbered
7
- * `cg-entry` rows `collectionList` already renders here — an index, not a
8
- * card grid.
11
+ * The page of one taxonomy term (contract D `theme@1.3`): every project filed
12
+ * under a discipline, a client or a member of the team.
9
13
  *
10
- * Reusing the block's own classes rather than inventing archive-only ones is
11
- * deliberate: it is what makes an archive of work look like this theme's
12
- * lists without a second stylesheet to keep in step.
14
+ * It is set as the studio's index, not as a grid: an archive entry carries no
15
+ * picture (`TermArchiveEntry` has none), and a grid of empty frames is worse
16
+ * than a well-set list. The taxonomy's own name sits above the term in the
17
+ * caption size; the term itself is the page's one `h1`, very large; each
18
+ * project is a ruled row with its title, its statement when it has one, and
19
+ * its year; sub-terms and the pager are
20
+ * underlined words, the pager's arrows drawn by the stylesheet.
13
21
  */
14
- export function renderTermArchive(input: TermArchiveInput): HtmlElement {
15
- const items = input.entries.map((entry, index) =>
16
- h(
17
- 'li',
18
- { class: 'cg-entry' },
19
- h(
20
- 'div',
21
- { class: 'cg-entry__body' },
22
- h(
23
- 'span',
24
- { class: 'cg-entry__index', 'aria-hidden': 'true' },
25
- String(index + 1).padStart(2, '0'),
26
- ),
27
- h(
28
- 'h2',
29
- { class: 'cg-entry__title' },
30
- entry.href === null
31
- ? entry.title
32
- : h('a', { class: 'cg-entry__link', href: entry.href }, entry.title),
33
- ),
34
- entry.publishedAt === null
35
- ? null
36
- : h(
37
- 'time',
38
- { class: 'cg-entry__date', datetime: entry.publishedAt },
39
- entry.publishedAt.slice(0, 10),
40
- ),
41
- entry.summary === null ? null : h('p', { class: 'cg-entry__excerpt' }, entry.summary),
42
- ),
43
- ),
44
- )
22
+ function workFromArchive(entry: TermArchiveEntry): Work {
23
+ const year = entry.publishedAt === null ? undefined : yearOf(entry.publishedAt)
24
+ return {
25
+ href: entry.href,
26
+ title: entry.title,
27
+ caption: year === undefined ? {} : { year },
28
+ ...(entry.summary === null ? {} : { summary: entry.summary }),
29
+ }
30
+ }
45
31
 
32
+ function pager(input: TermArchiveInput): HtmlElement | null {
33
+ if (input.page.previousHref === null && input.page.nextHref === null) return null
46
34
  return h(
47
- 'main',
48
- { class: 'cg-main cg-archive', id: 'cg-main' },
49
- input.ancestors.length === 0
35
+ 'nav',
36
+ { class: 'cg-archive__pager', 'aria-label': input.labels.pagination },
37
+ input.page.previousHref === null
50
38
  ? null
51
39
  : h(
52
- 'nav',
53
- { class: 'cg-archive__breadcrumb', 'aria-label': input.labels.breadcrumb },
54
- h(
55
- 'ol',
56
- {},
57
- ...input.ancestors.map((l) => h('li', {}, h('a', { href: l.href }, l.label))),
58
- ),
40
+ 'a',
41
+ {
42
+ class: 'cg-arrow-link',
43
+ 'data-direction': 'back',
44
+ rel: 'prev',
45
+ href: input.page.previousHref,
46
+ },
47
+ input.labels.previous,
59
48
  ),
60
- h('p', { class: 'cg-collection__title' }, input.taxonomyName),
61
- h('h1', { class: 'cg-page__title' }, input.term.label),
62
- input.children.length === 0
49
+ input.page.nextHref === null
63
50
  ? null
64
51
  : h(
65
- 'ul',
66
- { class: 'cg-archive__children', 'aria-label': input.labels.subterms },
67
- ...input.children.map((c) => h('li', {}, h('a', { href: c.href }, c.label))),
52
+ 'a',
53
+ { class: 'cg-arrow-link', rel: 'next', href: input.page.nextHref },
54
+ input.labels.next,
68
55
  ),
69
- items.length === 0
70
- ? h('p', { class: 'cg-collection__empty' }, input.labels.empty)
71
- : h('ul', { class: 'cg-collection__items' }, ...items),
72
- pager(input),
73
56
  )
74
57
  }
75
58
 
76
- function pager(input: TermArchiveInput): HtmlElement | null {
77
- if (input.page.previousHref === null && input.page.nextHref === null) return null
59
+ export function renderTermArchive(input: TermArchiveInput): HtmlElement {
78
60
  return h(
79
- 'nav',
80
- { class: 'cg-archive__pager', 'aria-label': input.labels.pagination },
81
- input.page.previousHref === null
82
- ? null
83
- : h('a', { rel: 'prev', href: input.page.previousHref }, input.labels.previous),
84
- input.page.nextHref === null
85
- ? null
86
- : h('a', { rel: 'next', href: input.page.nextHref }, input.labels.next),
61
+ 'main',
62
+ { class: 'cg-main cg-archive', id: 'cg-main' },
63
+ h(
64
+ 'header',
65
+ { class: 'cg-archive__head' },
66
+ h(
67
+ 'div',
68
+ { class: 'cg-container cg-archive__head-inner' },
69
+ input.ancestors.length === 0
70
+ ? null
71
+ : h(
72
+ 'nav',
73
+ { class: 'cg-archive__breadcrumb', 'aria-label': input.labels.breadcrumb },
74
+ h(
75
+ 'ol',
76
+ { class: 'cg-archive__trail' },
77
+ input.ancestors.map((link) => h('li', {}, h('a', { href: link.href }, link.label))),
78
+ ),
79
+ ),
80
+ h('p', { class: 'cg-archive__kicker' }, taxonomyLabel(input.taxonomyName, input.locale)),
81
+ h('h1', { class: 'cg-archive__title' }, input.term.label),
82
+ input.children.length === 0
83
+ ? null
84
+ : h(
85
+ 'ul',
86
+ { class: 'cg-archive__children', 'aria-label': input.labels.subterms },
87
+ input.children.map((child) =>
88
+ h('li', {}, h('a', { class: 'cg-arrow-link', href: child.href }, child.label)),
89
+ ),
90
+ ),
91
+ ),
92
+ ),
93
+ h(
94
+ 'section',
95
+ { class: 'cg-section cg-collection', 'data-form': 'index' },
96
+ h(
97
+ 'div',
98
+ { class: 'cg-container cg-collection__inner' },
99
+ input.entries.length === 0
100
+ ? h('p', { class: 'cg-empty' }, input.labels.empty)
101
+ : h(
102
+ 'ol',
103
+ { class: 'cg-index', 'data-count': String(input.entries.length) },
104
+ input.entries.map((entry) => renderIndexRow(workFromArchive(entry), 'h2')),
105
+ ),
106
+ pager(input),
107
+ ),
108
+ ),
87
109
  )
88
110
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The widget areas this theme names (contract D `theme@1.6`). Declaring them
3
+ * is what tells the host this theme draws the footer columns inside its own
4
+ * colophon, on the footer's twelve columns; the page areas are placed by the
5
+ * host in the shared `cg-sidebar-layout` markup, which `styles/widgets.css`
6
+ * sets on the page's own grid: the column beside the content takes the last
7
+ * columns of the twelve, the way the header's navigation does.
8
+ */
9
+ export const widgetAreas: readonly {
10
+ readonly id: string
11
+ readonly label: string
12
+ readonly description?: string
13
+ }[] = [
14
+ {
15
+ id: 'sidebar',
16
+ label: 'Beside the page',
17
+ description:
18
+ 'Beside an index of work (a discipline, a client, a member of the team) and search results.',
19
+ },
20
+ {
21
+ id: 'content-after',
22
+ label: 'After the work',
23
+ description: 'Under a project or a page, before the footer: more work, a way to get in touch.',
24
+ },
25
+ ]
@@ -0,0 +1,124 @@
1
+ import {
2
+ type ContentEntry,
3
+ entryHref,
4
+ entryImage,
5
+ entryTitle,
6
+ type HeadingTag,
7
+ type HtmlElement,
8
+ h,
9
+ heading,
10
+ type ImageSource,
11
+ type RenderContext,
12
+ renderImageSource,
13
+ } from '@cogenta/theme-kit'
14
+ import { captionUnder, entryCaption, renderCaption, type WorkCaption } from './layout.js'
15
+
16
+ /**
17
+ * One piece of work as a listing sets it: the image, square-cornered and
18
+ * edge to edge within its columns, then the title and the caption line
19
+ * (client, discipline, year). The grid, the strip and the index all build
20
+ * their rows from this one unit.
21
+ *
22
+ * The title is the one link. The image links to the same page but is taken
23
+ * out of the tab order and hidden from assistive technology, so a keyboard or
24
+ * screen-reader user meets each project once.
25
+ */
26
+ export interface Work {
27
+ readonly href: string | null
28
+ readonly title: string
29
+ readonly caption: WorkCaption
30
+ readonly image?: ImageSource
31
+ /** A sentence about the work, when the source carries one and the slot shows it (the archive index). */
32
+ readonly summary?: string
33
+ }
34
+
35
+ /** Covers are drawn at 3:2; a listing never asks for another shape, so no card is cropped differently from its neighbour. */
36
+ const COVER_WIDTH = 2000
37
+
38
+ export function workFromEntry(entry: ContentEntry, ctx: RenderContext): Work {
39
+ const image = entryImage(entry, ctx, {
40
+ width: COVER_WIDTH,
41
+ height: Math.round((COVER_WIDTH * 2) / 3),
42
+ fit: 'cover',
43
+ })
44
+ return {
45
+ href: entryHref(entry, ctx),
46
+ title: entryTitle(entry, ctx),
47
+ caption: entryCaption(entry),
48
+ ...(image === undefined || image.kind !== 'image' ? {} : { image }),
49
+ }
50
+ }
51
+
52
+ export interface WorkCardOptions {
53
+ readonly tag: HeadingTag
54
+ readonly sizes: string
55
+ readonly loading?: 'eager' | 'lazy'
56
+ }
57
+
58
+ function titleOf(work: Work, tag: HeadingTag, className: string): HtmlElement {
59
+ return heading(
60
+ tag,
61
+ { class: className },
62
+ work.href === null
63
+ ? work.title
64
+ : h('a', { class: 'cg-work__link', href: work.href }, work.title),
65
+ )
66
+ }
67
+
68
+ export function renderWorkCard(work: Work, options: WorkCardOptions): HtmlElement {
69
+ const picture =
70
+ work.image === undefined
71
+ ? h('div', { class: 'cg-work__media', 'data-empty': 'true' })
72
+ : h(
73
+ work.href === null ? 'div' : 'a',
74
+ work.href === null
75
+ ? { class: 'cg-work__media' }
76
+ : { class: 'cg-work__media', href: work.href, tabindex: '-1', 'aria-hidden': 'true' },
77
+ renderImageSource(work.image, {
78
+ className: 'cg-work__image',
79
+ sizes: options.sizes,
80
+ loading: options.loading ?? 'lazy',
81
+ }),
82
+ )
83
+ return h(
84
+ 'article',
85
+ { class: 'cg-work' },
86
+ picture,
87
+ h(
88
+ 'div',
89
+ { class: 'cg-work__text' },
90
+ titleOf(work, options.tag, 'cg-work__title'),
91
+ renderCaption(captionUnder(work.caption, work.title), 'cg-work__caption'),
92
+ ),
93
+ )
94
+ }
95
+
96
+ /**
97
+ * A row of the index: the title, then the client, the discipline and the
98
+ * year in their own columns, a hairline under each. The columns carry no
99
+ * header row: a header is words, and a theme has no translation for them.
100
+ * The client and the discipline share one wrapper, so on a phone they read
101
+ * as one line under the title; on a wide screen the wrapper steps aside
102
+ * (`display: contents`) and each takes its column.
103
+ */
104
+ export function renderIndexRow(work: Work, tag: HeadingTag): HtmlElement {
105
+ const { client, discipline, year } = work.caption
106
+ return h(
107
+ 'li',
108
+ { class: 'cg-index__row' },
109
+ titleOf(work, tag, 'cg-index__title'),
110
+ work.summary === undefined
111
+ ? h(
112
+ 'span',
113
+ { class: 'cg-index__meta' },
114
+ h('span', { class: 'cg-index__cell', 'data-part': 'client' }, client ?? ''),
115
+ h('span', { class: 'cg-index__cell', 'data-part': 'discipline' }, discipline ?? ''),
116
+ )
117
+ : h(
118
+ 'span',
119
+ { class: 'cg-index__meta' },
120
+ h('span', { class: 'cg-index__summary' }, work.summary),
121
+ ),
122
+ h('span', { class: 'cg-index__cell', 'data-part': 'year' }, year ?? ''),
123
+ )
124
+ }