@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,134 +1,100 @@
1
1
  import type { CollectionListBlock } from '@cogenta/blocks'
2
2
  import {
3
- blockHeadingTag,
4
3
  buildCollectionListQuery,
5
4
  type ContentEntry,
6
- entryDate,
7
- entryExcerpt,
8
- entryHref,
9
- entryImage,
10
- entryTitle,
11
- type HeadingTag,
12
5
  type HtmlElement,
13
6
  h,
14
- heading,
15
7
  nestedHeadingTag,
16
8
  type RenderContext,
17
- renderImageSource,
18
9
  } from '@cogenta/theme-kit'
10
+ import { section, sectionHead } from '../layout.js'
11
+ import { renderIndexRow, renderWorkCard, workFromEntry } from '../work.js'
12
+
13
+ export { buildCollectionListQuery as query }
19
14
 
20
15
  /**
21
- * The only block of the seventeen that reads data at render time — contract
22
- * B marks it `runtime: 'server'` for that reason.
16
+ * A listing of work, in one of three forms taken from the layout the editor
17
+ * chose in contract B, never from a setting this theme invents:
23
18
  *
24
- * The read is *not* done here. `query` builds the request
25
- * (`@cogenta/theme-kit`'s `buildCollectionListQuery`, shared across every
26
- * theme since it derives purely from the block's own fields), the caller
27
- * awaits `ctx.content.list(...)` before rendering starts, and this function
28
- * stays a pure function of the entries it is handed.
19
+ * - `grid` is **the work grid**: an asymmetric sequence on twelve columns,
20
+ * six places long. A large image across seven columns with a smaller one
21
+ * across four, dropped lower, beside it; a full-width image; the pair again
22
+ * mirrored; a full-width image. Every cover keeps the same 3:2 shape, so
23
+ * the asymmetry comes from the columns and the drop, never from cropping
24
+ * one project differently from the next. The pattern lives in `work.css`,
25
+ * keyed on each item's `data-place`.
26
+ * - `list` is **the index**: one ruled row per project, title, client,
27
+ * discipline and year in columns.
28
+ * - `carousel` is **a strip**: cards side by side at one width, scrolling on
29
+ * any screen, a focusable and labelled region. No script.
29
30
  *
30
- * `list` keeps this theme's original register — a numbered mono index, the
31
- * title set at feature-headline scale, the date pushed to the far edge —
32
- * unchanged since L23. `grid`/`carousel` (`theme@1.4`) retile the same
33
- * entries into full-bleed project cards instead: a cover (`entryImage`,
34
- * 4:3), the title in display type, and — read straight off the raw
35
- * `ContentEntry` this block's entries already are, never invented — the
36
- * entry's own `role`/`year` fields as a meta line when a collection happens
37
- * to declare them (the `portfolio` blueprint's `project` does; any other
38
- * collection simply shows no meta line).
31
+ * An empty list keeps its label and says so in one line.
39
32
  */
40
- export { buildCollectionListQuery as query }
41
33
 
42
- function rawText(entry: ContentEntry, field: string): string | undefined {
43
- const value = entry[field]
44
- return typeof value === 'string' && value.trim() !== '' ? value : undefined
34
+ const PLACES = 6
35
+
36
+ function sizesFor(place: number): string {
37
+ if (place === 3 || place === 0) return '(min-width: 64rem) 92rem, 100vw'
38
+ if (place === 1 || place === 5) return '(min-width: 64rem) 54rem, 100vw'
39
+ return '(min-width: 64rem) 30rem, 100vw'
45
40
  }
46
41
 
47
- /** `role`/`year`, read straight off the entry — the "raw field" meta line a grid card shows when the collection behind it has them. */
48
- function renderCardMeta(entry: ContentEntry): HtmlElement | null {
49
- const role = rawText(entry, 'role')
50
- const year = rawText(entry, 'year')
51
- if (role === undefined && year === undefined) return null
42
+ function grid(entries: readonly ContentEntry[], ctx: RenderContext, titled: boolean): HtmlElement {
43
+ const tag = nestedHeadingTag('collectionList', titled)
52
44
  return h(
53
- 'p',
54
- { class: 'cg-collection__meta' },
55
- role === undefined ? null : h('span', { class: 'cg-collection__meta-role' }, role),
56
- year === undefined ? null : h('span', { class: 'cg-collection__meta-year' }, year),
45
+ 'ul',
46
+ { class: 'cg-grid', 'data-count': String(entries.length) },
47
+ entries.map((entry, index) => {
48
+ const place = (index + 1) % PLACES
49
+ return h(
50
+ 'li',
51
+ { class: 'cg-grid__item', 'data-place': String(place === 0 ? PLACES : place) },
52
+ renderWorkCard(workFromEntry(entry, ctx), {
53
+ tag,
54
+ sizes: sizesFor(place),
55
+ loading: index < 2 ? 'eager' : 'lazy',
56
+ }),
57
+ )
58
+ }),
57
59
  )
58
60
  }
59
61
 
60
- function renderCard(
61
- entry: ContentEntry,
62
- ctx: RenderContext,
63
- tag: HeadingTag,
64
- index: number,
65
- ): HtmlElement {
66
- const cover = entryImage(entry, ctx, { width: 900, height: 675, fit: 'cover' })
62
+ function index(entries: readonly ContentEntry[], ctx: RenderContext, titled: boolean): HtmlElement {
63
+ const tag = nestedHeadingTag('collectionList', titled)
67
64
  return h(
68
- 'li',
69
- { class: 'cg-entry cg-entry--card' },
70
- h(
71
- 'article',
72
- { class: 'cg-entry__body' },
73
- h(
74
- 'a',
75
- { class: 'cg-entry__cover-link', href: entryHref(entry, ctx), tabindex: -1 },
76
- cover === undefined
77
- ? h('span', { class: 'cg-entry__cover-empty', 'aria-hidden': 'true' })
78
- : renderImageSource(cover, {
79
- className: 'cg-entry__cover',
80
- sizes: '(min-width: 80rem) 50vw, 100vw',
81
- }),
82
- ),
83
- h(
84
- 'span',
85
- { class: 'cg-entry__index', 'aria-hidden': 'true' },
86
- String(index + 1).padStart(2, '0'),
87
- ),
88
- heading(
89
- tag,
90
- { class: 'cg-entry__title' },
91
- h('a', { class: 'cg-entry__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
92
- ),
93
- renderCardMeta(entry),
94
- ),
65
+ 'ol',
66
+ { class: 'cg-index', 'data-count': String(entries.length) },
67
+ entries.map((entry) => renderIndexRow(workFromEntry(entry, ctx), tag)),
95
68
  )
96
69
  }
97
70
 
98
- function renderRow(
99
- entry: ContentEntry,
71
+ function strip(
72
+ entries: readonly ContentEntry[],
100
73
  ctx: RenderContext,
101
- tag: HeadingTag,
102
- index: number,
74
+ block: CollectionListBlock,
103
75
  ): HtmlElement {
104
- const date = entryDate(entry)
105
- const excerpt = entryExcerpt(entry)
76
+ const tag = nestedHeadingTag('collectionList', block.title !== undefined)
106
77
  return h(
107
- 'li',
108
- { class: 'cg-entry' },
78
+ 'div',
79
+ {
80
+ class: 'cg-strip',
81
+ role: 'region',
82
+ 'aria-label': block.title ?? ctx.t('collection.carousel'),
83
+ tabindex: '0',
84
+ },
109
85
  h(
110
- 'article',
111
- { class: 'cg-entry__body' },
112
- h(
113
- 'span',
114
- { class: 'cg-entry__index', 'aria-hidden': 'true' },
115
- String(index + 1).padStart(2, '0'),
86
+ 'ul',
87
+ { class: 'cg-strip__items', 'data-count': String(entries.length) },
88
+ entries.map((entry) =>
89
+ h(
90
+ 'li',
91
+ { class: 'cg-strip__item' },
92
+ renderWorkCard(workFromEntry(entry, ctx), {
93
+ tag,
94
+ sizes: '(min-width: 64rem) 34rem, 80vw',
95
+ }),
96
+ ),
116
97
  ),
117
- heading(
118
- tag,
119
- { class: 'cg-entry__title' },
120
- h('a', { class: 'cg-entry__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
121
- ),
122
- date === undefined
123
- ? null
124
- : h(
125
- 'time',
126
- { class: 'cg-entry__date', datetime: date },
127
- // Formatted in the rendered locale rather than left as an ISO
128
- // string: the machine-readable form is already in `datetime`.
129
- new Intl.DateTimeFormat(ctx.locale, { dateStyle: 'long' }).format(new Date(date)),
130
- ),
131
- excerpt === undefined ? null : h('p', { class: 'cg-entry__excerpt' }, excerpt),
132
98
  ),
133
99
  )
134
100
  }
@@ -138,44 +104,24 @@ export function renderCollectionList(
138
104
  ctx: RenderContext,
139
105
  entries: readonly ContentEntry[],
140
106
  ): HtmlElement {
141
- const hasTitle = block.title !== undefined
142
- const entryTag = nestedHeadingTag('collectionList', hasTitle)
143
- const isTiled = block.layout === 'grid' || block.layout === 'carousel'
144
- const renderItem = isTiled ? renderCard : renderRow
145
- const items =
107
+ const titled = block.title !== undefined
108
+ const form = block.layout === 'list' ? 'index' : block.layout === 'carousel' ? 'strip' : 'grid'
109
+ const body =
146
110
  entries.length === 0
147
- ? h('p', { class: 'cg-collection__empty' }, ctx.t('collection.empty'))
148
- : h(
149
- 'ol',
150
- { class: 'cg-collection__items' },
151
- entries.map((entry, index) => renderItem(entry, ctx, entryTag, index)),
152
- )
111
+ ? h('p', { class: 'cg-empty' }, ctx.t('collection.empty'))
112
+ : form === 'index'
113
+ ? index(entries, ctx, titled)
114
+ : form === 'strip'
115
+ ? strip(entries, ctx, block)
116
+ : grid(entries, ctx, titled)
153
117
 
154
- return h(
118
+ return section(
155
119
  'section',
156
- {
157
- class: 'cg-block cg-collection',
158
- 'data-block': 'collectionList',
159
- 'data-layout': block.layout,
160
- },
161
- hasTitle
162
- ? heading(
163
- blockHeadingTag('collectionList') ?? 'h2',
164
- { class: 'cg-collection__title', 'data-field': 'title' },
165
- block.title ?? '',
166
- )
167
- : null,
168
- block.layout === 'carousel'
169
- ? h(
170
- 'div',
171
- {
172
- class: 'cg-collection__viewport',
173
- role: 'region',
174
- 'aria-label': block.title ?? ctx.t('collection.carousel'),
175
- tabindex: '0',
176
- },
177
- items,
178
- )
179
- : items,
120
+ 'collectionList',
121
+ 'cg-collection',
122
+ { 'data-form': form, 'data-collection': block.collection },
123
+ 'div',
124
+ sectionHead('collectionList', block.title),
125
+ body,
180
126
  )
181
127
  }
@@ -1,5 +1,6 @@
1
1
  import type { CtaBlock } from '@cogenta/blocks'
2
2
  import {
3
+ actionLink,
3
4
  actionList,
4
5
  blockHeadingTag,
5
6
  type HtmlElement,
@@ -7,31 +8,42 @@ import {
7
8
  heading,
8
9
  type RenderContext,
9
10
  } from '@cogenta/theme-kit'
11
+ import { section } from '../layout.js'
10
12
 
11
13
  /**
12
- * The one panel on the page that inverts the palette — ink background, paper
13
- * text — so it reads as a full stop rather than another section. The title
14
- * is set at the same display scale as the hero: a call to action on a
15
- * portfolio site is a second headline, not a footnote.
14
+ * The invitation to get in touch, set the way a studio sets its contact
15
+ * line: a short title and a sentence, then the first action as a very large
16
+ * link in the display width, underlined in the one signal colour. Any further
17
+ * action follows as ordinary underlined words. No box, no band, no button.
16
18
  */
17
19
  export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
18
- return h(
20
+ const [first, ...rest] = block.actions
21
+ const lead =
22
+ first === undefined
23
+ ? null
24
+ : (() => {
25
+ const link = actionLink(ctx, first)
26
+ return { ...link, attrs: { ...link.attrs, class: 'cg-action cg-contact__link' } }
27
+ })()
28
+ return section(
19
29
  'section',
20
- { class: 'cg-block cg-cta', 'data-block': 'cta' },
30
+ 'cta',
31
+ 'cg-contact',
32
+ {},
33
+ 'div',
21
34
  h(
22
35
  'div',
23
- { class: 'cg-cta__frame' },
36
+ { class: 'cg-contact__copy' },
24
37
  heading(
25
38
  blockHeadingTag('cta') ?? 'h2',
26
- { class: 'cg-cta__title', 'data-field': 'title' },
39
+ { class: 'cg-contact__title', 'data-field': 'title' },
27
40
  block.title,
28
41
  ),
29
42
  block.text === undefined
30
43
  ? null
31
- : h('p', { class: 'cg-cta__text', 'data-field': 'text' }, block.text),
32
- // `actions` is required and non-empty for a `cta`, so the list is always
33
- // rendered; the null branch stays reachable only through invalid data.
34
- actionList(ctx, block.actions, block.title),
44
+ : h('p', { class: 'cg-contact__text', 'data-field': 'text' }, block.text),
35
45
  ),
46
+ lead === null ? null : h('p', { class: 'cg-contact__lead' }, lead),
47
+ actionList(ctx, rest, block.title),
36
48
  )
37
49
  }
@@ -1,23 +1,17 @@
1
1
  import type { EmbedBlock } from '@cogenta/blocks'
2
2
  import { aspectRatio, type HtmlElement, h, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * Nothing here contacts a third party before the visitor has consented.
6
+ * A film or a recording from a third party, the size a project image would
7
+ * be: columns 3 to 10 at the block's ratio.
6
8
  *
7
- * When `consentRequired` is true the block renders a self-contained card and
8
- * an outbound link — no `<iframe>`, no script tag, no preconnect, no poster
9
- * image fetched from the provider. An embed that "only" loads a thumbnail has
10
- * already handed the visitor's IP address and a cookie to the provider,
11
- * which is the exact transfer consent was supposed to gate.
12
- *
13
- * The theme ships no consent *button*, because it ships no JavaScript:
14
- * granting consent is a site-wide decision that belongs to the consent
15
- * layer, not to a block. The card carries a bracketed provider tag in the
16
- * mono register — `[ youtube ]` — the theme's own visual shorthand for
17
- * "something external belongs here".
9
+ * Nothing here contacts a third party before the visitor consents: when
10
+ * `consentRequired` is set, or the provider has no cookie-free frame source,
11
+ * a notice renders in the frame's place instead of an `<iframe>`, holding the
12
+ * same shape: a hairline frame, a small label, one sentence and one arrow
13
+ * link. No preconnect, no thumbnail fetched from the provider.
18
14
  */
19
-
20
- /** `null` means "this provider has no embeddable frame we trust". */
21
15
  function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
22
16
  const url = URL.parse(rawUrl)
23
17
  if (url === null) return null
@@ -25,8 +19,6 @@ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string |
25
19
 
26
20
  switch (provider) {
27
21
  case 'youtube': {
28
- // `youtube-nocookie.com` is the privacy-preserving host, and the only one
29
- // this theme will point an iframe at.
30
22
  const id = url.hostname.endsWith('youtu.be')
31
23
  ? segments[0]
32
24
  : (url.searchParams.get('v') ?? segments[1])
@@ -49,22 +41,20 @@ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string |
49
41
  case 'soundcloud':
50
42
  return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
51
43
  default:
52
- // bluesky, mastodon, other: a post embed is a script tag, which this
53
- // theme does not load. The link card is the rendering.
54
44
  return null
55
45
  }
56
46
  }
57
47
 
58
- function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
48
+ function notice(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
59
49
  return h(
60
50
  'div',
61
- { class: 'cg-embed__placeholder' },
62
- h('span', { class: 'cg-embed__tag' }, `[ ${block.provider} ]`),
63
- h('p', { class: 'cg-embed__notice' }, reason),
51
+ { class: 'cg-embed__notice' },
52
+ h('p', { class: 'cg-embed__label' }, ctx.t('embed.label')),
53
+ h('p', { class: 'cg-embed__reason' }, reason),
64
54
  h(
65
55
  'a',
66
56
  {
67
- class: 'cg-embed__link',
57
+ class: 'cg-arrow-link cg-embed__link',
68
58
  href: ctx.link(block.url),
69
59
  rel: 'noopener noreferrer nofollow',
70
60
  },
@@ -75,37 +65,39 @@ function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): Htm
75
65
 
76
66
  export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
77
67
  const source = block.consentRequired ? null : frameSource(block.provider, block.url)
78
- // Video providers are 16:9 unless the editor framed them otherwise; without a
79
- // ratio the frame would collapse and shift the layout as it loads.
80
68
  const ratio = aspectRatio(block.ratio) ?? '16 / 9'
81
69
 
82
- return h(
70
+ return section(
83
71
  'div',
72
+ 'embed',
73
+ 'cg-embed',
84
74
  {
85
- class: 'cg-block cg-embed',
86
- 'data-block': 'embed',
87
75
  'data-provider': block.provider,
88
76
  'data-consent': block.consentRequired ? 'required' : 'not-required',
89
77
  style: `--cg-ratio:${ratio}`,
90
78
  },
91
- source === null
92
- ? consentCard(
93
- block,
94
- ctx,
95
- block.consentRequired
96
- ? ctx.t('embed.consentRequired', { provider: block.provider })
97
- : ctx.t('embed.unsupported', { provider: block.provider }),
98
- )
99
- : h('iframe', {
100
- class: 'cg-embed__frame',
101
- src: source,
102
- // An iframe with no accessible name is announced as "frame" and
103
- // nothing else. WCAG 4.1.2, and the single most common embed defect.
104
- title: ctx.t('embed.title', { provider: block.provider }),
105
- loading: 'lazy',
106
- referrerpolicy: 'strict-origin-when-cross-origin',
107
- allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
108
- allowfullscreen: true,
109
- }),
79
+ 'div',
80
+ h(
81
+ 'div',
82
+ { class: 'cg-embed__frame' },
83
+ source === null
84
+ ? notice(
85
+ block,
86
+ ctx,
87
+ block.consentRequired
88
+ ? ctx.t('embed.consentRequired', { provider: block.provider })
89
+ : ctx.t('embed.unsupported', { provider: block.provider }),
90
+ )
91
+ : h('iframe', {
92
+ class: 'cg-embed__player',
93
+ src: source,
94
+ title: ctx.t('embed.title', { provider: block.provider }),
95
+ loading: 'lazy',
96
+ referrerpolicy: 'strict-origin-when-cross-origin',
97
+ allow:
98
+ 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
99
+ allowfullscreen: true,
100
+ }),
101
+ ),
110
102
  )
111
103
  }
@@ -1,54 +1,45 @@
1
1
  import type { FaqBlock, FaqItem } from '@cogenta/blocks'
2
2
  import {
3
- blockHeadingTag,
3
+ type HeadingTag,
4
4
  type HtmlElement,
5
5
  h,
6
6
  heading,
7
+ nestedHeadingTag,
7
8
  type RenderContext,
8
9
  renderRichText,
9
10
  } from '@cogenta/theme-kit'
11
+ import { section, sectionHead } from '../layout.js'
10
12
 
11
13
  /**
12
- * `<details>`/`<summary>` rather than a scripted accordion: expanding,
13
- * keyboard operation, the expanded state announced to assistive technology
14
- * and in-page search all come from the browser, at zero bytes of
15
- * JavaScript.
16
- *
17
- * The question is plain text inside `<summary>`, not a heading: a heading
18
- * nested in a summary is rendered inconsistently across screen readers, and
19
- * the questions are already reachable as a list under the block's own
20
- * heading. A CSS-drawn plus mark (rotated to a cross on `[open]`) replaces
21
- * the native marker rather than a chevron — the theme's own vocabulary of
22
- * shapes, kept purely visual.
14
+ * Questions a client asks before a project starts, set open: the label in
15
+ * the first three columns, and from the fourth each question in the display
16
+ * width with its answer beside it on a wide screen, divided by hairlines.
17
+ * Nothing is hidden behind a disclosure: a short set of answers is read
18
+ * whole. (`accordion` is the collapsible form of the same content.)
23
19
  */
24
- function renderItem(item: FaqItem, ctx: RenderContext): HtmlElement {
20
+ function renderItem(item: FaqItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
25
21
  return h(
26
- 'li',
27
- { class: 'cg-faq__item' },
28
- h(
29
- 'details',
30
- { class: 'cg-faq__details' },
31
- h('summary', { class: 'cg-faq__question' }, h('span', {}, item.question)),
32
- h('div', { class: 'cg-faq__answer' }, renderRichText(ctx, item.answer)),
33
- ),
22
+ 'div',
23
+ { class: 'cg-answers__item' },
24
+ heading(tag, { class: 'cg-answers__question' }, item.question),
25
+ h('div', { class: 'cg-answers__answer' }, renderRichText(ctx, item.answer)),
34
26
  )
35
27
  }
36
28
 
37
29
  export function renderFaq(block: FaqBlock, ctx: RenderContext): HtmlElement {
38
- return h(
30
+ const titled = block.title !== undefined
31
+ const tag = nestedHeadingTag('faq', titled)
32
+ return section(
39
33
  'section',
40
- { class: 'cg-block cg-faq', 'data-block': 'faq' },
41
- block.title === undefined
42
- ? null
43
- : heading(
44
- blockHeadingTag('faq') ?? 'h2',
45
- { class: 'cg-faq__title', 'data-field': 'title' },
46
- block.title,
47
- ),
34
+ 'faq',
35
+ 'cg-answers cg-split',
36
+ { 'data-titled': titled ? 'true' : 'false' },
37
+ 'div',
38
+ sectionHead('faq', block.title),
48
39
  h(
49
- 'ul',
50
- { class: 'cg-faq__items' },
51
- block.items.map((item) => renderItem(item, ctx)),
40
+ 'div',
41
+ { class: 'cg-answers__items' },
42
+ block.items.map((item) => renderItem(item, ctx, tag)),
52
43
  ),
53
44
  )
54
45
  }
@@ -1,6 +1,5 @@
1
1
  import type { FeatureGridBlock, FeatureItem } from '@cogenta/blocks'
2
2
  import {
3
- blockHeadingTag,
4
3
  type HeadingTag,
5
4
  type HtmlElement,
6
5
  h,
@@ -8,61 +7,59 @@ import {
8
7
  href,
9
8
  nestedHeadingTag,
10
9
  type RenderContext,
11
- renderIcon,
12
10
  } from '@cogenta/theme-kit'
11
+ import { section, sectionHead } from '../layout.js'
13
12
 
14
13
  /**
15
- * The item's title is the link, so the link's accessible name is the
16
- * feature's name — a bare "learn more" repeated across a grid is the
17
- * classic failure of WCAG 2.4.4, and the block carries no label field to
18
- * write one anyway.
14
+ * Services, disciplines, principles or clients, set as a typographic list
15
+ * rather than as cards with icons. Two forms, chosen from the data itself:
19
16
  *
20
- * `icon` names a symbol (`renderIcon`, `theme@1.4`) — drawn small and
21
- * accent-coloured, decorative (`aria-hidden`, the title already carries the
22
- * accessible name) — sitting *above* the running index numeral this theme
23
- * has always drawn from a CSS counter, never replacing it: the numeral is
24
- * this theme's own editorial device, the icon is the shared vocabulary's.
25
- * An item with no icon, or one whose name this theme does not recognise,
26
- * keeps exactly the pre-1.4 numeral-only card.
17
+ * - **rows**, when any item carries a sentence: each item a ruled row, its
18
+ * name in the display width on the left and the sentence beside it. A
19
+ * linked item links its name, drawn as an arrow link.
20
+ * - **names**, when no item does: a list of names in columns, the way a
21
+ * studio lists its clients.
22
+ *
23
+ * An item's `icon` is not drawn: a studio's list of services is words, and a
24
+ * pictogram beside each would be decoration. The value stays in the content
25
+ * for a theme that wants it.
27
26
  */
28
- function renderItem(item: FeatureItem, ctx: RenderContext, tag: HeadingTag): HtmlElement {
29
- const title =
27
+ function renderItem(
28
+ item: FeatureItem,
29
+ ctx: RenderContext,
30
+ tag: HeadingTag,
31
+ form: 'rows' | 'names',
32
+ ): HtmlElement {
33
+ const name =
30
34
  item.link === undefined
31
- ? heading(tag, { class: 'cg-feature__title' }, item.title)
32
- : heading(
33
- tag,
34
- { class: 'cg-feature__title' },
35
- h('a', { class: 'cg-feature__link', href: href(ctx, item.link) }, item.title),
36
- )
37
- const icon =
38
- item.icon === undefined ? null : renderIcon(item.icon, { className: 'cg-feature__icon' })
35
+ ? item.title
36
+ : h('a', { class: 'cg-arrow-link cg-list__link', href: href(ctx, item.link) }, item.title)
37
+ // A name in a list of names is an item, not a section of the page: only a
38
+ // row with its own sentence earns a heading in the outline.
39
+ if (form === 'names') return h('li', { class: 'cg-list__item cg-list__name' }, name)
39
40
  return h(
40
41
  'li',
41
- { class: 'cg-feature', 'data-icon': item.icon },
42
- icon,
43
- h('span', { class: 'cg-feature__index', 'aria-hidden': 'true' }),
44
- title,
45
- item.text === undefined ? null : h('p', { class: 'cg-feature__text' }, item.text),
42
+ { class: 'cg-list__item' },
43
+ heading(tag, { class: 'cg-list__name' }, name),
44
+ item.text === undefined ? null : h('p', { class: 'cg-list__text' }, item.text),
46
45
  )
47
46
  }
48
47
 
49
48
  export function renderFeatureGrid(block: FeatureGridBlock, ctx: RenderContext): HtmlElement {
50
- const hasTitle = block.title !== undefined
51
- const itemTag = nestedHeadingTag('featureGrid', hasTitle)
52
- return h(
49
+ const titled = block.title !== undefined
50
+ const tag = nestedHeadingTag('featureGrid', titled)
51
+ const form = block.items.some((item) => item.text !== undefined) ? 'rows' : 'names'
52
+ return section(
53
53
  'section',
54
- { class: 'cg-block cg-features', 'data-block': 'featureGrid' },
55
- hasTitle
56
- ? heading(
57
- blockHeadingTag('featureGrid') ?? 'h2',
58
- { class: 'cg-features__title', 'data-field': 'title' },
59
- block.title ?? '',
60
- )
61
- : null,
54
+ 'featureGrid',
55
+ 'cg-list cg-split',
56
+ { 'data-form': form, 'data-titled': titled ? 'true' : 'false' },
57
+ 'div',
58
+ sectionHead('featureGrid', block.title),
62
59
  h(
63
60
  'ul',
64
- { class: 'cg-features__items' },
65
- block.items.map((item) => renderItem(item, ctx, itemTag)),
61
+ { class: 'cg-list__items', 'data-count': String(block.items.length) },
62
+ block.items.map((item) => renderItem(item, ctx, tag, form)),
66
63
  ),
67
64
  )
68
65
  }