@getvitops/emdash 2.0.0-alpha.2 → 2.0.0-alpha.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/emdash",
3
- "version": "2.0.0-alpha.2",
3
+ "version": "2.0.0-alpha.4",
4
4
  "description": "EmDash CMS native plugin for the Vitops design system — Portable Text block types editors can insert from the admin, rendered with the design-system web components and CSS framework.",
5
5
  "homepage": "https://docs.vitops.ca",
6
6
  "bugs": "https://github.com/getvitops/design-system/issues",
@@ -31,15 +31,15 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@getvitops/generator": "8.3.0-alpha.2",
35
- "@getvitops/utils": "8.3.0-alpha.2"
34
+ "@getvitops/generator": "8.3.0-alpha.4",
35
+ "@getvitops/utils": "8.3.0-alpha.4"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@astrojs/cloudflare": "^14.0.0",
39
39
  "@astrojs/react": "^7.0.0",
40
40
  "@emdash-cms/cloudflare": "^1.4.0",
41
- "@getvitops/astro": "8.3.0-alpha.2",
42
- "@getvitops/core": "8.3.0-alpha.2",
41
+ "@getvitops/astro": "8.3.0-alpha.4",
42
+ "@getvitops/core": "8.3.0-alpha.4",
43
43
  "@types/react": "^19.3.0",
44
44
  "@types/react-dom": "^19.3.0",
45
45
  "astro": "^7.4.4",
@@ -20,6 +20,8 @@ const items = rows<Record<string, unknown>>(value.items)
20
20
  open: Boolean(row.open),
21
21
  }))
22
22
  .filter((item) => item.summary);
23
+ const { spriteBase = '/vitops' } = Astro.props as { spriteBase?: string };
24
+ const sprite = `${spriteBase}/icons.svg`;
23
25
  const group = value.exclusive ? `accordion-${value._key ?? 'group'}` : undefined;
24
26
  import { rootAttrs } from './attrs.ts';
25
27
  ---
@@ -29,7 +31,14 @@ import { rootAttrs } from './attrs.ts';
29
31
  <div {...rootAttrs(value, ['accordion'])}>
30
32
  {items.map((item) => (
31
33
  <details name={group} open={item.open}>
32
- <summary>{item.summary}</summary>
34
+ <summary>
35
+ <span>{item.summary}</span>
36
+ <span class="icon details-icon" aria-hidden="true">
37
+ <svg>
38
+ <use href={`${sprite}#icon-chevron-down`} />
39
+ </svg>
40
+ </span>
41
+ </summary>
33
42
  {item.body.map((p) => (
34
43
  <p>{p}</p>
35
44
  ))}
@@ -46,7 +46,7 @@ const items = rows<Record<string, unknown>>(value.items)
46
46
  return (
47
47
  <li>
48
48
  <Tag
49
- class:list={[kind, item.tone.style && item.tone.className, item.href && 'link']}
49
+ class:list={[kind, item.tone.style && item.tone.surface, item.href && 'link']}
50
50
  href={item.href || undefined}
51
51
  style={item.tone.style || undefined}
52
52
  >
@@ -39,7 +39,7 @@ import { rootAttrs } from './attrs.ts';
39
39
  {
40
40
  (heading || body) && (
41
41
  <div
42
- {...rootAttrs(value, ['callout', tone.className, value.solid && 'tone--solid', value.align === 'center' && 'callout--center'], joinStyle(tone.style))}
42
+ {...rootAttrs(value, ['callout', value.solid ? tone.className : tone.surface, value.solid && 'tone--solid', value.align === 'center' && 'callout--center'], joinStyle(tone.style))}
43
43
  >
44
44
  <div class="callout__body">
45
45
  {heading && <Heading class="callout__title font-title">{heading}</Heading>}
@@ -19,7 +19,7 @@
19
19
  import Cards from '@getvitops/astro/components/Cards.astro';
20
20
  import Subgrid from '@getvitops/astro/components/Subgrid.astro';
21
21
  import RichText from './RichText.astro';
22
- import { joinStyle, resolveTone, rowTone } from './tone.ts';
22
+ import { joinStyle, resolveTheme, resolveTone, rowTone } from './tone.ts';
23
23
  import { headingTag, lines, resolveBlockValue, rows, safeHref, text } from './value.ts';
24
24
 
25
25
  interface Value {
@@ -44,6 +44,7 @@ const cards = rows<Record<string, unknown>>(value.cards)
44
44
  ctaLabel: text(row.ctaLabel),
45
45
  ctaHref: safeHref(row.ctaHref),
46
46
  style: rowTone(row),
47
+ theme: resolveTheme(row.theme),
47
48
  }))
48
49
  .filter((card) => card.title || card.body || card.image);
49
50
  const columns = Math.min(4, Math.max(1, Math.round(Number(value.columns) || 3)));
@@ -59,8 +60,8 @@ import { rootAttrs } from './attrs.ts';
59
60
  <Grid {...rootAttrs(value, [], `--subgrid-cols: ${columns}`)}>
60
61
  {cards.map((card) => (
61
62
  <li
62
- class:list={['card', 'subgrid-card', panel && tone.className]}
63
- style={panel ? joinStyle(tone.style, card.style) : card.style}
63
+ class:list={['card', 'subgrid-card', panel && tone.surface, card.theme?.className]}
64
+ style={joinStyle(panel ? joinStyle(tone.style, card.style) : card.style, card.theme?.style)}
64
65
  >
65
66
  {card.image && <img src={card.image} alt={card.imageAlt} loading="lazy" />}
66
67
  {card.title && <Heading class="card__title font-heading">{card.title}</Heading>}
@@ -8,7 +8,7 @@
8
8
  * Flat fields, because a block cannot contain other blocks.
9
9
  */
10
10
  import RichText from './RichText.astro';
11
- import { joinStyle, resolveTone, rowTone } from './tone.ts';
11
+ import { joinStyle, resolveTheme, resolveTone, rowTone } from './tone.ts';
12
12
  import {
13
13
  accentRuns,
14
14
  outlineTag,
@@ -61,6 +61,7 @@ const cards = rows<Record<string, unknown>>(value.cards)
61
61
  ctaLabel: text(row.ctaLabel),
62
62
  ctaHref: safeHref(row.ctaHref),
63
63
  style: rowTone(row),
64
+ theme: resolveTheme(row.theme),
64
65
  }))
65
66
  .filter((card) => card.title || card.body);
66
67
  import { rootAttrs } from './attrs.ts';
@@ -68,7 +69,7 @@ import { rootAttrs } from './attrs.ts';
68
69
 
69
70
  {
70
71
  (heading || body) && (
71
- <section {...rootAttrs(value, [tone.style && tone.className], joinStyle(tone.style))}>
72
+ <section {...rootAttrs(value, [tone.style && tone.surface], joinStyle(tone.style))}>
72
73
  <div class={split ? 'split split-1-2' : 'centered'}>
73
74
  <div class="rhythm">
74
75
  {eyebrow && <p class="font-eyebrow">{eyebrow}</p>}
@@ -100,7 +101,7 @@ import { rootAttrs } from './attrs.ts';
100
101
  {cards.length > 0 && (
101
102
  <ul class="subgrid centered" role="list" style={`--subgrid-cols: ${Math.min(4, cards.length)}`}>
102
103
  {cards.map((card) => (
103
- <li class:list={['card', 'subgrid-card', card.style && 'tone']} style={card.style}>
104
+ <li class:list={['card', 'subgrid-card', card.style && 'tone tone--tint', card.theme?.className]} style={joinStyle(card.style, card.theme?.style)}>
104
105
  {card.eyebrow && <p class="font-eyebrow">{card.eyebrow}</p>}
105
106
  {card.title && <CardHeading class="font-heading">{card.title}</CardHeading>}
106
107
  {card.body && (
@@ -13,6 +13,10 @@ interface Value {
13
13
  logos?: unknown;
14
14
  grayscale?: boolean;
15
15
  size?: string;
16
+ kicker?: string;
17
+ note?: string;
18
+ linkLabel?: string;
19
+ linkHref?: string;
16
20
  }
17
21
 
18
22
  const HEIGHTS: Record<string, string> = { sm: '1.75rem', md: '2.5rem', lg: '3.5rem' };
@@ -23,11 +27,23 @@ const logos = rows<Record<string, unknown>>(value.logos)
23
27
  .map((row) => ({ image: safeHref(row.image), alt: text(row.alt), href: safeHref(row.href) }))
24
28
  .filter((logo) => logo.image || logo.alt);
25
29
  const height = HEIGHTS[text(value.size)];
30
+ const kicker = text(value.kicker);
31
+ const note = text(value.note);
32
+ const linkLabel = text(value.linkLabel);
33
+ const linkHref = safeHref(value.linkHref);
34
+ const hasLink = Boolean(linkLabel && linkHref);
35
+ // A wrapper exists only when there is a kicker, note or link to hold. The attributes stay on the
36
+ // list in both cases, as they do on `vitops_cards`, so a selector on the list works either way.
37
+ const framed = Boolean(kicker || note || hasLink);
38
+ const Wrap = framed ? 'div' : Fragment;
39
+ const wrapProps = framed ? { class: 'logos-block' } : {};
26
40
  import { rootAttrs } from './attrs.ts';
27
41
  ---
28
42
 
29
43
  {
30
44
  logos.length > 0 && (
45
+ <Wrap {...wrapProps}>
46
+ {kicker && <p class="logos__kicker font-eyebrow">{kicker}</p>}
31
47
  <ul
32
48
  {...rootAttrs(value, ['logos', value.grayscale && 'logos--grayscale'], height ? `--logos-height: ${height}` : undefined)}
33
49
  role="list"
@@ -47,5 +63,14 @@ import { rootAttrs } from './attrs.ts';
47
63
  </li>
48
64
  ))}
49
65
  </ul>
66
+ {note && <p class="logos__note font-caption">{note}</p>}
67
+ {hasLink && (
68
+ <p class="logos__link">
69
+ <a class="link" href={linkHref}>
70
+ {linkLabel}
71
+ </a>
72
+ </p>
73
+ )}
74
+ </Wrap>
50
75
  )
51
76
  }
@@ -66,7 +66,7 @@ import { rootAttrs } from './attrs.ts';
66
66
  {
67
67
  items.length > 0 && (
68
68
  <div
69
- {...rootAttrs(value, ['navblock', toned && tone.className], toned ? joinStyle(tone.style) : undefined)}
69
+ {...rootAttrs(value, ['navblock', toned && tone.surface], toned ? joinStyle(tone.style) : undefined)}
70
70
  >
71
71
  {heading && <Heading class="font-heading">{heading}</Heading>}
72
72
  {variant === 'sitenav' ? (
@@ -20,6 +20,8 @@ const plans = rows<Record<string, unknown>>(value.plans)
20
20
  name: text(row.name),
21
21
  price: text(row.price),
22
22
  period: text(row.period),
23
+ yearly: text(row.yearly),
24
+ note: text(row.note),
23
25
  features: lines(row.features),
24
26
  ctaLabel: text(row.ctaLabel),
25
27
  ctaHref: safeHref(row.ctaHref),
@@ -41,10 +43,12 @@ import { rootAttrs } from './attrs.ts';
41
43
  <li class:list={['pricing-card', plan.featured && 'pricing-card--featured']}>
42
44
  <div class="pricing-card__header">
43
45
  {plan.name && <Heading class="font-title">{plan.name}</Heading>}
46
+ {plan.note && <p class="pricing-card__note font-caption">{plan.note}</p>}
44
47
  </div>
45
48
  <p class="pricing-card__price">
46
49
  {plan.price}
47
50
  {plan.period && <small class="font-caption"> {plan.period}</small>}
51
+ {plan.yearly && <small class="pricing-card__yearly font-caption">{plan.yearly}</small>}
48
52
  </p>
49
53
  <ul class="pricing-card__features" role="list">
50
54
  {plan.features.map((feature) => (
@@ -81,7 +81,7 @@ import { rootAttrs } from './attrs.ts';
81
81
  {
82
82
  reviews.length > 0 ? (
83
83
  <div
84
- {...rootAttrs(value, ['reviews', toned && tone.className], toned ? joinStyle(tone.style) : undefined)}
84
+ {...rootAttrs(value, ['reviews', toned && tone.surface], toned ? joinStyle(tone.style) : undefined)}
85
85
  >
86
86
  {layout === 'carousel' ? (
87
87
  <Carousel label={label}>
@@ -8,7 +8,9 @@
8
8
  * Composed from the framework's layout primitives: `.region` for vertical rhythm, `.centered`
9
9
  * for the tracks (the `width` field picks `breakout`/`spotlight`/`fullbleed`), `.section` for
10
10
  * the head and the heading-left `split` layout, `.tone` for the backdrop. The heading is a
11
- * real heading at the editor's `level`.
11
+ * real heading at the editor's `level`, with `*accent*` runs. Level 1 plus `cta_label`/`secondary_label`
12
+ * make it the page opener, so a hero is not nested in a section. `stack: rhythm` puts `.rhythm` on the
13
+ * inner element so head and body are spaced by the vertical-rhythm scale.
12
14
  *
13
15
  * The body is rendered by EmDash's `<PortableText>`, which merges this plugin's
14
16
  * `blockComponents`, so a `vitops_cards` or `vitops_actionLink` placed in it renders with its
@@ -22,7 +24,7 @@
22
24
  const { PortableText } = await import('emdash/ui');
23
25
  import { unrenderedTypes } from './unrendered.ts';
24
26
  import { joinStyle, resolveTone } from './tone.ts';
25
- import { outlineTag, resolveBlockValue, text } from './value.ts';
27
+ import { accentRuns, outlineTag, resolveBlockValue, safeHref, text } from './value.ts';
26
28
 
27
29
  interface Value {
28
30
  kicker?: string;
@@ -36,6 +38,12 @@ interface Value {
36
38
  color?: string;
37
39
  solid?: boolean;
38
40
  theme?: string;
41
+ // A block TYPE's slugs must be lowercase (like `class_name`), so these are snake_case.
42
+ cta_label?: string;
43
+ cta_href?: string;
44
+ secondary_label?: string;
45
+ secondary_href?: string;
46
+ stack?: string;
39
47
  }
40
48
 
41
49
  const WIDTHS: Record<string, string> = {
@@ -57,22 +65,49 @@ const tone = resolveTone(value.tone, value.color);
57
65
  const solid = Boolean(value.solid);
58
66
  const layout = text(value.layout);
59
67
  const width = WIDTHS[text(value.width)];
60
- const hasHead = Boolean(kicker || heading || sub);
68
+ const headingRuns = accentRuns(heading);
69
+ const ctaLabel = text(value.cta_label);
70
+ const ctaHref = safeHref(value.cta_href);
71
+ const secondaryLabel = text(value.secondary_label);
72
+ const secondaryHref = safeHref(value.secondary_href);
73
+ const hasActions = Boolean((ctaLabel && ctaHref) || (secondaryLabel && secondaryHref));
74
+ const stacked = text(value.stack) === 'rhythm';
75
+ const hasHead = Boolean(kicker || heading || sub || hasActions);
61
76
  import { rootAttrs } from './attrs.ts';
62
77
  ---
63
78
 
64
79
  {
65
80
  (hasHead || body.length > 0) && (
66
81
  <section
67
- {...rootAttrs(value, ['section', 'region', tone.style && tone.className, tone.style && solid && 'tone--solid', layout === 'split' && 'section--split', layout === 'center' && 'section--center'], joinStyle(tone.style))}
82
+ {...rootAttrs(value, ['section', 'region', tone.style && (solid ? tone.className : tone.surface), tone.style && solid && 'tone--solid', layout === 'split' && 'section--split', layout === 'center' && 'section--center'], joinStyle(tone.style))}
68
83
  >
69
84
  <div class="centered">
70
- <div class:list={['section__inner', width]}>
85
+ <div class:list={['section__inner', width, stacked && 'rhythm']}>
71
86
  {hasHead && (
72
87
  <header class="section__head">
73
88
  {kicker && <p class="section__kicker font-eyebrow">{kicker}</p>}
74
- {heading && <Heading class="section__title font-title">{heading}</Heading>}
89
+ {heading && (
90
+ <Heading class="section__title font-title">
91
+ {headingRuns.map((run) =>
92
+ run.accent ? <em class="accent">{run.text}</em> : run.text,
93
+ )}
94
+ </Heading>
95
+ )}
75
96
  {sub && <p class="section__sub font-lead">{sub}</p>}
97
+ {hasActions && (
98
+ <p class="section__actions cluster">
99
+ {ctaLabel && ctaHref && (
100
+ <a class="cta" href={ctaHref}>
101
+ {ctaLabel}
102
+ </a>
103
+ )}
104
+ {secondaryLabel && secondaryHref && (
105
+ <a class="link" href={secondaryHref}>
106
+ {secondaryLabel}
107
+ </a>
108
+ )}
109
+ </p>
110
+ )}
76
111
  </header>
77
112
  )}
78
113
  {body.length > 0 && (
@@ -58,7 +58,7 @@ describe('blockAttrs', () => {
58
58
  describe('theme on every block', () => {
59
59
  it('adds the tinted classes and --tone-* properties to the root', () => {
60
60
  const a = rootAttrs({ theme: 'surface' }, ['callout']);
61
- expect(a.class).toBe('callout tone');
61
+ expect(a.class).toBe('callout tone tone--tint');
62
62
  expect(a.style).toContain('--tone-bg: var(--color-bg-surface-muted');
63
63
  });
64
64
  it('fills with the role solid and its computed text for `<role>:solid`', () => {
@@ -67,7 +67,9 @@ describe('theme on every block', () => {
67
67
  expect(a.style).toContain('--tone-on-solid: var(--color-text-on-brand-primary');
68
68
  });
69
69
  it("replaces a block's own tone classes rather than doubling them, and is inert for none", () => {
70
- expect(rootAttrs({ theme: 'info' }, ['x', 'tone', 'tone--solid']).class).toBe('x tone');
70
+ expect(rootAttrs({ theme: 'info' }, ['x', 'tone', 'tone--solid']).class).toBe(
71
+ 'x tone tone--tint',
72
+ );
71
73
  expect(rootAttrs({ theme: 'none' }, ['x', 'tone']).class).toBe('x tone');
72
74
  expect(rootAttrs({ theme: 'bad role!' }, ['x']).class).toBe('x');
73
75
  });
@@ -102,7 +102,7 @@ export function rootAttrs(
102
102
  const out: BlockAttrs = {};
103
103
  if (user.id) out.id = user.id;
104
104
  const cls = [
105
- ...own.filter((c) => !theme || (c !== 'tone' && c !== 'tone--solid')),
105
+ ...own.filter((c) => !theme || (c !== 'tone' && c !== 'tone--solid' && c !== 'tone--tint')),
106
106
  theme?.className,
107
107
  user.id && 'block-anchor',
108
108
  user.class,
@@ -59,7 +59,7 @@ describe('vitops_cards', () => {
59
59
  tone: 'brand-primary',
60
60
  cards: [{ title: 'Pillar', checklist: 'one\ntwo', ctaLabel: 'Go', ctaHref: '/go' }],
61
61
  });
62
- expect(html).toContain('class="card subgrid-card tone"');
62
+ expect(html).toContain('class="card subgrid-card tone tone--tint"');
63
63
  expect(html).toContain('--tone-bg: var(--color-bg-brand-primary-muted');
64
64
  expect(html).toContain('<li>one</li>');
65
65
  expect(html).toContain('class="cta" href="/go"');
@@ -210,6 +210,25 @@ describe('vitops_entries', () => {
210
210
  });
211
211
  });
212
212
 
213
+ describe('vitops_logos frame', () => {
214
+ it('wraps the row with a kicker, note and link only when one is set', async () => {
215
+ const bare = await render(Logos, { logos: [{ alt: 'Acme' }] });
216
+ expect(bare).not.toContain('logos-block');
217
+ const html = await render(Logos, {
218
+ kicker: 'Trusted by',
219
+ note: 'and 40 more',
220
+ linkLabel: 'All partners',
221
+ linkHref: '/partners',
222
+ logos: [{ alt: 'Acme' }],
223
+ });
224
+ expect(html).toContain('<div class="logos-block">');
225
+ expect(html).toContain('<p class="logos__kicker font-eyebrow">Trusted by</p>');
226
+ expect(html).toContain('<p class="logos__note font-caption">and 40 more</p>');
227
+ expect(html).toContain('<a class="link" href="/partners">All partners</a>');
228
+ expect(html).toContain('aria-label="Partners"');
229
+ });
230
+ });
231
+
213
232
  describe('vitops_pricing', () => {
214
233
  it('aligns plans through the pricing-card subgrid rows', async () => {
215
234
  const html = await render(Pricing, {
@@ -238,6 +257,24 @@ describe('vitops_pricing', () => {
238
257
  expect(html).toContain('<h3 class="font-title">Pro</h3>');
239
258
  expect(html).toContain('pricing-card__feature');
240
259
  });
260
+
261
+ it('carries a yearly line and a note per plan', async () => {
262
+ const html = await render(Pricing, {
263
+ plans: [
264
+ {
265
+ name: 'Pro',
266
+ price: '$39',
267
+ period: '/month',
268
+ yearly: '$34 paid yearly',
269
+ note: 'Best for teams',
270
+ },
271
+ ],
272
+ });
273
+ expect(html).toContain(
274
+ '<small class="pricing-card__yearly font-caption">$34 paid yearly</small>',
275
+ );
276
+ expect(html).toContain('<p class="pricing-card__note font-caption">Best for teams</p>');
277
+ });
241
278
  });
242
279
 
243
280
  describe('vitops_embed', () => {
@@ -385,7 +422,7 @@ describe('vitops_reviews', () => {
385
422
  fixtures.cache = { version: 1, locations: { ottawa: { reviews: [review('a', 5)] } } };
386
423
  const { default: Reviews } = await import('./Reviews.astro');
387
424
  const html = await render(Reviews, { tone: 'brand-primary' });
388
- expect(html).toContain('class="reviews tone"');
425
+ expect(html).toContain('class="reviews tone tone--tint"');
389
426
  expect(html).toContain('<article class="review"');
390
427
  });
391
428
  });
@@ -491,13 +528,38 @@ describe('vitops_section', () => {
491
528
  },
492
529
  },
493
530
  });
494
- expect(html).toContain('section region tone section--split');
531
+ expect(html).toContain('section region tone tone--tint section--split');
495
532
  expect(html).toContain('<h3 class="section__title font-title">Two services</h3>');
496
533
  expect(html).toContain('Why us');
497
534
  expect(html).toContain('Body copy');
498
535
  expect(html).toContain('class="logos"');
499
536
  expect(html).toContain('alt="Acme"');
500
537
  }, 60_000);
538
+
539
+ it('is a page opener: accent heading, two actions and a rhythm stack', async () => {
540
+ const { default: Section } = await import('./Section.astro');
541
+ const container = await AstroContainer.create();
542
+ const html = await container.renderToString(Section as unknown as Component, {
543
+ props: {
544
+ value: {
545
+ heading: 'Two services. *One team.*',
546
+ level: '1',
547
+ sub: 'Lead',
548
+ cta_label: 'Book a call',
549
+ cta_href: '/contact',
550
+ secondary_label: 'See pricing',
551
+ secondary_href: '/pricing',
552
+ stack: 'rhythm',
553
+ },
554
+ },
555
+ });
556
+ expect(html).toContain(
557
+ '<h1 class="section__title font-title">Two services. <em class="accent">One team.</em></h1>',
558
+ );
559
+ expect(html).toContain('<a class="cta" href="/contact">Book a call</a>');
560
+ expect(html).toContain('<a class="link" href="/pricing">See pricing</a>');
561
+ expect(html).toContain('class="section__inner rhythm"');
562
+ }, 60_000);
501
563
  });
502
564
 
503
565
  describe('vitops_form', () => {
@@ -680,3 +742,21 @@ describe('per-item colour and hero card extras', () => {
680
742
  expect(html).not.toContain('stretched-link');
681
743
  });
682
744
  });
745
+
746
+ describe('per-card theme', () => {
747
+ it('tints one card without touching its siblings', async () => {
748
+ const container = await AstroContainer.create();
749
+ const html = await container.renderToString(Cards as unknown as Component, {
750
+ props: {
751
+ value: {
752
+ variant: 'panel',
753
+ cards: [{ title: 'Without us', theme: 'danger' }, { title: 'With us' }],
754
+ },
755
+ },
756
+ });
757
+ const [first, second] = html.split('<li ').slice(1);
758
+ expect(first).toContain('tone tone--tint');
759
+ expect(first).toContain('--tone-bg');
760
+ expect(second).not.toContain('--tone-bg');
761
+ }, 60_000);
762
+ });
@@ -16,8 +16,8 @@ describe('toneChain', () => {
16
16
 
17
17
  describe('resolveTone', () => {
18
18
  it('is empty when nothing is set, so an unconfigured block stays transparent', () => {
19
- expect(resolveTone()).toEqual({ className: 'tone', style: '' });
20
- expect(resolveTone('', '')).toEqual({ className: 'tone', style: '' });
19
+ expect(resolveTone()).toEqual({ className: 'tone', surface: 'tone', style: '' });
20
+ expect(resolveTone('', '')).toEqual({ className: 'tone', surface: 'tone', style: '' });
21
21
  });
22
22
 
23
23
  it('resolves a role to var() chains the framework tokens satisfy', () => {
@@ -68,6 +68,8 @@ function chain(tokens: string[]): string {
68
68
  export interface ResolvedTone {
69
69
  /** Always `tone`; a block adds `tone--solid` itself when it fills rather than tints. */
70
70
  className: string;
71
+ /** `tone tone--tint`: what a block that is a surface (callout, card, band) puts on its root. */
72
+ surface: string;
71
73
  /** Inline `style` value of `--tone-*` properties (no trailing semicolon). Empty when unset. */
72
74
  style: string;
73
75
  }
@@ -94,6 +96,7 @@ export function resolveTone(tone?: unknown, color?: unknown): ResolvedTone {
94
96
  const flip = (light: string, dark: string) => `light-dark(${at(light)}, ${at(dark)})`;
95
97
  return {
96
98
  className: 'tone',
99
+ surface: 'tone tone--tint',
97
100
  style: [
98
101
  `--tone-bg: ${flip('100', '900')}`,
99
102
  `--tone-fg: ${flip('900', '100')}`,
@@ -104,13 +107,14 @@ export function resolveTone(tone?: unknown, color?: unknown): ResolvedTone {
104
107
  };
105
108
  }
106
109
 
107
- if (!hasTone) return { className: 'tone', style: '' };
110
+ if (!hasTone) return { className: 'tone', surface: 'tone', style: '' };
108
111
 
109
112
  const roles = toneChain(tone);
110
113
  const of = (prefix: string, suffix = '') =>
111
114
  chain(roles.map((r) => `--color-${prefix}-${r}${suffix}`));
112
115
  return {
113
116
  className: 'tone',
117
+ surface: 'tone tone--tint',
114
118
  style: [
115
119
  `--tone-bg: ${of('bg', '-muted')}`,
116
120
  `--tone-fg: ${of('text')}`,
@@ -160,5 +164,8 @@ export function resolveTheme(theme: unknown): { className: string; style: string
160
164
  if (!role || !ROLE.test(role)) return undefined;
161
165
  const tone = resolveTone(role);
162
166
  if (!tone.style) return undefined;
163
- return { className: fill === 'solid' ? 'tone tone--solid' : 'tone', style: tone.style };
167
+ return {
168
+ className: fill === 'solid' ? 'tone tone--solid' : 'tone tone--tint',
169
+ style: tone.style,
170
+ };
164
171
  }