@cogenta/theme-portfolio 1.1.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/src/index.d.ts +9 -0
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +10 -1
  4. package/dist/src/index.js.map +1 -1
  5. package/dist/src/render/blocks/accordion.d.ts.map +1 -1
  6. package/dist/src/render/blocks/accordion.js +10 -18
  7. package/dist/src/render/blocks/accordion.js.map +1 -1
  8. package/dist/src/render/blocks/collection-list.d.ts +0 -20
  9. package/dist/src/render/blocks/collection-list.d.ts.map +1 -1
  10. package/dist/src/render/blocks/collection-list.js +59 -66
  11. package/dist/src/render/blocks/collection-list.js.map +1 -1
  12. package/dist/src/render/blocks/cta.d.ts +4 -4
  13. package/dist/src/render/blocks/cta.d.ts.map +1 -1
  14. package/dist/src/render/blocks/cta.js +15 -10
  15. package/dist/src/render/blocks/cta.js.map +1 -1
  16. package/dist/src/render/blocks/embed.d.ts.map +1 -1
  17. package/dist/src/render/blocks/embed.js +16 -31
  18. package/dist/src/render/blocks/embed.js.map +1 -1
  19. package/dist/src/render/blocks/faq.d.ts.map +1 -1
  20. package/dist/src/render/blocks/faq.js +12 -17
  21. package/dist/src/render/blocks/faq.js.map +1 -1
  22. package/dist/src/render/blocks/feature-grid.d.ts.map +1 -1
  23. package/dist/src/render/blocks/feature-grid.js +26 -23
  24. package/dist/src/render/blocks/feature-grid.js.map +1 -1
  25. package/dist/src/render/blocks/gallery.d.ts +0 -14
  26. package/dist/src/render/blocks/gallery.d.ts.map +1 -1
  27. package/dist/src/render/blocks/gallery.js +29 -21
  28. package/dist/src/render/blocks/gallery.js.map +1 -1
  29. package/dist/src/render/blocks/hero.d.ts +8 -8
  30. package/dist/src/render/blocks/hero.d.ts.map +1 -1
  31. package/dist/src/render/blocks/hero.js +17 -18
  32. package/dist/src/render/blocks/hero.js.map +1 -1
  33. package/dist/src/render/blocks/logo-strip.d.ts.map +1 -1
  34. package/dist/src/render/blocks/logo-strip.js +9 -14
  35. package/dist/src/render/blocks/logo-strip.js.map +1 -1
  36. package/dist/src/render/blocks/logos.d.ts.map +1 -1
  37. package/dist/src/render/blocks/logos.js +14 -19
  38. package/dist/src/render/blocks/logos.js.map +1 -1
  39. package/dist/src/render/blocks/media-figure.d.ts +8 -12
  40. package/dist/src/render/blocks/media-figure.d.ts.map +1 -1
  41. package/dist/src/render/blocks/media-figure.js +20 -20
  42. package/dist/src/render/blocks/media-figure.js.map +1 -1
  43. package/dist/src/render/blocks/pricing-table.d.ts.map +1 -1
  44. package/dist/src/render/blocks/pricing-table.js +17 -20
  45. package/dist/src/render/blocks/pricing-table.js.map +1 -1
  46. package/dist/src/render/blocks/prose.d.ts +10 -9
  47. package/dist/src/render/blocks/prose.d.ts.map +1 -1
  48. package/dist/src/render/blocks/prose.js +29 -10
  49. package/dist/src/render/blocks/prose.js.map +1 -1
  50. package/dist/src/render/blocks/quote.d.ts +5 -12
  51. package/dist/src/render/blocks/quote.d.ts.map +1 -1
  52. package/dist/src/render/blocks/quote.js +9 -15
  53. package/dist/src/render/blocks/quote.js.map +1 -1
  54. package/dist/src/render/blocks/stat-counter.d.ts.map +1 -1
  55. package/dist/src/render/blocks/stat-counter.js +10 -12
  56. package/dist/src/render/blocks/stat-counter.js.map +1 -1
  57. package/dist/src/render/blocks/stats.d.ts.map +1 -1
  58. package/dist/src/render/blocks/stats.js +7 -15
  59. package/dist/src/render/blocks/stats.js.map +1 -1
  60. package/dist/src/render/blocks/testimonial.d.ts +4 -14
  61. package/dist/src/render/blocks/testimonial.d.ts.map +1 -1
  62. package/dist/src/render/blocks/testimonial.js +9 -18
  63. package/dist/src/render/blocks/testimonial.js.map +1 -1
  64. package/dist/src/render/chrome.d.ts.map +1 -1
  65. package/dist/src/render/chrome.js +93 -90
  66. package/dist/src/render/chrome.js.map +1 -1
  67. package/dist/src/render/layout.d.ts +61 -0
  68. package/dist/src/render/layout.d.ts.map +1 -0
  69. package/dist/src/render/layout.js +134 -0
  70. package/dist/src/render/layout.js.map +1 -0
  71. package/dist/src/render/project.d.ts +27 -0
  72. package/dist/src/render/project.d.ts.map +1 -0
  73. package/dist/src/render/project.js +82 -0
  74. package/dist/src/render/project.js.map +1 -0
  75. package/dist/src/render/render-block.d.ts +17 -7
  76. package/dist/src/render/render-block.d.ts.map +1 -1
  77. package/dist/src/render/render-block.js +47 -34
  78. package/dist/src/render/render-block.js.map +1 -1
  79. package/dist/src/render/term-archive.d.ts +0 -11
  80. package/dist/src/render/term-archive.d.ts.map +1 -1
  81. package/dist/src/render/term-archive.js +36 -24
  82. package/dist/src/render/term-archive.js.map +1 -1
  83. package/dist/src/render/widget-areas.d.ts +14 -0
  84. package/dist/src/render/widget-areas.d.ts.map +1 -0
  85. package/dist/src/render/widget-areas.js +21 -0
  86. package/dist/src/render/widget-areas.js.map +1 -0
  87. package/dist/src/render/work.d.ts +37 -0
  88. package/dist/src/render/work.d.ts.map +1 -0
  89. package/dist/src/render/work.js +49 -0
  90. package/dist/src/render/work.js.map +1 -0
  91. package/dist/theme.config.d.ts +4 -7
  92. package/dist/theme.config.d.ts.map +1 -1
  93. package/dist/theme.config.js +6 -9
  94. package/dist/theme.config.js.map +1 -1
  95. package/package.json +4 -4
  96. package/src/index.ts +10 -1
  97. package/src/render/blocks/accordion.ts +22 -42
  98. package/src/render/blocks/collection-list.ts +83 -137
  99. package/src/render/blocks/cta.ts +24 -12
  100. package/src/render/blocks/embed.ts +39 -47
  101. package/src/render/blocks/faq.ts +24 -33
  102. package/src/render/blocks/feature-grid.ts +38 -41
  103. package/src/render/blocks/gallery.ts +34 -28
  104. package/src/render/blocks/hero.ts +31 -28
  105. package/src/render/blocks/logo-strip.ts +18 -20
  106. package/src/render/blocks/logos.ts +22 -43
  107. package/src/render/blocks/media-figure.ts +28 -20
  108. package/src/render/blocks/pricing-table.ts +31 -32
  109. package/src/render/blocks/prose.ts +38 -13
  110. package/src/render/blocks/quote.ts +18 -18
  111. package/src/render/blocks/stat-counter.ts +19 -37
  112. package/src/render/blocks/stats.ts +18 -33
  113. package/src/render/blocks/testimonial.ts +16 -22
  114. package/src/render/chrome.ts +99 -97
  115. package/src/render/layout.ts +177 -0
  116. package/src/render/project.ts +140 -0
  117. package/src/render/render-block.ts +49 -45
  118. package/src/render/term-archive.ts +92 -70
  119. package/src/render/widget-areas.ts +25 -0
  120. package/src/render/work.ts +124 -0
  121. package/src/styles/archive.css +61 -21
  122. package/src/styles/base.css +763 -648
  123. package/src/styles/blocks.css +697 -1093
  124. package/src/styles/theme.css +22 -10
  125. package/src/styles/tokens.css +174 -252
  126. package/src/styles/widgets.css +948 -0
  127. package/src/styles/work.css +405 -0
  128. package/tokens.json +19 -19
@@ -1,49 +1,55 @@
1
1
  import type { GalleryBlock } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * The carousel ships **no JavaScript**. It is a scroll-snapping list, which
6
- * gives touch swiping, trackpad scrolling and — because the list is focusable
7
- * and labelled — arrow-key scrolling, with none of the failure modes of a
8
- * scripted carousel: no auto-advance to fight (WCAG 2.2.2), no focus stolen
9
- * from an off-screen slide, and it still works before hydration.
6
+ * The sequence of pictures a case study is made of. Every picture keeps its
7
+ * own shape: a poster stays a poster, a spread stays a spread. The layout the
8
+ * editor chose decides the rhythm:
10
9
  *
11
- * Every tile carries a decorative index badge (`aria-hidden`, a CSS counter)
12
- * — a numbered contact sheet rather than a plain image wall, and never a
13
- * second name for the picture a screen reader would announce.
14
- *
15
- * A focusable scrollable region needs an accessible name, or a screen reader
16
- * announces an unlabelled tab stop; that is WCAG 2.4.3 and 4.1.2 both.
10
+ * - `grid`: a sequence of three places on twelve columns, a large picture
11
+ * across seven columns beside a smaller one across five set to the same
12
+ * bottom line, then one across the whole container.
13
+ * - `masonry`: two columns (three on a wide screen) at each picture's own
14
+ * shape.
15
+ * - `carousel`: one row at a single height that scrolls and snaps, focusable
16
+ * and labelled; touch, trackpad and arrow keys are all native. No script.
17
17
  */
18
+ function sizesFor(layout: GalleryBlock['layout'], place: number): string {
19
+ if (layout === 'carousel') return '(min-width: 64rem) 40rem, 80vw'
20
+ if (layout === 'masonry') return '(min-width: 64rem) 30rem, 50vw'
21
+ if (place === 3) return '(min-width: 96rem) 92rem, 100vw'
22
+ return place === 1 ? '(min-width: 64rem) 54rem, 100vw' : '(min-width: 64rem) 38rem, 100vw'
23
+ }
24
+
18
25
  export function renderGallery(block: GalleryBlock, ctx: RenderContext): HtmlElement {
19
26
  const items = h(
20
27
  'ul',
21
- { class: 'cg-gallery__items' },
22
- block.items.map((item) =>
23
- h(
28
+ { class: 'cg-pictures__items', 'data-count': String(block.items.length) },
29
+ block.items.map((item, index) => {
30
+ const place = (index % 3) + 1
31
+ return h(
24
32
  'li',
25
- { class: 'cg-gallery__item' },
26
- h('span', { class: 'cg-gallery__index', 'aria-hidden': 'true' }),
33
+ { class: 'cg-pictures__item', 'data-place': String(place) },
27
34
  image(ctx, item.media, {
28
- className: 'cg-gallery__image',
29
- sizes: '(min-width: 48rem) 22rem, 60vw',
35
+ className: 'cg-pictures__image',
36
+ sizes: sizesFor(block.layout, place),
30
37
  }),
31
- ),
32
- ),
38
+ )
39
+ }),
33
40
  )
34
41
 
35
- return h(
36
- 'section',
37
- {
38
- class: 'cg-block cg-gallery',
39
- 'data-block': 'gallery',
40
- 'data-layout': block.layout,
41
- },
42
+ return section(
43
+ 'div',
44
+ 'gallery',
45
+ 'cg-pictures',
46
+ { 'data-layout': block.layout },
47
+ 'div',
42
48
  block.layout === 'carousel'
43
49
  ? h(
44
50
  'div',
45
51
  {
46
- class: 'cg-gallery__viewport',
52
+ class: 'cg-pictures__viewport',
47
53
  role: 'region',
48
54
  'aria-label': ctx.t('gallery.carousel'),
49
55
  tabindex: '0',
@@ -8,47 +8,50 @@ import {
8
8
  image,
9
9
  type RenderContext,
10
10
  } from '@cogenta/theme-kit'
11
+ import { section } from '../layout.js'
11
12
 
12
13
  /**
13
- * The hero carries the page's `h1` — contract B declares `headingLevel: 'h1'`
14
- * for it, and `renderPage` relies on that to avoid emitting a second one.
14
+ * The statement. `hero` carries the page's own `h1` (contract B), and on a
15
+ * studio's home page that heading is what the studio says it does, set very
16
+ * large in the display width across eleven columns. The eyebrow is a small
17
+ * line above it in the text face, never a badge. The subtitle drops to the
18
+ * right half of the grid, so the opening is asymmetric by construction; the
19
+ * actions follow it as underlined words.
15
20
  *
16
- * Structurally distinct from a centred banner: the eyebrow becomes a running
17
- * index mark set in the mono register (`— eyebrow`), the title is a display
18
- * headline that overflows its own column on wide screens (`blocks.css` gives
19
- * it a fluid `clamp()`), and the media is offset into the title's own
20
- * negative margin rather than sitting in a tidy second column — the overlap
21
- * is what makes this a poster, not a slide.
21
+ * A photograph or a film, when there is one, runs under the words across the
22
+ * whole container at 16:9: the one image above the fold, loaded eagerly.
22
23
  */
23
24
  export function renderHero(block: HeroBlock, ctx: RenderContext): HtmlElement {
24
25
  const tag = blockHeadingTag('hero') ?? 'h1'
25
- return h(
26
+ return section(
26
27
  'section',
27
- { class: 'cg-block cg-hero', 'data-block': 'hero' },
28
- h(
29
- 'div',
30
- { class: 'cg-hero__frame' },
31
- block.eyebrow === undefined
32
- ? null
33
- : h('p', { class: 'cg-hero__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
34
- heading(tag, { class: 'cg-hero__title', 'data-field': 'title' }, block.title),
35
- block.subtitle === undefined
36
- ? null
37
- : h('p', { class: 'cg-hero__subtitle', 'data-field': 'subtitle' }, block.subtitle),
38
- actionList(ctx, block.actions, ctx.t('hero.actions')),
39
- ),
28
+ 'hero',
29
+ 'cg-statement',
30
+ { 'data-media': block.media === undefined ? 'none' : 'image' },
31
+ 'div',
32
+ block.eyebrow === undefined
33
+ ? null
34
+ : h('p', { class: 'cg-statement__eyebrow', 'data-field': 'eyebrow' }, block.eyebrow),
35
+ heading(tag, { class: 'cg-statement__title', 'data-field': 'title' }, block.title),
36
+ block.subtitle === undefined && (block.actions === undefined || block.actions.length === 0)
37
+ ? null
38
+ : h(
39
+ 'div',
40
+ { class: 'cg-statement__aside' },
41
+ block.subtitle === undefined
42
+ ? null
43
+ : h('p', { class: 'cg-statement__subtitle', 'data-field': 'subtitle' }, block.subtitle),
44
+ actionList(ctx, block.actions, ctx.t('hero.actions')),
45
+ ),
40
46
  block.media === undefined
41
47
  ? null
42
48
  : h(
43
49
  'div',
44
- { class: 'cg-hero__media' },
45
- // The only image on the page that is above the fold by construction,
46
- // so the only one that must not be lazy: lazy-loading the LCP element
47
- // is a measured Lighthouse regression, not a theoretical one.
50
+ { class: 'cg-statement__media' },
48
51
  image(ctx, block.media, {
49
- className: 'cg-hero__image',
52
+ className: 'cg-statement__image',
50
53
  loading: 'eager',
51
- sizes: '(min-width: 64rem) 46vw, 100vw',
54
+ sizes: '(min-width: 96rem) 92rem, 100vw',
52
55
  }),
53
56
  ),
54
57
  )
@@ -1,38 +1,36 @@
1
1
  import type { LogoStripBlock, LogoStripItem } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * Lighter-weight than `logos` on purpose (RFC 0001): no per-mark `name`/
6
- * `url`, so no link, no accessible-name field to write, and no numbered
7
- * ledger row — this is the quieter "as seen in" strip, marks set edge to
8
- * edge with no dividers, desaturated exactly like `logos.ts`'s own marks
9
- * (the theme's one visual language for "a logo not currently in focus").
10
- * The image's own alt text (set once, in the media library) is what names
11
- * each mark, same as `logos.ts` falls back to when a link is absent — never
12
- * invented here, which is why `image` is called with no `altFrom`.
6
+ * A line of marks between two hairlines: the caption in the first three
7
+ * columns, the marks along the rest of the row at one height. `logoStrip`
8
+ * carries no name per mark, so each image's own alt text (set once, in the
9
+ * media library) names it. In the dark scheme the row sits on a light plate
10
+ * for the same reason `logos` does.
13
11
  */
14
12
  function renderItem(item: LogoStripItem, ctx: RenderContext): HtmlElement {
15
13
  return h(
16
14
  'li',
17
- { class: 'cg-logostrip__item' },
18
- image(ctx, item.media, {
19
- className: 'cg-logostrip__image',
20
- variant: { fit: 'contain' },
21
- }),
15
+ { class: 'cg-credits__item' },
16
+ image(ctx, item.media, { className: 'cg-credits__image', variant: { fit: 'contain' } }),
22
17
  )
23
18
  }
24
19
 
25
20
  export function renderLogoStrip(block: LogoStripBlock, ctx: RenderContext): HtmlElement {
26
- return h(
27
- 'figure',
28
- { class: 'cg-block cg-logostrip', 'data-block': 'logoStrip' },
21
+ return section(
22
+ 'div',
23
+ 'logoStrip',
24
+ 'cg-credits',
25
+ { 'data-captioned': block.caption === undefined ? 'false' : 'true' },
26
+ 'div',
27
+ block.caption === undefined
28
+ ? null
29
+ : h('p', { class: 'cg-credits__caption', 'data-field': 'caption' }, block.caption),
29
30
  h(
30
31
  'ul',
31
- { class: 'cg-logostrip__items' },
32
+ { class: 'cg-credits__items' },
32
33
  block.logos.map((item) => renderItem(item, ctx)),
33
34
  ),
34
- block.caption === undefined
35
- ? null
36
- : h('figcaption', { class: 'cg-logostrip__caption', 'data-field': 'caption' }, block.caption),
37
35
  )
38
36
  }
@@ -1,63 +1,42 @@
1
1
  import type { LogoItem, LogosBlock } from '@cogenta/blocks'
2
- import {
3
- blockHeadingTag,
4
- type HtmlElement,
5
- h,
6
- heading,
7
- image,
8
- type RenderContext,
9
- } from '@cogenta/theme-kit'
2
+ import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section, sectionHead } from '../layout.js'
10
4
 
11
5
  /**
12
- * Contract B says the organisation's name **is** the accessible name of the
13
- * link. So the logo image carries it as alt text when the media entity has
14
- * none, and no visually hidden duplicate is added: a link whose only content
15
- * is an image with alt text is already named.
16
- *
17
- * Rendered as a single-row ledger rather than a grid of tiles — a running
18
- * mono index beside each mark, in a desaturated register that resolves to
19
- * full ink only on hover or focus (`blocks.css`), which is the theme's own
20
- * take on the "selected clients" strip.
6
+ * Marks of clients or partners, each on its own plate in a ruled grid, the
7
+ * name under the plate in the caption size. The plate stays light in the dark
8
+ * scheme, so a dark wordmark drawn for white paper is never lost on black.
9
+ * The organisation's name is the image's accessible name when the media
10
+ * entity has none, and a mark with a URL links it.
21
11
  */
22
- function renderItem(item: LogoItem, ctx: RenderContext, index: number): HtmlElement {
23
- const logo = image(ctx, item.media, {
24
- className: 'cg-logo__image',
12
+ function renderItem(item: LogoItem, ctx: RenderContext): HtmlElement {
13
+ const mark = image(ctx, item.media, {
14
+ className: 'cg-marks__image',
25
15
  altFrom: item.name,
26
16
  variant: { fit: 'contain' },
27
17
  })
28
18
  return h(
29
19
  'li',
30
- { class: 'cg-logo' },
31
- h(
32
- 'span',
33
- { class: 'cg-logo__index', 'aria-hidden': 'true' },
34
- String(index + 1).padStart(2, '0'),
35
- ),
20
+ { class: 'cg-marks__item' },
36
21
  item.url === undefined
37
- ? logo
38
- : h(
39
- 'a',
40
- { class: 'cg-logo__link', href: ctx.link(item.url), rel: 'noopener noreferrer' },
41
- logo,
42
- ),
22
+ ? h('div', { class: 'cg-marks__plate' }, mark)
23
+ : h('a', { class: 'cg-marks__plate', href: item.url, rel: 'noopener noreferrer' }, mark),
24
+ h('p', { class: 'cg-marks__name' }, item.name),
43
25
  )
44
26
  }
45
27
 
46
28
  export function renderLogos(block: LogosBlock, ctx: RenderContext): HtmlElement {
47
- return h(
29
+ return section(
48
30
  'section',
49
- { class: 'cg-block cg-logos', 'data-block': 'logos' },
50
- block.title === undefined
51
- ? null
52
- : heading(
53
- blockHeadingTag('logos') ?? 'h2',
54
- { class: 'cg-logos__title', 'data-field': 'title' },
55
- block.title,
56
- ),
31
+ 'logos',
32
+ 'cg-marks',
33
+ { 'data-count': String(block.items.length) },
34
+ 'div',
35
+ sectionHead('logos', block.title),
57
36
  h(
58
37
  'ul',
59
- { class: 'cg-logos__items' },
60
- block.items.map((item, index) => renderItem(item, ctx, index)),
38
+ { class: 'cg-marks__items' },
39
+ block.items.map((item) => renderItem(item, ctx)),
61
40
  ),
62
41
  )
63
42
  }
@@ -1,38 +1,42 @@
1
1
  import type { MediaFigureBlock } from '@cogenta/blocks'
2
2
  import { aspectRatio, type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * `<figure>`/`<figcaption>` rather than a div and a paragraph: the
6
- * association between the picture and its caption is then in the markup,
7
- * and a screen reader announces the caption as belonging to the image
8
- * instead of as loose text after it.
6
+ * An image placed on the grid by its alignment, square-cornered, with a
7
+ * small caption under it on the image's own left edge and the credit after
8
+ * the caption. Alignment is how a case study gets its rhythm of large and
9
+ * small pictures:
9
10
  *
10
- * The "Fig. 01" plate number that precedes the caption is drawn entirely
11
- * from a CSS counter (`counter-increment: cg-figure` in `blocks.css`) — it
12
- * is not stored data, so it renumbers itself as figures are added or
13
- * removed and never needs updating here.
14
- *
15
- * `align` is written as a data attribute, never as a class: contract B's
16
- * values are `start`/`end`, an intent that mirrors in right-to-left
17
- * locales, and the skin decides what it means.
11
+ * - `center` (the default): columns 3 to 10.
12
+ * - `start`: the first seven columns. `end`: the last seven.
13
+ * - `wide`: the whole container.
14
+ * - `full`: edge to edge, the caption returning to the grid.
18
15
  */
19
16
  export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext): HtmlElement {
20
17
  const ratio = aspectRatio(block.ratio)
18
+ const align = block.align ?? 'center'
21
19
  const hasCaption = block.caption !== undefined || block.credit !== undefined
22
- return h(
23
- 'figure',
20
+ return section(
21
+ 'div',
22
+ 'mediaFigure',
23
+ 'cg-figure',
24
24
  {
25
- class: 'cg-block cg-figure',
26
- 'data-block': 'mediaFigure',
27
- 'data-align': block.align ?? 'center',
25
+ 'data-align': align,
28
26
  style: ratio === undefined ? undefined : `--cg-ratio:${ratio}`,
29
27
  },
28
+ 'figure',
30
29
  h(
31
30
  'div',
32
31
  { class: 'cg-figure__frame' },
33
32
  image(ctx, block.media, {
34
- className: 'cg-figure__media',
35
- sizes: '(min-width: 48rem) 42rem, 100vw',
33
+ className: 'cg-figure__image',
34
+ sizes:
35
+ align === 'full'
36
+ ? '100vw'
37
+ : align === 'wide'
38
+ ? '(min-width: 96rem) 92rem, 100vw'
39
+ : '(min-width: 64rem) 56rem, 100vw',
36
40
  }),
37
41
  ),
38
42
  hasCaption
@@ -41,7 +45,11 @@ export function renderMediaFigure(block: MediaFigureBlock, ctx: RenderContext):
41
45
  { class: 'cg-figure__caption' },
42
46
  block.caption === undefined
43
47
  ? null
44
- : h('span', { class: 'cg-figure__plate' }, block.caption),
48
+ : h(
49
+ 'span',
50
+ { class: 'cg-figure__caption-text', 'data-field': 'caption' },
51
+ block.caption,
52
+ ),
45
53
  block.credit === undefined
46
54
  ? null
47
55
  : h('span', { class: 'cg-figure__credit', 'data-field': 'credit' }, block.credit),
@@ -1,7 +1,6 @@
1
1
  import type { PricingTableBlock, PricingTier } from '@cogenta/blocks'
2
2
  import {
3
3
  actionLink,
4
- blockHeadingTag,
5
4
  type HeadingTag,
6
5
  type HtmlElement,
7
6
  h,
@@ -9,60 +8,60 @@ import {
9
8
  nestedHeadingTag,
10
9
  type RenderContext,
11
10
  } from '@cogenta/theme-kit'
11
+ import { section, sectionHead } from '../layout.js'
12
12
 
13
13
  /**
14
- * `highlighted` is an editorial signal, never a colour (RFC 0001's own
15
- * comment on the field): it is written as `data-highlighted="true"` for the
16
- * skin to key off, and the tier additionally carries `aria-current="true"`
17
- * — the closest native semantic for "the one the author means you to pick"
18
- * among a set of siblings.
14
+ * Fees or engagements, set as the columns of a ruled table rather than as
15
+ * cards: each tier opens on a hairline, its name in the text face, the price
16
+ * in the display width with tabular numerals and the interval after it, what
17
+ * it includes as a list divided by hairlines, and its action at the foot. The
18
+ * tier the editor highlighted opens on a heavy rule in ink instead, and its
19
+ * action is the filled one; the others keep their words underlined.
19
20
  */
20
21
  function renderTier(tier: PricingTier, ctx: RenderContext, tag: HeadingTag): HtmlElement {
22
+ const highlighted = tier.highlighted === true
21
23
  return h(
22
24
  'li',
23
- {
24
- class: 'cg-pricing__tier',
25
- 'data-highlighted': tier.highlighted === true ? 'true' : undefined,
26
- 'aria-current': tier.highlighted === true ? 'true' : undefined,
27
- },
28
- heading(tag, { class: 'cg-pricing__name' }, tier.name),
25
+ { class: 'cg-fees__tier', 'data-highlighted': highlighted ? 'true' : undefined },
26
+ heading(tag, { class: 'cg-fees__name' }, tier.name),
29
27
  h(
30
28
  'p',
31
- { class: 'cg-pricing__price' },
32
- tier.price,
33
- tier.interval === undefined
34
- ? null
35
- : h('span', { class: 'cg-pricing__interval' }, tier.interval),
29
+ { class: 'cg-fees__price' },
30
+ h('span', { class: 'cg-fees__amount' }, tier.price),
31
+ tier.interval === undefined ? null : h('span', { class: 'cg-fees__interval' }, tier.interval),
36
32
  ),
37
33
  tier.features.length === 0
38
34
  ? null
39
35
  : h(
40
36
  'ul',
41
- { class: 'cg-pricing__features' },
42
- tier.features.map((feature) => h('li', {}, feature)),
37
+ { class: 'cg-fees__features' },
38
+ tier.features.map((feature) => h('li', { class: 'cg-fees__feature' }, feature)),
43
39
  ),
44
40
  tier.action === undefined
45
41
  ? null
46
- : h('div', { class: 'cg-pricing__action' }, actionLink(ctx, tier.action)),
42
+ : h(
43
+ 'div',
44
+ { class: 'cg-fees__action' },
45
+ actionLink(ctx, {
46
+ ...tier.action,
47
+ emphasis: tier.action.emphasis ?? (highlighted ? 'primary' : 'secondary'),
48
+ }),
49
+ ),
47
50
  )
48
51
  }
49
52
 
50
53
  export function renderPricingTable(block: PricingTableBlock, ctx: RenderContext): HtmlElement {
51
- const hasTitle = block.title !== undefined
52
- const tierTag = nestedHeadingTag('pricingTable', hasTitle)
53
- return h(
54
+ const tierTag = nestedHeadingTag('pricingTable', block.title !== undefined)
55
+ return section(
54
56
  'section',
55
- { class: 'cg-block cg-pricing', 'data-block': 'pricingTable' },
56
- hasTitle
57
- ? heading(
58
- blockHeadingTag('pricingTable') ?? 'h2',
59
- { class: 'cg-pricing__title', 'data-field': 'title' },
60
- block.title ?? '',
61
- )
62
- : null,
57
+ 'pricingTable',
58
+ 'cg-fees',
59
+ { 'data-count': String(Math.min(block.tiers.length, 4)) },
60
+ 'div',
61
+ sectionHead('pricingTable', block.title),
63
62
  h(
64
63
  'ul',
65
- { class: 'cg-pricing__tiers' },
64
+ { class: 'cg-fees__tiers' },
66
65
  block.tiers.map((tier) => renderTier(tier, ctx, tierTag)),
67
66
  ),
68
67
  )
@@ -1,22 +1,47 @@
1
- import type { ProseBlock } from '@cogenta/blocks'
1
+ import type { ProseBlock, RichTextDocument } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, type RenderContext, renderRichText } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * `prose` declares `headingLevel: 'none'`: it contributes no heading of its
6
- * own. Whatever headings appear come from the rich text document, whose
7
- * vocabulary starts at `h2`.
6
+ * Running text on the studio grid. When the text opens with a subhead, the
7
+ * subhead moves into the first three columns as the section's label and the
8
+ * text starts at the fourth, level with it: the asymmetric page of a case
9
+ * study. Without a subhead the text simply takes the reading column.
8
10
  *
9
- * The wrapper is deliberately plain — a rich text document is arbitrary
10
- * editorial content, and the only honest DOM shape for arbitrary content is
11
- * "here it is". The distinctive editorial treatment (a dropped first
12
- * capital, an oversized `h2`, a hairline-and-number rule before each
13
- * heading) lives entirely in `blocks.css`, driven off the very structure
14
- * `renderRichText` already produces.
11
+ * On a project page a long text sets in two columns on a wide screen
12
+ * (`work.css`). A short one stays in one: two columns of four lines each
13
+ * read as a mistake, not as a page. Everywhere else the text keeps one
14
+ * column at the reading measure.
15
15
  */
16
+
17
+ /** Below this many words, a text is kept in one column. */
18
+ export const TWO_COLUMN_MIN_WORDS = 110
19
+
20
+ function wordCount(document: RichTextDocument): number {
21
+ let words = 0
22
+ for (const node of document) {
23
+ if (node._type !== 'block') continue
24
+ for (const span of node.children) {
25
+ words += span.text.split(/\s+/u).filter((word) => word !== '').length
26
+ }
27
+ }
28
+ return words
29
+ }
30
+
16
31
  export function renderProse(block: ProseBlock, ctx: RenderContext): HtmlElement {
17
- return h(
32
+ const [first, ...rest] = block.body
33
+ const opensOnSubhead = first !== undefined && first._type === 'block' && first.style === 'h2'
34
+ const body = opensOnSubhead ? rest : block.body
35
+ return section(
36
+ 'div',
37
+ 'prose',
38
+ 'cg-prose',
39
+ {
40
+ 'data-label': opensOnSubhead ? 'true' : 'false',
41
+ 'data-length': wordCount(body) >= TWO_COLUMN_MIN_WORDS ? 'long' : 'short',
42
+ },
18
43
  'div',
19
- { class: 'cg-block cg-prose', 'data-block': 'prose' },
20
- renderRichText(ctx, block.body),
44
+ opensOnSubhead ? h('div', { class: 'cg-prose__label' }, renderRichText(ctx, [first])) : null,
45
+ h('div', { class: 'cg-prose__body' }, renderRichText(ctx, body)),
21
46
  )
22
47
  }
@@ -1,27 +1,27 @@
1
1
  import type { QuoteBlock } from '@cogenta/blocks'
2
2
  import { type HtmlElement, h, image, type RenderContext } from '@cogenta/theme-kit'
3
+ import { section } from '../layout.js'
3
4
 
4
5
  /**
5
- * `<figure><blockquote>…</blockquote><figcaption>` is the attribution
6
- * pattern the HTML spec prescribes: putting the author inside the
7
- * `<blockquote>` would claim the author's name is part of what was said.
8
- *
9
- * Set left-aligned at display scale rather than centred — a pull quote that
10
- * fills the column edge to edge, the way a magazine breaks one out of a
11
- * feature. The attribution is set in the mono register, the theme's
12
- * register for credits and metadata.
13
- *
14
- * The avatar is decorative here — the name is right beside it in text — so
15
- * its media entity's alt text is expected to be empty. `image` still writes
16
- * the attribute either way.
6
+ * A client's words, set large in the text width from the fourth column, with
7
+ * typographic quotation marks from the stylesheet (`quotes`), and the speaker
8
+ * under them in the caption size: their name, then their role. The portrait,
9
+ * when there is one, is a small square: it is decorative (the name is beside
10
+ * it in text), so it keeps the media library's own empty `alt`.
17
11
  */
18
12
  export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement {
19
- const hasAttribution =
20
- block.author !== undefined || block.role !== undefined || block.avatar !== undefined
21
- return h(
13
+ const hasAttribution = block.author !== undefined || block.role !== undefined
14
+ return section(
15
+ 'div',
16
+ 'quote',
17
+ 'cg-quote',
18
+ {},
22
19
  'figure',
23
- { class: 'cg-block cg-quote', 'data-block': 'quote' },
24
- h('blockquote', { class: 'cg-quote__text' }, h('p', { 'data-field': 'text' }, block.text)),
20
+ h(
21
+ 'blockquote',
22
+ { class: 'cg-quote__quote' },
23
+ h('p', { class: 'cg-quote__text', 'data-field': 'text' }, block.text),
24
+ ),
25
25
  hasAttribution
26
26
  ? h(
27
27
  'figcaption',
@@ -34,7 +34,7 @@ export function renderQuote(block: QuoteBlock, ctx: RenderContext): HtmlElement
34
34
  }),
35
35
  h(
36
36
  'span',
37
- { class: 'cg-quote__names' },
37
+ { class: 'cg-quote__who' },
38
38
  block.author === undefined
39
39
  ? null
40
40
  : h('span', { class: 'cg-quote__author', 'data-field': 'author' }, block.author),