@cogenta/theme-restaurant 0.3.2 → 0.5.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 (138) hide show
  1. package/dist/src/index.d.ts +3 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +3 -0
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/render/blocks/accordion.d.ts +7 -0
  6. package/dist/src/render/blocks/accordion.d.ts.map +1 -1
  7. package/dist/src/render/blocks/accordion.js +10 -16
  8. package/dist/src/render/blocks/accordion.js.map +1 -1
  9. package/dist/src/render/blocks/collection-list.d.ts +4 -11
  10. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  11. package/dist/src/render/blocks/collection-list.js +96 -96
  12. package/dist/src/render/blocks/collection-list.js.map +1 -1
  13. package/dist/src/render/blocks/cta.d.ts +7 -4
  14. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  15. package/dist/src/render/blocks/cta.js +9 -7
  16. package/dist/src/render/blocks/cta.js.map +1 -1
  17. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  18. package/dist/src/render/blocks/embed.js +30 -28
  19. package/dist/src/render/blocks/embed.js.map +1 -1
  20. package/dist/src/render/blocks/faq.d.ts +13 -1
  21. package/dist/src/render/blocks/faq.d.ts.map +1 -1
  22. package/dist/src/render/blocks/faq.js +15 -11
  23. package/dist/src/render/blocks/faq.js.map +1 -1
  24. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  25. package/dist/src/render/blocks/feature-grid.js +43 -16
  26. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  27. package/dist/src/render/blocks/gallery.d.ts +0 -7
  28. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  29. package/dist/src/render/blocks/gallery.js +28 -10
  30. package/dist/src/render/blocks/gallery.js.map +1 -1
  31. package/dist/src/render/blocks/hero.d.ts +16 -9
  32. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  33. package/dist/src/render/blocks/hero.js +27 -20
  34. package/dist/src/render/blocks/hero.js.map +1 -1
  35. package/dist/src/render/blocks/logo-strip.d.ts +6 -0
  36. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
  37. package/dist/src/render/blocks/logo-strip.js +7 -9
  38. package/dist/src/render/blocks/logo-strip.js.map +1 -1
  39. package/dist/src/render/blocks/logos.d.ts +8 -0
  40. package/dist/src/render/blocks/logos.d.ts.map +1 -1
  41. package/dist/src/render/blocks/logos.js +18 -18
  42. package/dist/src/render/blocks/logos.js.map +1 -1
  43. package/dist/src/render/blocks/media-figure.d.ts +14 -3
  44. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  45. package/dist/src/render/blocks/media-figure.js +34 -16
  46. package/dist/src/render/blocks/media-figure.js.map +1 -1
  47. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  48. package/dist/src/render/blocks/pricing-table.js +17 -22
  49. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  50. package/dist/src/render/blocks/prose.d.ts +11 -6
  51. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  52. package/dist/src/render/blocks/prose.js +13 -7
  53. package/dist/src/render/blocks/prose.js.map +1 -1
  54. package/dist/src/render/blocks/quote.d.ts +6 -2
  55. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  56. package/dist/src/render/blocks/quote.js +14 -12
  57. package/dist/src/render/blocks/quote.js.map +1 -1
  58. package/dist/src/render/blocks/stat-counter.d.ts +6 -0
  59. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
  60. package/dist/src/render/blocks/stat-counter.js +7 -10
  61. package/dist/src/render/blocks/stat-counter.js.map +1 -1
  62. package/dist/src/render/blocks/stats.d.ts +7 -0
  63. package/dist/src/render/blocks/stats.d.ts.map +1 -1
  64. package/dist/src/render/blocks/stats.js +8 -16
  65. package/dist/src/render/blocks/stats.js.map +1 -1
  66. package/dist/src/render/blocks/testimonial.d.ts +5 -11
  67. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  68. package/dist/src/render/blocks/testimonial.js +9 -17
  69. package/dist/src/render/blocks/testimonial.js.map +1 -1
  70. package/dist/src/render/chrome.d.ts.map +1 -1
  71. package/dist/src/render/chrome.js +101 -84
  72. package/dist/src/render/chrome.js.map +1 -1
  73. package/dist/src/render/dish.d.ts +13 -0
  74. package/dist/src/render/dish.d.ts.map +1 -0
  75. package/dist/src/render/dish.js +84 -0
  76. package/dist/src/render/dish.js.map +1 -0
  77. package/dist/src/render/layout.d.ts +23 -0
  78. package/dist/src/render/layout.d.ts.map +1 -0
  79. package/dist/src/render/layout.js +33 -0
  80. package/dist/src/render/layout.js.map +1 -0
  81. package/dist/src/render/menu.d.ts +53 -0
  82. package/dist/src/render/menu.d.ts.map +1 -0
  83. package/dist/src/render/menu.js +99 -0
  84. package/dist/src/render/menu.js.map +1 -0
  85. package/dist/src/render/render-block.d.ts +19 -0
  86. package/dist/src/render/render-block.d.ts.map +1 -1
  87. package/dist/src/render/render-block.js +50 -39
  88. package/dist/src/render/render-block.js.map +1 -1
  89. package/dist/src/render/strings.d.ts +15 -0
  90. package/dist/src/render/strings.d.ts.map +1 -0
  91. package/dist/src/render/strings.js +23 -0
  92. package/dist/src/render/strings.js.map +1 -0
  93. package/dist/src/render/term-archive.d.ts +10 -4
  94. package/dist/src/render/term-archive.d.ts.map +1 -1
  95. package/dist/src/render/term-archive.js +25 -16
  96. package/dist/src/render/term-archive.js.map +1 -1
  97. package/dist/src/render/widget-areas.d.ts +13 -0
  98. package/dist/src/render/widget-areas.d.ts.map +1 -0
  99. package/dist/src/render/widget-areas.js +20 -0
  100. package/dist/src/render/widget-areas.js.map +1 -0
  101. package/dist/theme.config.d.ts +1 -1
  102. package/dist/theme.config.js +2 -2
  103. package/dist/theme.config.js.map +1 -1
  104. package/package.json +5 -5
  105. package/src/index.ts +3 -0
  106. package/src/render/blocks/accordion.ts +18 -47
  107. package/src/render/blocks/collection-list.ts +245 -133
  108. package/src/render/blocks/cta.ts +20 -11
  109. package/src/render/blocks/embed.ts +45 -36
  110. package/src/render/blocks/faq.ts +32 -25
  111. package/src/render/blocks/feature-grid.ts +83 -33
  112. package/src/render/blocks/gallery.ts +39 -21
  113. package/src/render/blocks/hero.ts +41 -34
  114. package/src/render/blocks/logo-strip.ts +27 -25
  115. package/src/render/blocks/logos.ts +34 -43
  116. package/src/render/blocks/media-figure.ts +49 -24
  117. package/src/render/blocks/pricing-table.ts +30 -42
  118. package/src/render/blocks/prose.ts +18 -9
  119. package/src/render/blocks/quote.ts +29 -19
  120. package/src/render/blocks/stat-counter.ts +24 -29
  121. package/src/render/blocks/stats.ts +30 -40
  122. package/src/render/blocks/testimonial.ts +17 -22
  123. package/src/render/chrome.ts +113 -95
  124. package/src/render/dish.ts +195 -0
  125. package/src/render/layout.ts +64 -0
  126. package/src/render/menu.ts +117 -0
  127. package/src/render/render-block.ts +51 -40
  128. package/src/render/strings.ts +44 -0
  129. package/src/render/term-archive.ts +78 -44
  130. package/src/render/widget-areas.ts +23 -0
  131. package/src/styles/archive.css +46 -56
  132. package/src/styles/base.css +807 -554
  133. package/src/styles/blocks.css +723 -641
  134. package/src/styles/menu.css +482 -0
  135. package/src/styles/theme.css +24 -25
  136. package/src/styles/tokens.css +239 -225
  137. package/src/styles/widgets.css +853 -0
  138. package/tokens.json +16 -16
@@ -1,9 +1,9 @@
1
1
  import type { CollectionListBlock } from '@cogenta/blocks'
2
2
  import {
3
- blockHeadingTag,
4
3
  buildCollectionListQuery,
5
4
  type ContentEntry,
6
5
  entryExcerpt,
6
+ entryHref,
7
7
  entryImage,
8
8
  entryTitle,
9
9
  type HeadingTag,
@@ -11,179 +11,291 @@ import {
11
11
  h,
12
12
  heading,
13
13
  nestedHeadingTag,
14
+ type QueryRequest,
14
15
  type RenderContext,
15
16
  renderImageSource,
16
17
  } from '@cogenta/theme-kit'
18
+ import { section, sectionHead } from '../layout.js'
19
+ import {
20
+ currencyOf,
21
+ formatPrice,
22
+ groupBySection,
23
+ isVegetarian,
24
+ type MenuSection,
25
+ priceOf,
26
+ sectionOf,
27
+ } from '../menu.js'
28
+ import { menuString } from '../strings.js'
17
29
 
18
30
  /**
19
- * The only block of the seventeen that reads data at render time — contract
20
- * B marks it `runtime: 'server'` for that reason. The read is *not* done
21
- * here: `query` builds the request from the block's own fields
22
- * (`@cogenta/theme-kit`'s `buildCollectionListQuery`, shared across every
23
- * theme), the caller awaits `ctx.content.list(...)` before rendering starts,
24
- * and this function stays a pure function of the entries handed to it.
25
- */
26
- export { buildCollectionListQuery as query }
27
-
28
- /**
29
- * The signature piece of this theme: a `collectionList` on a `menu_item`-shaped
30
- * collection is rendered as a real, priced menu, not a card grid.
31
+ * A list of entries, read by what the entries are rather than by a setting.
32
+ *
33
+ * - **A menu.** Every entry has a price, so this is a menu, and it is set like
34
+ * a printed one: grouped by the section each dish names (in the order the
35
+ * dishes arrive), each section under its name in small capitals, each dish
36
+ * on one line (its name in the display serif, a dotted leader, the price in
37
+ * tabular figures aligned right) with its description under it in muted
38
+ * text. `grid` sets the sections in two columns on a wide screen, `list` in
39
+ * one column at a reading measure. `carousel` turns the same dishes into a
40
+ * band of plates: the photograph at 4:5, the name and the price under it,
41
+ * in a row that scrolls sideways.
42
+ * - **Photographs.** Entries with a picture and no price (a note from the
43
+ * kitchen, a private room): 4:5 photographs with the name under them, as an
44
+ * arrow link, and the summary.
45
+ * - **An index.** Anything else: ruled rows of titles and their summaries.
31
46
  *
32
- * Contract B fixes no "menu" block (a menu of dishes is a `collectionList`
33
- * grouped and formatted by the theme, per `docs/lots/L25-templates-pro.md`
34
- * "pièges connus" — a new block would need an RFC). Grouping reads the
35
- * entry's raw `category` field directly: a theme sees a whole entry, not
36
- * only the fields contract A's system columns declare, and `category` is
37
- * exactly the field the `restaurant` blueprint's `menu_item` collection
38
- * names for this. An entry with no `category` (or a collection that never
39
- * declares one) still renders, in one unlabelled group — grouping is a
40
- * presentation choice, never a requirement placed back on the schema.
47
+ * The page an entry is shown on is never listed on itself: a "more starters"
48
+ * list on a dish's own page skips the dish above it.
41
49
  *
42
- * `price` is read as a plain number (contract A's `f.number`, not a
43
- * currency type — commerce's real money lives in contract E, untouched
44
- * here) and formatted with `Intl.NumberFormat`'s `currency: 'EUR'`: the
45
- * currency itself is not part of what a theme's `RenderContext` carries, so
46
- * EUR is this theme's own documented demo default, exactly the way the
47
- * `store` blueprint's own catalogue prices need no currency conversion
48
- * either. A site that prices in another currency still gets a sensibly
49
- * localised number; only the currency symbol would need a real per-site
50
- * setting this contract does not yet offer.
50
+ * Each picture repeats the link of its name, so it is taken out of the tab
51
+ * order and hidden from assistive technology: one link per entry is
52
+ * announced, the name.
51
53
  */
52
54
 
53
- function priceLabel(entry: ContentEntry, ctx: RenderContext): string | null {
54
- const raw = entry.price
55
- if (typeof raw !== 'number' || !Number.isFinite(raw)) return null
56
- try {
57
- return new Intl.NumberFormat(ctx.locale, { style: 'currency', currency: 'EUR' }).format(raw)
58
- } catch {
59
- // An `Intl` failure (an exotic `ctx.locale`) is still a real number worth
60
- // showing, just without locale-aware grouping/decimal marks.
61
- return raw.toFixed(2)
62
- }
63
- }
55
+ type Shape = 'menu' | 'plates' | 'photos' | 'index'
64
56
 
65
- function categoryOf(entry: ContentEntry): string {
66
- const raw = entry.category
67
- return typeof raw === 'string' && raw.trim() !== '' ? raw : ''
57
+ function shapeOf(
58
+ entries: readonly ContentEntry[],
59
+ ctx: RenderContext,
60
+ layout: CollectionListBlock['layout'],
61
+ ): Shape {
62
+ if (entries.every((entry) => priceOf(entry.price) !== undefined)) {
63
+ return layout === 'carousel' ? 'plates' : 'menu'
64
+ }
65
+ if (entries.every((entry) => entryImage(entry, ctx) !== undefined)) return 'photos'
66
+ return 'index'
68
67
  }
69
68
 
70
- /** One level below `tag`, clamped at `h6` — the same rule `nestedHeadingTag` applies, extended one step further for the dish name under its category heading. */
71
- function stepDown(tag: HeadingTag): HeadingTag {
72
- const level = Math.min(6, Number(tag.slice(1)) + 1)
73
- return `h${level}` as HeadingTag
69
+ function price(entry: ContentEntry, ctx: RenderContext, className: string): HtmlElement {
70
+ const amount = priceOf(entry.price) as number
71
+ return h(
72
+ 'data',
73
+ { class: className, value: String(amount) },
74
+ formatPrice(amount, currencyOf(entry.currency), ctx.locale),
75
+ )
74
76
  }
75
77
 
76
- function renderDish(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
77
- const excerpt = entryExcerpt(entry)
78
- const price = priceLabel(entry, ctx)
79
- const photo = entryImage(entry, ctx, { width: 120, height: 120, fit: 'cover' })
78
+ function dish(entry: ContentEntry, ctx: RenderContext): HtmlElement {
79
+ const description = entryExcerpt(entry)
80
+ const vegetarian = isVegetarian(entry)
80
81
  return h(
81
82
  'li',
82
- { class: 'cg-menu__dish' },
83
- photo === undefined
83
+ { class: 'cr-menu__item', 'data-vegetarian': vegetarian ? 'true' : undefined },
84
+ h(
85
+ 'p',
86
+ { class: 'cr-menu__line' },
87
+ h('a', { class: 'cr-menu__name', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
88
+ h('span', { class: 'cr-menu__leader', 'aria-hidden': 'true' }),
89
+ price(entry, ctx, 'cr-menu__price'),
90
+ ),
91
+ description === undefined && !vegetarian
84
92
  ? null
85
93
  : h(
86
- 'div',
87
- { class: 'cg-menu__dish-photo' },
88
- renderImageSource(photo, { sizes: '5rem', className: 'cg-menu__dish-image' }),
94
+ 'p',
95
+ { class: 'cr-menu__description' },
96
+ description === undefined ? null : h('span', { class: 'cr-menu__text' }, description),
97
+ vegetarian
98
+ ? h('span', { class: 'cr-menu__diet' }, menuString(ctx.locale, 'vegetarian'))
99
+ : null,
89
100
  ),
90
- h(
91
- 'div',
92
- { class: 'cg-menu__dish-body' },
93
- h(
94
- 'div',
95
- { class: 'cg-menu__dish-row' },
96
- heading(tag, { class: 'cg-menu__dish-name' }, entryTitle(entry, ctx)),
97
- // Leader and price are one wrapping unit (`cg-menu__dish-tail`): on
98
- // a narrow screen where the name alone fills the row, the dotted
99
- // leader must move to the next line together with the price it
100
- // points to — never left dangling at the end of the name's line
101
- // with the price it introduces stranded on a line of its own.
102
- h(
103
- 'span',
104
- { class: 'cg-menu__dish-tail' },
105
- h('span', { class: 'cg-menu__dish-leader', 'aria-hidden': 'true' }),
106
- price === null ? null : h('span', { class: 'cg-menu__dish-price' }, price),
107
- ),
108
- ),
109
- excerpt === undefined ? null : h('p', { class: 'cg-menu__dish-description' }, excerpt),
110
- ),
111
101
  )
112
102
  }
113
103
 
114
- function renderGroup(
115
- category: string,
116
- entries: readonly ContentEntry[],
104
+ function menuSection(
105
+ group: MenuSection<ContentEntry>,
117
106
  ctx: RenderContext,
118
- groupTag: HeadingTag,
107
+ tag: HeadingTag,
108
+ named: boolean,
119
109
  ): HtmlElement {
120
- const dishTag = stepDown(groupTag)
121
110
  return h(
122
111
  'div',
123
- { class: 'cg-menu__group' },
124
- category === '' ? null : heading(groupTag, { class: 'cg-menu__group-title' }, category),
112
+ { class: 'cr-menu__section' },
113
+ group.title === undefined || !named
114
+ ? null
115
+ : heading(tag, { class: 'cr-menu__section-title' }, group.title),
125
116
  h(
126
117
  'ul',
127
- { class: 'cg-menu__dishes' },
128
- entries.map((entry) => renderDish(entry, ctx, dishTag)),
118
+ { class: 'cr-menu__items' },
119
+ group.items.map((entry) => dish(entry, ctx)),
120
+ ),
121
+ )
122
+ }
123
+
124
+ function mediaLink(
125
+ entry: ContentEntry,
126
+ ctx: RenderContext,
127
+ className: string,
128
+ sizes: string,
129
+ ): HtmlElement | null {
130
+ const source = entryImage(entry, ctx)
131
+ if (source === undefined) return null
132
+ return h(
133
+ 'a',
134
+ {
135
+ class: `${className}__media`,
136
+ href: entryHref(entry, ctx),
137
+ tabindex: '-1',
138
+ 'aria-hidden': 'true',
139
+ },
140
+ renderImageSource(source, { className: `${className}__image`, sizes }),
141
+ )
142
+ }
143
+
144
+ function plate(entry: ContentEntry, ctx: RenderContext): HtmlElement {
145
+ const group = sectionOf(entry)
146
+ return h(
147
+ 'li',
148
+ { class: 'cr-plates__item', 'data-media': String(entryImage(entry, ctx) !== undefined) },
149
+ mediaLink(entry, ctx, 'cr-plates', '(min-width: 64rem) 22vw, 70vw'),
150
+ h(
151
+ 'p',
152
+ { class: 'cr-plates__line' },
153
+ h('a', { class: 'cr-plates__name', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
154
+ price(entry, ctx, 'cr-plates__price'),
129
155
  ),
156
+ group === undefined ? null : h('p', { class: 'cr-plates__section' }, group),
130
157
  )
131
158
  }
132
159
 
133
- function groupByCategory(entries: readonly ContentEntry[]): readonly [string, ContentEntry[]][] {
134
- const order: string[] = []
135
- const groups = new Map<string, ContentEntry[]>()
136
- for (const entry of entries) {
137
- const key = categoryOf(entry)
138
- const bucket = groups.get(key)
139
- if (bucket === undefined) {
140
- groups.set(key, [entry])
141
- order.push(key)
142
- } else {
143
- bucket.push(entry)
160
+ function photo(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
161
+ const excerpt = entryExcerpt(entry)
162
+ return h(
163
+ 'li',
164
+ { class: 'cr-photos__item' },
165
+ mediaLink(entry, ctx, 'cr-photos', '(min-width: 64rem) 28vw, 100vw'),
166
+ heading(
167
+ tag,
168
+ { class: 'cr-photos__name' },
169
+ h('a', { class: 'cr-arrow-link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
170
+ ),
171
+ excerpt === undefined ? null : h('p', { class: 'cr-photos__text' }, excerpt),
172
+ )
173
+ }
174
+
175
+ function indexItem(entry: ContentEntry, ctx: RenderContext, tag: HeadingTag): HtmlElement {
176
+ const excerpt = entryExcerpt(entry)
177
+ return h(
178
+ 'li',
179
+ { class: 'cr-index__item' },
180
+ heading(
181
+ tag,
182
+ { class: 'cr-index__name' },
183
+ h('a', { class: 'cr-index__link', href: entryHref(entry, ctx) }, entryTitle(entry, ctx)),
184
+ ),
185
+ excerpt === undefined ? null : h('p', { class: 'cr-index__text' }, excerpt),
186
+ )
187
+ }
188
+
189
+ /** The entries that are not the page being rendered. */
190
+ function othersThanThisPage(
191
+ entries: readonly ContentEntry[],
192
+ ctx: RenderContext,
193
+ ): readonly ContentEntry[] {
194
+ return entries.filter((entry) => entryHref(entry, ctx) !== ctx.url.pathname)
195
+ }
196
+
197
+ function body(
198
+ shape: Shape,
199
+ shown: readonly ContentEntry[],
200
+ block: CollectionListBlock,
201
+ ctx: RenderContext,
202
+ tag: HeadingTag,
203
+ ): HtmlElement {
204
+ switch (shape) {
205
+ case 'menu': {
206
+ const groups = groupBySection(shown)
207
+ // One section under a title of its own ("The other starters") is named
208
+ // by that title already; printing "Starters" under it again says nothing.
209
+ const named = groups.length > 1 || block.title === undefined
210
+ return h(
211
+ 'div',
212
+ {
213
+ class: 'cr-list__items cr-menu',
214
+ 'data-sections': String(groups.length),
215
+ },
216
+ groups.map((group) => menuSection(group, ctx, tag, named)),
217
+ )
144
218
  }
219
+ case 'plates':
220
+ return h(
221
+ 'div',
222
+ {
223
+ class: 'cr-list__viewport',
224
+ role: 'region',
225
+ 'aria-label': block.title ?? ctx.t('collection.carousel'),
226
+ tabindex: '0',
227
+ },
228
+ h(
229
+ 'ul',
230
+ { class: 'cr-list__items cr-plates' },
231
+ shown.map((entry) => plate(entry, ctx)),
232
+ ),
233
+ )
234
+ case 'photos': {
235
+ const list = h(
236
+ 'ul',
237
+ {
238
+ class: 'cr-list__items cr-photos',
239
+ 'data-count': String(Math.min(shown.length, 3)),
240
+ },
241
+ shown.map((entry) => photo(entry, ctx, tag)),
242
+ )
243
+ return block.layout === 'carousel'
244
+ ? h(
245
+ 'div',
246
+ {
247
+ class: 'cr-list__viewport',
248
+ role: 'region',
249
+ 'aria-label': block.title ?? ctx.t('collection.carousel'),
250
+ tabindex: '0',
251
+ },
252
+ list,
253
+ )
254
+ : list
255
+ }
256
+ case 'index':
257
+ return h(
258
+ 'ul',
259
+ { class: 'cr-list__items cr-index' },
260
+ shown.map((entry) => indexItem(entry, ctx, tag)),
261
+ )
145
262
  }
146
- return order.map((key) => [key, groups.get(key) as ContentEntry[]])
147
263
  }
148
264
 
149
265
  export function renderCollectionList(
150
266
  block: CollectionListBlock,
151
267
  ctx: RenderContext,
152
- entries: readonly ContentEntry[],
268
+ fetched: readonly ContentEntry[],
153
269
  ): HtmlElement {
154
- const hasTitle = block.title !== undefined
155
- const groupTag = nestedHeadingTag('collectionList', hasTitle)
156
- const groups = groupByCategory(entries)
157
- const body =
158
- entries.length === 0
159
- ? h('p', { class: 'cg-menu__empty' }, ctx.t('collection.empty'))
160
- : h(
161
- 'div',
162
- { class: 'cg-menu__groups' },
163
- groups.map(([category, items]) => renderGroup(category, items, ctx, groupTag)),
164
- )
270
+ const titled = block.title !== undefined
271
+ const shown = othersThanThisPage(fetched, ctx)
272
+ const tag = nestedHeadingTag('collectionList', titled)
165
273
 
166
- return h(
274
+ if (shown.length === 0) {
275
+ return section(
276
+ 'section',
277
+ 'collectionList',
278
+ 'cr-list',
279
+ { 'data-layout': block.layout, 'data-shape': 'empty' },
280
+ 'div',
281
+ sectionHead('collectionList', block.title),
282
+ h('p', { class: 'cr-empty' }, ctx.t('collection.empty')),
283
+ )
284
+ }
285
+
286
+ const shape = shapeOf(shown, ctx, block.layout)
287
+ return section(
167
288
  'section',
168
- { class: 'cg-menu', 'data-block': 'collectionList', 'data-layout': block.layout },
169
- hasTitle
170
- ? heading(
171
- blockHeadingTag('collectionList') ?? 'h2',
172
- { class: 'cg-menu__title', 'data-field': 'title' },
173
- block.title ?? '',
174
- )
175
- : null,
176
- block.layout === 'carousel'
177
- ? h(
178
- 'div',
179
- {
180
- class: 'cg-menu__viewport',
181
- role: 'region',
182
- 'aria-label': block.title ?? ctx.t('collection.carousel'),
183
- tabindex: '0',
184
- },
185
- body,
186
- )
187
- : body,
289
+ 'collectionList',
290
+ 'cr-list',
291
+ { 'data-layout': block.layout, 'data-shape': shape },
292
+ 'div',
293
+ sectionHead('collectionList', block.title),
294
+ body(shape, shown, block, ctx, tag),
188
295
  )
189
296
  }
297
+
298
+ /** The query this block needs, fetched by the host before any markup is built. */
299
+ export function query(block: CollectionListBlock): QueryRequest {
300
+ return buildCollectionListQuery(block)
301
+ }
@@ -7,25 +7,34 @@ import {
7
7
  heading,
8
8
  type RenderContext,
9
9
  } from '@cogenta/theme-kit'
10
+ import { optionalText, section } from '../layout.js'
10
11
 
11
12
  /**
12
- * "Book now / Call us" — a centred, dark, full-bleed close, mirroring the
13
- * hero's own centred treatment so the page opens and closes on the same
14
- * note. `--cg-scrim` gives the panel its depth; nothing here is a literal
15
- * colour (R3/isolation test).
13
+ * A call to action set as a line of the page, not a coloured box: a hairline
14
+ * in ink across the container, the title large and light on the first seven
15
+ * columns, the sentence and the actions on the last four. How to book a
16
+ * table, the private room, a gift for someone else.
17
+ *
18
+ * A site that wants a band asks for one with the `background` variant, which
19
+ * lays the same line on the paper's darker stock.
16
20
  */
17
21
  export function renderCta(block: CtaBlock, ctx: RenderContext): HtmlElement {
18
- return h(
22
+ return section(
19
23
  'section',
20
- { class: 'cg-close', 'data-block': 'cta' },
24
+ 'cta',
25
+ 'cr-cta',
26
+ {},
27
+ 'div',
21
28
  heading(
22
29
  blockHeadingTag('cta') ?? 'h2',
23
- { class: 'cg-close__title', 'data-field': 'title' },
30
+ { class: 'cr-cta__title', 'data-field': 'title' },
24
31
  block.title,
25
32
  ),
26
- block.text === undefined
27
- ? null
28
- : h('p', { class: 'cg-close__text', 'data-field': 'text' }, block.text),
29
- actionList(ctx, block.actions, block.title),
33
+ h(
34
+ 'div',
35
+ { class: 'cr-cta__body' },
36
+ optionalText('p', 'cr-cta__text', block.text, { 'data-field': 'text' }),
37
+ actionList(ctx, block.actions, block.title),
38
+ ),
30
39
  )
31
40
  }
@@ -1,23 +1,20 @@
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
- * this theme's own blueprint uses `provider: 'other'` for a map embed
7
- * (`consentRequired: true`), which this renderer never has a trusted frame
8
- * source for anyway, so it always renders the honest placeholder card below.
6
+ * A film of the kitchen, a map, a recording from a third party, eight
7
+ * columns wide from the third.
9
8
  *
10
- * When `consentRequired` is true (or the provider/URL cannot be turned into
11
- * a trusted frame source) the block renders a self-contained card and an
12
- * outbound link — no `<iframe>`, no `<script>`, no preconnect. An embed
13
- * that "only" loads a thumbnail has already handed the visitor's IP address
14
- * and a cookie to the provider, which is the exact transfer consent was
15
- * supposed to gate. The theme ships no consent *button*, because it ships
16
- * no JavaScript: granting consent is a site-wide decision that belongs to
17
- * the consent layer, not to a block.
9
+ * Nothing here contacts a third party before the visitor consents: when
10
+ * `consentRequired` is set, or the provider has no cookie-free frame (a map,
11
+ * a post), a notice takes the frame's place. The notice is a short ruled
12
+ * card, not an empty frame kept at the video's ratio: without a script there
13
+ * is nothing to load into that frame later, and a large blank rectangle on a
14
+ * page is a hole, not a placeholder. A small label in small capitals, one
15
+ * sentence, and one arrow link to the original. No preconnect, no poster
16
+ * fetched from the provider, no consent button (this theme ships no script).
18
17
  */
19
-
20
- /** `null` means "this provider has no embeddable frame we trust". */
21
18
  function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string | null {
22
19
  const url = URL.parse(rawUrl)
23
20
  if (url === null) return null
@@ -47,59 +44,71 @@ function frameSource(provider: EmbedBlock['provider'], rawUrl: string): string |
47
44
  case 'soundcloud':
48
45
  return `https://w.soundcloud.com/player/?url=${encodeURIComponent(rawUrl)}`
49
46
  default:
50
- // bluesky, mastodon, other (a map link, in this theme's own
51
- // blueprint): the link card is the rendering.
47
+ // bluesky, mastodon, other: a post embed is a script tag and a map is
48
+ // a third-party page, neither of which this theme loads. The notice is
49
+ // the rendering.
52
50
  return null
53
51
  }
54
52
  }
55
53
 
56
- function consentCard(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
54
+ function notice(block: EmbedBlock, ctx: RenderContext, reason: string): HtmlElement {
57
55
  return h(
58
56
  'div',
59
- { class: 'cg-embed__placeholder' },
60
- h('p', { class: 'cg-embed__provider', 'aria-hidden': 'true' }, block.provider),
61
- h('p', { class: 'cg-embed__notice' }, reason),
57
+ { class: 'cr-embed__notice' },
58
+ h('p', { class: 'cr-embed__label' }, ctx.t('embed.label')),
59
+ h('p', { class: 'cr-embed__reason' }, reason),
62
60
  h(
63
61
  'a',
64
62
  {
65
- class: 'cg-embed__link',
63
+ class: 'cr-arrow-link cr-embed__link',
66
64
  href: ctx.link(block.url),
67
65
  rel: 'noopener noreferrer nofollow',
68
66
  },
69
- ctx.t('embed.open', { provider: block.provider }),
67
+ block.provider === 'other'
68
+ ? ctx.t('embed.openOther')
69
+ : ctx.t('embed.open', { provider: block.provider }),
70
70
  ),
71
71
  )
72
72
  }
73
73
 
74
74
  export function renderEmbed(block: EmbedBlock, ctx: RenderContext): HtmlElement {
75
75
  const source = block.consentRequired ? null : frameSource(block.provider, block.url)
76
+ // Without a ratio the frame would collapse and shift the layout as it loads.
76
77
  const ratio = aspectRatio(block.ratio) ?? '16 / 9'
77
78
 
78
- return h(
79
+ return section(
79
80
  'div',
81
+ 'embed',
82
+ 'cr-embed',
80
83
  {
81
- class: 'cg-embed',
82
- 'data-block': 'embed',
83
84
  'data-provider': block.provider,
84
85
  'data-consent': block.consentRequired ? 'required' : 'not-required',
85
- style: `--cg-ratio:${ratio}`,
86
+ 'data-frame': source === null ? 'notice' : 'player',
86
87
  },
88
+ 'div',
87
89
  source === null
88
- ? consentCard(
90
+ ? notice(
89
91
  block,
90
92
  ctx,
91
93
  block.consentRequired
92
94
  ? ctx.t('embed.consentRequired', { provider: block.provider })
93
95
  : ctx.t('embed.unsupported', { provider: block.provider }),
94
96
  )
95
- : h('iframe', {
96
- class: 'cg-embed__frame',
97
- src: source,
98
- title: ctx.t('embed.title', { provider: block.provider }),
99
- loading: 'lazy',
100
- referrerpolicy: 'strict-origin-when-cross-origin',
101
- allow: 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
102
- allowfullscreen: true,
103
- }),
97
+ : h(
98
+ 'div',
99
+ { class: 'cr-embed__frame', style: `aspect-ratio:${ratio}` },
100
+ h('iframe', {
101
+ class: 'cr-embed__player',
102
+ src: source,
103
+ // A frame with no accessible name is announced as "frame" and
104
+ // nothing else (WCAG 4.1.2).
105
+ title: ctx.t('embed.title', { provider: block.provider }),
106
+ loading: 'lazy',
107
+ referrerpolicy: 'strict-origin-when-cross-origin',
108
+ allow:
109
+ 'accelerometer; clipboard-write; encrypted-media; picture-in-picture; fullscreen',
110
+ allowfullscreen: true,
111
+ }),
112
+ ),
104
113
  )
105
114
  }