@repobit/dex-system-design 1.0.1 → 2.0.1

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 (136) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +43 -12
  3. package/package.json +6 -4
  4. package/schemas/component-manifest.json +207 -49
  5. package/schemas/manifest-omissions.json +2 -0
  6. package/src/components/accordion/accordion.css.js +12 -1
  7. package/src/components/agent-endpoints/agent-endpoints.js +1 -1
  8. package/src/components/alert/alert.css.js +22 -1
  9. package/src/components/anchor/anchor-nav.css.js +6 -1
  10. package/src/components/annotation/annotation.css.js +6 -1
  11. package/src/components/architecture.stories.js +6 -4
  12. package/src/components/avatar/avatar.js +37 -11
  13. package/src/components/avatar/avatar.stories.js +10 -6
  14. package/src/components/awards/awards-flex.css.js +31 -129
  15. package/src/components/awards/awards-flex.js +66 -116
  16. package/src/components/awards/awards-flex.stories.js +8 -6
  17. package/src/components/back/back.css.js +6 -1
  18. package/src/components/back/back.js +24 -12
  19. package/src/components/back/back.stories.js +3 -3
  20. package/src/components/badge/badge.js +6 -3
  21. package/src/components/badge/badge.stories.js +13 -9
  22. package/src/components/bento-grid/bento-grid.js +1 -1
  23. package/src/components/blockquote/blockquote.css.js +12 -3
  24. package/src/components/blockquote/blockquote.js +12 -5
  25. package/src/components/blockquote/blockquote.stories.js +6 -4
  26. package/src/components/button/button.css.js +46 -25
  27. package/src/components/button/button.js +44 -43
  28. package/src/components/button/button.stories.js +2 -2
  29. package/src/components/caption/caption.css.js +10 -0
  30. package/src/components/caption/caption.stories.js +4 -4
  31. package/src/components/carousel/carousel.css.js +19 -116
  32. package/src/components/carousel/carousel.js +70 -74
  33. package/src/components/carousel/carousel.stories.js +9 -8
  34. package/src/components/checkbox/checkbox.css.js +6 -1
  35. package/src/components/code/code.css.js +6 -1
  36. package/src/components/code/code.js +48 -7
  37. package/src/components/code/code.stories.js +7 -4
  38. package/src/components/divider/divider-horizontal.js +11 -3
  39. package/src/components/divider/divider-vertical.js +12 -5
  40. package/src/components/divider/divider.css.js +4 -1
  41. package/src/components/divider/divider.stories.js +6 -6
  42. package/src/components/dropdown/dropdown.js +67 -13
  43. package/src/components/dropdown/dropdown.stories.js +15 -14
  44. package/src/components/fieldset/fieldset.stories.js +41 -16
  45. package/src/components/footer/footer-nav-menu.css.js +6 -1
  46. package/src/components/footer/footer.css.js +6 -1
  47. package/src/components/header/header-code.js +1 -1
  48. package/src/components/header/header.css.js +9 -4
  49. package/src/components/heading/heading.stories.js +64 -31
  50. package/src/components/highlight/highlight.js +74 -34
  51. package/src/components/highlight/highlight.stories.js +5 -4
  52. package/src/components/icons/icon-element.js +49 -6
  53. package/src/components/icons/icons.stories.js +7 -0
  54. package/src/components/image/image.css.js +9 -98
  55. package/src/components/image/image.js +7 -2
  56. package/src/components/input/custom-form.stories.js +14 -19
  57. package/src/components/input/input-clipboard.css.js +6 -1
  58. package/src/components/input/input-clipboard.js +56 -8
  59. package/src/components/input/input.js +19 -2
  60. package/src/components/label/label.js +87 -1
  61. package/src/components/label/label.stories.js +21 -12
  62. package/src/components/link/link.css.js +52 -3
  63. package/src/components/link/link.js +52 -17
  64. package/src/components/link/link.stories.js +114 -3
  65. package/src/components/list/list-item/list-item.css.js +4 -6
  66. package/src/components/list/list-item/list-item.js +53 -5
  67. package/src/components/list/list-item/list-item.stories.js +22 -0
  68. package/src/components/list/list.js +27 -0
  69. package/src/components/modal/modal.css.js +6 -1
  70. package/src/components/pagination-dots/pagination-dots.css.js +161 -0
  71. package/src/components/pagination-dots/pagination-dots.js +202 -0
  72. package/src/components/pagination-dots/pagination-dots.stories.js +171 -0
  73. package/src/components/paragraph/paragraph.css.js +37 -1
  74. package/src/components/paragraph/paragraph.js +19 -2
  75. package/src/components/paragraph/paragraph.stories.js +40 -6
  76. package/src/components/picture/picture.css.js +8 -67
  77. package/src/components/picture/picture.js +15 -8
  78. package/src/components/pricing-cards/pricing-card.css.js +12 -2
  79. package/src/components/pricing-cards/pricing-card.js +4 -4
  80. package/src/components/progress/progress.js +24 -4
  81. package/src/components/progress/progress.stories.js +8 -6
  82. package/src/components/radio/radio.css.js +6 -1
  83. package/src/components/radio/radio.js +48 -3
  84. package/src/components/radio/radio.stories.js +1 -1
  85. package/src/components/shared/form-field.css.js +30 -4
  86. package/src/components/shared/token-scales.js +16 -0
  87. package/src/components/spinner/spinner.css.js +31 -4
  88. package/src/components/spinner/spinner.js +26 -4
  89. package/src/components/spinner/spinner.stories.js +10 -7
  90. package/src/components/switch/switch.css.js +21 -3
  91. package/src/components/switch/switch.js +8 -1
  92. package/src/components/switch/switch.stories.js +2 -1
  93. package/src/components/tab-list/tab-list.css.js +6 -1
  94. package/src/components/terminal/terminal-cursor.js +11 -0
  95. package/src/components/terminal/terminal-message.js +1 -1
  96. package/src/components/termsOfUse/terms.css.js +6 -1
  97. package/src/components/textarea/textarea.stories.js +23 -0
  98. package/src/components/tooltip/tooltip.css.js +24 -1
  99. package/src/components/tooltip/tooltip.js +135 -5
  100. package/src/components/tooltip/tooltip.stories.js +20 -14
  101. package/src/components/trust-panel/trust-panel.js +2 -2
  102. package/src/components/video/video.js +38 -3
  103. package/src/components/video/video.stories.js +10 -5
  104. package/src/components/video-player/video-player.css.js +6 -1
  105. package/src/tokens/elevation.stories.js +2 -3
  106. package/src/tokens/focus.stories.js +81 -15
  107. package/src/tokens/motion.stories.js +0 -6
  108. package/src/tokens/tokens.css +174 -97
  109. package/src/tokens/tokens.js +174 -97
  110. package/src/tokens/vertical-rhythm.stories.js +1 -1
  111. package/src/stories/Button.js +0 -20
  112. package/src/stories/Button.stories.js +0 -46
  113. package/src/stories/Configure.mdx +0 -364
  114. package/src/stories/Header.js +0 -45
  115. package/src/stories/Header.stories.js +0 -24
  116. package/src/stories/Page.js +0 -62
  117. package/src/stories/Page.stories.js +0 -20
  118. package/src/stories/assets/accessibility.png +0 -0
  119. package/src/stories/assets/accessibility.svg +0 -1
  120. package/src/stories/assets/addon-library.png +0 -0
  121. package/src/stories/assets/assets.png +0 -0
  122. package/src/stories/assets/avif-test-image.avif +0 -0
  123. package/src/stories/assets/context.png +0 -0
  124. package/src/stories/assets/discord.svg +0 -1
  125. package/src/stories/assets/docs.png +0 -0
  126. package/src/stories/assets/figma-plugin.png +0 -0
  127. package/src/stories/assets/github.svg +0 -1
  128. package/src/stories/assets/share.png +0 -0
  129. package/src/stories/assets/styling.png +0 -0
  130. package/src/stories/assets/testing.png +0 -0
  131. package/src/stories/assets/theming.png +0 -0
  132. package/src/stories/assets/tutorials.svg +0 -1
  133. package/src/stories/assets/youtube.svg +0 -1
  134. package/src/stories/button.css +0 -30
  135. package/src/stories/header.css +0 -32
  136. package/src/stories/page.css +0 -68
@@ -1,18 +1,24 @@
1
- import { LitElement, html } from 'lit';
1
+ import { LitElement, html, nothing } from 'lit';
2
2
  // Installs the design tokens into document scope; see src/tokens/tokens.js
3
3
  import '../../tokens/tokens.js';
4
4
  import { tooltipCSS } from './tooltip.css.js';
5
5
 
6
+ let descriptionIds = 0;
7
+
6
8
  export class BdTooltip extends LitElement {
7
9
  static styles = [tooltipCSS];
8
10
 
9
11
  static properties = {
10
- text: { type: String }
12
+ text : { type: String },
13
+ /* Set by Escape and cleared once neither the pointer nor focus is on the
14
+ tooltip any more, so the next hover or focus shows it again. */
15
+ _dismissed: { type: Boolean, state: true }
11
16
  };
12
17
 
13
18
  constructor() {
14
19
  super();
15
20
  this.text = '';
21
+ this._dismissed = false;
16
22
  }
17
23
 
18
24
  /* The bubble is centred on its trigger and is wider than it, so near a
@@ -119,7 +125,31 @@ export class BdTooltip extends LitElement {
119
125
  Both boxes, because they move for different reasons: the host changes
120
126
  when the trigger resizes, the bubble when its own text reflows.
121
127
  ResizeObserver fires once on observe, which is the settled first pass. */
122
- if (typeof ResizeObserver === 'undefined') return;
128
+ this._observe();
129
+ }
130
+
131
+ /* ESCAPE. WCAG 1.4.13 asks that content shown on hover or focus can be
132
+ dismissed without moving the pointer or focus. The pointer may be over the
133
+ trigger while focus is anywhere on the page, so the key is heard on the
134
+ document, and only acted on while this bubble is actually showing.
135
+
136
+ Capture phase, and the event is marked handled with preventDefault rather
137
+ than stopped: a tooltip is the innermost thing that can be open, so its
138
+ Escape should not also close a native <dialog> it sits in, and a dialog's
139
+ cancel is exactly what preventDefault on that keydown withholds.
140
+ Propagation is left alone, as bd-modal leaves it, so a page that wants to
141
+ hear the key still does. */
142
+ get _showing() {
143
+ return !this._dismissed && (this.matches(':hover') || this.matches(':focus-within'));
144
+ }
145
+
146
+ /* Started on first render and again on every reconnect. It used to start
147
+ only in firstUpdated, and disconnectedCallback tears it down, so a tooltip
148
+ moved in the DOM -- removed and re-inserted, which a framework reordering
149
+ a list does -- came back with no observer and never re-placed itself when
150
+ its trigger or text changed size. */
151
+ _observe() {
152
+ if (typeof ResizeObserver === 'undefined' || this._ro) return;
123
153
  this._ro = new ResizeObserver(() => this._place());
124
154
  this._ro.observe(this);
125
155
  const bubble = this.renderRoot?.querySelector('.bubble');
@@ -128,27 +158,127 @@ export class BdTooltip extends LitElement {
128
158
 
129
159
  connectedCallback() {
130
160
  super.connectedCallback();
161
+ // a first connection is handled by firstUpdated, once the bubble exists
162
+ if (this.hasUpdated) this._observe();
131
163
  this._onShow = () => this._place();
132
164
  /* Both reveals the stylesheet already honours, and resize because the
133
165
  measurement is only true for the width it was taken at. */
134
166
  this.addEventListener('mouseenter', this._onShow);
135
167
  this.addEventListener('focusin', this._onShow);
136
168
  window.addEventListener('resize', this._onShow);
169
+
170
+ this._onKeydown = e => {
171
+ if (e.key !== 'Escape' || e.defaultPrevented || !this._showing) return;
172
+ e.preventDefault();
173
+ this._dismissed = true;
174
+ };
175
+ /* Back to normal once the pointer and focus have both gone: a pointer
176
+ leaving while focus stays inside keeps it dismissed, and the reverse. */
177
+ this._onLeave = e => {
178
+ if (!this._dismissed) return;
179
+ const pointerGone = e.type === 'mouseleave' || !this.matches(':hover');
180
+ const focusGone = e.type === 'focusout'
181
+ ? !this.contains(e.relatedTarget) && !this.shadowRoot.contains(e.relatedTarget)
182
+ : !this.matches(':focus-within');
183
+ if (pointerGone && focusGone) this._dismissed = false;
184
+ };
185
+ document.addEventListener('keydown', this._onKeydown, true);
186
+ this.addEventListener('mouseleave', this._onLeave);
187
+ this.addEventListener('focusout', this._onLeave);
137
188
  }
138
189
 
139
190
  disconnectedCallback() {
140
191
  this.removeEventListener('mouseenter', this._onShow);
141
192
  this.removeEventListener('focusin', this._onShow);
142
193
  window.removeEventListener('resize', this._onShow);
194
+ document.removeEventListener('keydown', this._onKeydown, true);
195
+ this.removeEventListener('mouseleave', this._onLeave);
196
+ this.removeEventListener('focusout', this._onLeave);
143
197
  this._ro?.disconnect();
144
198
  this._ro = undefined;
145
199
  super.disconnectedCallback();
146
200
  }
147
201
 
202
+ /* DESCRIBING THE TRIGGER.
203
+ The bubble had role="tooltip" and nothing pointed at it, so a screen
204
+ reader landing on the trigger heard its name and not the tooltip, and
205
+ the bubble's text sat in the page as a stray line. It cannot be pointed
206
+ at directly: the bubble is in this shadow root and the trigger is not,
207
+ and an id reference never crosses that boundary. So the trigger is
208
+ described from its own side:
209
+
210
+ a native control aria-describedby names a hidden copy of the text,
211
+ kept as a child of this element in a hidden slot --
212
+ the same tree as the trigger, so the reference
213
+ resolves
214
+ bd-button, the text goes to its description property, which
215
+ bd-button-link, wires aria-describedby inside its own shadow root,
216
+ bd-link where its real control is
217
+
218
+ Once described, the bubble is hidden from assistive technology so the
219
+ text is not read twice. Any other custom element as trigger keeps the
220
+ bubble exposed and goes undescribed -- its control is out of reach. */
221
+ _describe() {
222
+ this._undescribe();
223
+ const slot = this.shadowRoot?.querySelector('slot');
224
+ const trigger = slot?.assignedElements()[0];
225
+ if (!trigger || !this.text) return;
226
+
227
+ // a custom element that has not upgraded yet has no description to set:
228
+ // come back once it has
229
+ if (trigger.localName.includes('-') && !customElements.get(trigger.localName)) {
230
+ customElements.whenDefined(trigger.localName).then(() => this._describe());
231
+ return;
232
+ }
233
+
234
+ if ('description' in trigger && trigger.localName.includes('-')) {
235
+ trigger.description = this.text;
236
+ this._described = { trigger, kind: 'property' };
237
+ } else if (!trigger.localName.includes('-')) {
238
+ const node = document.createElement('span');
239
+ node.id = `bd-tooltip-description-${++descriptionIds}`;
240
+ node.slot = 'description';
241
+ node.hidden = true;
242
+ node.textContent = this.text;
243
+ this.append(node);
244
+ const ids = (trigger.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
245
+ trigger.setAttribute('aria-describedby', [...ids, node.id].join(' '));
246
+ this._described = { trigger, kind: 'idref', node };
247
+ }
248
+ this.requestUpdate();
249
+ }
250
+
251
+ _undescribe() {
252
+ const d = this._described;
253
+ if (!d) return;
254
+ if (d.kind === 'property') {
255
+ d.trigger.description = '';
256
+ } else {
257
+ const ids = (d.trigger.getAttribute('aria-describedby') || '').split(/\s+/).filter(t => t && t !== d.node.id);
258
+ if (ids.length) d.trigger.setAttribute('aria-describedby', ids.join(' '));
259
+ else d.trigger.removeAttribute('aria-describedby');
260
+ d.node.remove();
261
+ }
262
+ this._described = null;
263
+ this.requestUpdate();
264
+ }
265
+
266
+ updated(changed) {
267
+ if (changed.has('text') && this.hasUpdated) this._describe();
268
+ }
269
+
148
270
  render() {
149
271
  return html`
150
- <slot></slot>
151
- <span class="bubble" role="tooltip">${this.text}</span>
272
+ <slot @slotchange=${() => this._describe()}></slot>
273
+ <!-- the trigger's description: hidden, but slotted, because a node
274
+ assigned to no slot is not in the flat tree and Chrome then drops
275
+ an aria-describedby that points at it -->
276
+ <span hidden><slot name="description"></slot></span>
277
+ <span
278
+ class="bubble ${this._dismissed ? 'bubble--dismissed' : ''}"
279
+ role="tooltip"
280
+ aria-hidden="${this._described ? 'true' : nothing}"
281
+ >${this.text}</span>
152
282
  `;
153
283
  }
154
284
  }
@@ -33,23 +33,29 @@ Anything required to complete a task belongs in visible text or in
33
33
  \`<bd-help-text>\`, because a tooltip is unreachable on touch, invisible in
34
34
  print, and gone the moment the pointer leaves.
35
35
 
36
- ### Accessibility caveats
37
- The bubble carries \`role="tooltip"\`, but this element does **not** wire up
38
- \`aria-describedby\` between the trigger and the bubble. A screen reader
39
- therefore has no link from the control to its description, so the tooltip text
40
- may never be announced.
36
+ ### Accessibility
37
+ The trigger is described by the tooltip: a screen reader reads the \`text\`
38
+ after the trigger's name. How depends on the trigger, because the bubble sits
39
+ in this element's shadow root and an \`aria-describedby\` cannot point into it:
41
40
 
42
- Until that is connected, describe the trigger directly as well:
41
+ - **a native control** — \`<button>\`, \`<a>\`, \`<input>\` — gets
42
+ \`aria-describedby\` naming a hidden copy of the text kept inside the
43
+ tooltip, added to any ids it already lists and removed when it leaves;
44
+ - **\`bd-button\`, \`bd-button-link\` and \`bd-link\`** get the text through
45
+ their \`description\` property, which wires the description inside their own
46
+ shadow root, where the real control is;
47
+ - **any other custom element** cannot be reached, so it is not described and
48
+ the bubble stays exposed instead. Prefer a native control, a
49
+ \`bd-button\` or a \`bd-link\` as the trigger.
43
50
 
44
- \`\`\`html
45
- <bd-tooltip text="Blocks trackers">
46
- <bd-button aria-describedby="trackers-hint">What is this?</bd-button>
47
- </bd-tooltip>
48
- <span id="trackers-hint" hidden>Blocks trackers across every browser</span>
49
- \`\`\`
51
+ Once the trigger is described, the bubble itself is hidden from assistive
52
+ technology, so the text is not read twice.
50
53
 
51
- There is also no dismiss-on-Escape and no delay before showing, both of which
52
- WCAG 1.4.13 asks of content that appears on hover.
54
+ The bubble meets WCAG 1.4.13. It is hoverable: the pointer can move from the
55
+ trigger onto the bubble, across the gap between them, and it stays open. It is
56
+ dismissible: Escape hides it without moving the pointer or focus, and it shows
57
+ again on the next hover or focus. That Escape is marked handled, so a native
58
+ \`<dialog>\` the tooltip sits in stays open on the same keypress.
53
59
  `
54
60
  }
55
61
  }
@@ -36,7 +36,7 @@ class TrustPanel extends LitElement {
36
36
  return html`
37
37
  <div class="bd-trustpanel-card">
38
38
  ${card.eyebrow
39
- ? html`<bd-p variant="regular" font-weight="semibold" class="bd-trustpanel-card-eyebrow">${card.eyebrow}</bd-p>`
39
+ ? html`<bd-p variant="eyebrow" class="bd-trustpanel-card-eyebrow">${card.eyebrow}</bd-p>`
40
40
  : nothing}
41
41
 
42
42
  <div class="bd-trustpanel-card-logos">
@@ -66,7 +66,7 @@ class TrustPanel extends LitElement {
66
66
  return html`
67
67
  <div class="bd-ensemble bd-trustpanel-wrapper">
68
68
  ${this.eyebrow
69
- ? html`<bd-p variant="regular" font-weight="semibold" class="bd-trustpanel-eyebrow"><slot name="eyebrow">${this.eyebrow}</slot></bd-p>`
69
+ ? html`<bd-p variant="eyebrow" class="bd-trustpanel-eyebrow"><slot name="eyebrow">${this.eyebrow}</slot></bd-p>`
70
70
  : nothing}
71
71
 
72
72
  ${this.title
@@ -9,9 +9,13 @@ export class BdVideo extends LitElement {
9
9
  static properties = {
10
10
  src : { type: String },
11
11
  poster : { type: String },
12
- controls: { type: Boolean },
12
+ /* On unless told otherwise -- see the story's Notes. A default-on boolean
13
+ cannot be turned off by leaving the attribute out, so the attribute
14
+ reads controls="false" as off; the property takes false as it is. */
15
+ controls: { converter: { fromAttribute: value => value !== 'false' } },
13
16
  muted : { type: Boolean },
14
- loop : { type: Boolean }
17
+ loop : { type: Boolean },
18
+ _media : { state: true }
15
19
  };
16
20
 
17
21
  constructor() {
@@ -21,8 +25,38 @@ export class BdVideo extends LitElement {
21
25
  this.controls = true;
22
26
  this.muted = false;
23
27
  this.loop = false;
28
+ this._media = [];
24
29
  }
25
30
 
31
+ /* WHY THE TRACKS ARE COPIED IN.
32
+ This rendered <video><slot></slot></video>, and a media element builds
33
+ its text tracks and sources only from real <track> and <source> children
34
+ -- a slot is neither, so the captions the story told readers to slot in
35
+ never reached the player. The <track> and <source> children are now
36
+ copied into the <video> itself, and copied again whenever they change. */
37
+ connectedCallback() {
38
+ super.connectedCallback();
39
+ this._collect();
40
+ this._observer = new MutationObserver(() => this._collect());
41
+ this._observer.observe(this, { childList: true, subtree: true, attributes: true });
42
+ }
43
+
44
+ disconnectedCallback() {
45
+ this._observer?.disconnect();
46
+ super.disconnectedCallback();
47
+ }
48
+
49
+ _collect() {
50
+ this._media = [...this.children]
51
+ .filter(el => el.localName === 'track' || el.localName === 'source')
52
+ .map(el => el.cloneNode(true));
53
+ }
54
+
55
+ /* muted is a property, not only an attribute. The attribute is read once,
56
+ when the media loads -- it is what lets a muted video autoplay -- and
57
+ changing it afterwards mutes nothing, so muted = true on a playing video
58
+ left the sound on. Both are set: the attribute for the load, the
59
+ property for every change after it. */
26
60
  render() {
27
61
  return html`
28
62
  <video
@@ -30,9 +64,10 @@ export class BdVideo extends LitElement {
30
64
  poster=${this.poster || nothing}
31
65
  ?controls=${this.controls}
32
66
  ?muted=${this.muted}
67
+ .muted=${this.muted}
33
68
  ?loop=${this.loop}
34
69
  playsinline
35
- ><slot></slot></video>
70
+ >${this._media}</video>
36
71
  `;
37
72
  }
38
73
  }
@@ -29,9 +29,10 @@ export default {
29
29
  | \`muted\` | boolean | \`false\` |
30
30
  | \`loop\` | boolean | \`false\` |
31
31
 
32
- ### Captions go in the slot
33
- Anything slotted is passed through into the \`<video>\`, which is how you supply
34
- \`<track>\` elements. **Recorded video needs captions** — WCAG 1.2.2 at level A —
32
+ ### Captions go inside the element
33
+ \`<track>\` and \`<source>\` children are copied into the \`<video>\` — a media
34
+ element only reads tracks and sources from its own children — and copied again
35
+ when they change. That is how you supply captions. **Recorded video needs captions** — WCAG 1.2.2 at level A —
35
36
  and this element does nothing to add them for you.
36
37
 
37
38
  ### Notes
@@ -41,7 +42,11 @@ screen.
41
42
  \`controls\` defaults to \`true\` on purpose: a video a user cannot pause is a
42
43
  2.2.2 failure the moment it plays for more than five seconds. If you turn
43
44
  controls off, the video must also be \`muted\` and \`loop\` and behave as
44
- decoration.
45
+ decoration. In HTML that is \`controls="false"\`: leaving the attribute out
46
+ cannot turn off a default that is on. From script, \`.controls = false\`.
47
+
48
+ \`muted\` follows the property as well as the attribute, so changing it on a
49
+ playing video takes effect.
45
50
 
46
51
  There is no \`autoplay\` prop, deliberately — autoplay with sound is both
47
52
  disruptive and blocked by most browsers anyway.
@@ -91,7 +96,7 @@ export const Default = () => html`
91
96
  * them.
92
97
  */
93
98
  export const Decorative = () => html`
94
- <bd-video poster="/images/bd-header-img.jpg" ?controls=${false} muted loop></bd-video>
99
+ <bd-video poster="/images/bd-header-img.jpg" controls="false" muted loop></bd-video>
95
100
  `;
96
101
 
97
102
  /**
@@ -124,7 +124,12 @@ export default css`
124
124
  --radius-lg corner and the close button is a circle. */
125
125
  .bd-video-thumb:focus-visible,
126
126
  .bd-video-close:focus-visible {
127
- outline: none;
127
+ /* forced-colors mode does not paint box-shadow, so the ring
128
+ vanishes there; it does paint an outline, in a system colour. This one
129
+ is transparent everywhere else, and the offset lands it on the ring's
130
+ outer band. */
131
+ outline: var(--border-width-2) solid transparent;
132
+ outline-offset: var(--focus-outline-offset);
128
133
  box-shadow: var(--focus-outline-primary);
129
134
  }
130
135
 
@@ -75,15 +75,14 @@ export const Shadows = () => {
75
75
  const surface = all.filter(token => !token.includes('focus'));
76
76
  const rings = all.filter(token => token.includes('focus'));
77
77
 
78
- const ladder = ['--shadow-sm', '--shadow-md', '--shadow-lg', '--shadow-xl', '--shadow-2xl'];
78
+ const ladder = ['--shadow-sm', '--shadow-md', '--shadow-lg', '--shadow-xl'];
79
79
 
80
80
  return html`
81
81
  ${elevationStyles}
82
82
  ${docsPage({
83
83
  title: 'Elevation',
84
84
  lede : html`Shadows are the only elevation mechanism in the system — there is no z-index
85
- scale. <code>sm</code> through <code>2xl</code> form a ladder;
86
- <code>--shadow-inner</code> is an inset press state.`,
85
+ scale. <code>sm</code> through <code>xl</code> form a ladder.`,
87
86
  note : referenceNote,
88
87
  children: html`
89
88
  ${refSection({
@@ -73,10 +73,20 @@ const focusStyles = html`
73
73
  margin-bottom: var(--spacing-6);
74
74
  }
75
75
 
76
+ .focus-demo--brand {
77
+ background: var(--color-blue-500);
78
+ padding: var(--spacing-24);
79
+ border-radius: var(--radius-md);
80
+ }
81
+
76
82
  .focus-demo--dark .ring-label {
77
83
  color: var(--color-neutral-300);
78
84
  }
79
85
 
86
+ .focus-demo--brand .ring-label {
87
+ color: var(--color-neutral-0);
88
+ }
89
+
80
90
  /* A genuinely interactive control, so the real :focus-visible can be tested. */
81
91
  .try-me {
82
92
  font: inherit;
@@ -117,9 +127,14 @@ export const FocusRings = () => {
117
127
  // -offset and -radius are lengths, not rings; they would render as empty swatches
118
128
  const NOT_RINGS = ['--focus-outline-offset', '--focus-outline-radius'];
119
129
  const rings = tokens.filter(token => token.startsWith('--focus-outline-') && !NOT_RINGS.includes(token));
130
+ // the -traditional pair is an outline, not a shadow list; as a box-shadow it draws nothing
131
+ const ringStyle = token => token.includes('-traditional')
132
+ ? `outline: var(${token}); outline-offset: var(--focus-outline-offset)`
133
+ : `box-shadow: var(${token})`;
120
134
  const ringColour = resolve('--color-blue-500');
121
135
  const onLight = contrast(ringColour, PAPER);
122
136
  const onDark = contrast(ringColour, INK);
137
+ const onBrand = contrast(resolve('--color-neutral-0'), ringColour);
123
138
 
124
139
  return html`
125
140
  ${focusStyles}
@@ -130,6 +145,22 @@ export const FocusRings = () => {
130
145
  inner band of <code>--color-neutral-0</code> and an outer band of the state colour. The
131
146
  inner band is what makes the ring visible on both light and dark backgrounds.
132
147
  <br /><br />
148
+ <b>CTAs are the exception: bd-button draws an offset outline.</b>
149
+ <code>--focus-outline-traditional</code> (blue) or
150
+ <code>--focus-outline-traditional-inverse</code> (white), held off the control by
151
+ <code>--focus-outline-offset</code>. The gap is transparent, so it is always the surface
152
+ the button sits on, whether that is white, neutral-900 or brand blue. That means it
153
+ cannot merge into a white fill, and it never paints a dark band onto a brand hero. Only
154
+ the colour follows the surface: blue under <code>surface="light"</code>, white under
155
+ <code>surface="dark"</code> and on the white presets.
156
+ <br /><br />
157
+ <b>Figma cannot do this.</b> Its rings are spread shadows, so the gap has to be a painted
158
+ band, and the library keeps one ring per surface: <code>focus/ring-primary</code> on
159
+ light and <code>focus/ring-inverse</code> (<code>--focus-outline-inverse</code>, a
160
+ <code>--color-neutral-900</code> band against the control, a white band outside it) on
161
+ dark and brand. The code and the library are meant to differ here, so do not copy the
162
+ Figma ring back into bd-button.
163
+ <br /><br />
133
164
  The ring is a <code>box-shadow</code>, so it already follows the radius of whatever it
134
165
  sits on — a component with its own radius should set nothing and let the ring take its
135
166
  shape. <code>--focus-outline-radius</code> is the default for the ones that have no
@@ -137,8 +168,10 @@ export const FocusRings = () => {
137
168
  note: html`WCAG 2.2 <b>1.4.11 Non-text Contrast</b> asks for <b>3:1</b> between a focus
138
169
  indicator and its adjacent background. The blue ring is
139
170
  ${onLight.toFixed(2)}${levelBadge(onLight)} on white and
140
- ${onDark.toFixed(2)}${levelBadge(onDark)} on <code>--color-neutral-900</code> — which is
141
- exactly why the white inner band matters on dark surfaces. ${referenceNote}`,
171
+ ${onDark.toFixed(2)}${levelBadge(onDark)} on <code>--color-neutral-900</code>, but
172
+ 1.00:1 on a blue-500 hero. That is why every two-band ring here has a white band, and why
173
+ a CTA on a dark or brand surface takes a white outline: ${onBrand.toFixed(2)}${levelBadge(onBrand)}
174
+ on blue-500. ${referenceNote}`,
142
175
  children: html`
143
176
  ${refSection({
144
177
  name : 'The rule',
@@ -146,13 +179,24 @@ export const FocusRings = () => {
146
179
  children: html`
147
180
  <div class="specimen">
148
181
  <p class="rule-note">
149
- Every interactive component shows the same ring on keyboard focus,
150
- and it is two lines:
182
+ Every interactive component shows the same ring on keyboard focus:
151
183
  </p>
152
184
  <pre class="rule-code"><code>.control:focus-visible {
153
- outline: none;
185
+ outline: var(--border-width-2) solid transparent;
186
+ outline-offset: var(--focus-outline-offset);
154
187
  box-shadow: var(--focus-outline-primary);
155
188
  }</code></pre>
189
+ <p class="rule-note">
190
+ <b>The transparent outline is not decoration.</b> Windows High
191
+ Contrast and every other forced-colors mode drop
192
+ <code>box-shadow</code>, so a ring drawn only as a shadow
193
+ disappears there and leaves a keyboard user with no cue at all. An
194
+ outline is kept and repainted in a system colour, and
195
+ <code>--focus-outline-offset</code> puts it where the ring's outer
196
+ band would be. It draws nothing anywhere else. Writing
197
+ <code>outline: none</code> beside the ring is what lost it, on
198
+ every atom that did.
199
+ </p>
156
200
  <p class="rule-note">
157
201
  <b>Always <code>:focus-visible</code>, never <code>:focus</code>.</b>
158
202
  <code>:focus</code> fires on a mouse click too, which rings
@@ -167,13 +211,19 @@ export const FocusRings = () => {
167
211
  accordion triggers did exactly that.
168
212
  </p>
169
213
  <p class="rule-note">
170
- <b>Text fields are the one exception.</b> Inputs, textareas and
171
- selects keep the field border and add
172
- <code>--shadow-focus-outline-blue</code> as a glow, because an
173
- offset ring around a full-width field reads as a second border.
174
- Everything else you can operate — buttons, links, checkboxes,
175
- radios, switches, tabs, accordion triggers, the scrollable code
176
- block — uses the ring.
214
+ <b>Text fields add their border.</b> Inputs, textareas and selects
215
+ take the same ring, and also turn their own border to
216
+ <code>--form-input-focus-borderColor</code>, so the field's edge
217
+ changes with it. (<code>--shadow-focus-outline-blue</code>, a
218
+ translucent glow, is not used by any field; only the blog
219
+ newsletter reads it.) Everything else you can operate — links,
220
+ checkboxes, radios, switches, tabs, accordion triggers, the
221
+ scrollable code block — uses the ring alone.
222
+ </p>
223
+ <p class="rule-note">
224
+ <b>bd-button is the other exception.</b> It sits on light, dark
225
+ and brand surfaces, so it uses the offset outline, whose
226
+ transparent gap works on all three. See the note at the top.
177
227
  </p>
178
228
  </div>
179
229
  `
@@ -198,7 +248,7 @@ export const FocusRings = () => {
198
248
  token => html`
199
249
  <div>
200
250
  <span class="ring-label">${token.replace('--focus-outline-', '')}</span>
201
- <div class="ring" style="box-shadow: var(${token})">Button</div>
251
+ <div class="ring" style=${ringStyle(token)}>Button</div>
202
252
  </div>
203
253
  `
204
254
  )}
@@ -208,14 +258,30 @@ export const FocusRings = () => {
208
258
  })}
209
259
  ${refSection({
210
260
  name : 'On a dark surface',
211
- meta : 'the white inner band keeps the ring readable',
261
+ meta : 'on a white control, primary merges into the fill; the outline\'s gap is the surface',
212
262
  children: html`
213
263
  <div class="focus-demo focus-demo--dark">
214
264
  ${rings.map(
215
265
  token => html`
216
266
  <div>
217
267
  <span class="ring-label">${token.replace('--focus-outline-', '')}</span>
218
- <div class="ring" style="box-shadow: var(${token})">Button</div>
268
+ <div class="ring" style=${ringStyle(token)}>Button</div>
269
+ </div>
270
+ `
271
+ )}
272
+ </div>
273
+ `
274
+ })}
275
+ ${refSection({
276
+ name : 'On a brand surface',
277
+ meta : 'inverse paints a black band here; traditional-inverse leaves the gap blue',
278
+ children: html`
279
+ <div class="focus-demo focus-demo--brand">
280
+ ${rings.map(
281
+ token => html`
282
+ <div>
283
+ <span class="ring-label">${token.replace('--focus-outline-', '')}</span>
284
+ <div class="ring" style=${ringStyle(token)}>Button</div>
219
285
  </div>
220
286
  `
221
287
  )}
@@ -199,12 +199,6 @@ export const Motion = () => {
199
199
  `
200
200
  )}
201
201
  </div>
202
- <p class="docs-lede" style="margin-top: var(--spacing-16)">
203
- <code>--transition-easing-bounce</code> is
204
- <code>${resolve('--transition-easing-bounce')}</code> — the negative and
205
- greater-than-one control points make it overshoot at both ends. Use it for a
206
- deliberate spring, not for hover states.
207
- </p>
208
202
  `
209
203
  })}
210
204