@astryxdesign/core 0.7.0 → 0.7.1-canary.0365e23

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 (80) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +1 -1
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +12 -2
  4. package/dist/CodeBlock/CodeBlock.d.ts +10 -1
  5. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  6. package/dist/CodeBlock/CodeBlock.js +158 -38
  7. package/dist/CodeBlock/tokenizer.d.ts +39 -0
  8. package/dist/CodeBlock/tokenizer.d.ts.map +1 -1
  9. package/dist/CodeBlock/tokenizer.js +108 -0
  10. package/dist/ContextMenu/ContextMenu.d.ts +1 -1
  11. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  12. package/dist/ContextMenu/ContextMenu.js +22 -8
  13. package/dist/Dialog/Dialog.d.ts.map +1 -1
  14. package/dist/Dialog/Dialog.js +3 -26
  15. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  16. package/dist/DropdownMenu/DropdownMenu.js +10 -3
  17. package/dist/Markdown/Markdown.d.ts.map +1 -1
  18. package/dist/Markdown/Markdown.js +19 -3
  19. package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
  20. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  21. package/dist/Typeahead/BaseTypeahead.js +12 -3
  22. package/dist/astryx.css +13 -0
  23. package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
  24. package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
  25. package/dist/theme/syntax/SyntaxTheme.d.ts.map +1 -1
  26. package/dist/theme/syntax/SyntaxTheme.js +43 -2
  27. package/dist/theme/syntax/defineSyntaxTheme.d.ts +18 -5
  28. package/dist/theme/syntax/defineSyntaxTheme.d.ts.map +1 -1
  29. package/dist/theme/syntax/defineSyntaxTheme.js +38 -9
  30. package/dist/theme/syntax/index.d.ts +1 -1
  31. package/dist/theme/syntax/index.d.ts.map +1 -1
  32. package/dist/theme/syntax/tokens.d.ts +12 -2
  33. package/dist/theme/syntax/tokens.d.ts.map +1 -1
  34. package/dist/theme/syntax/tokens.js +25 -4
  35. package/dist/utils/focusReturn.d.ts +12 -8
  36. package/dist/utils/focusReturn.d.ts.map +1 -1
  37. package/dist/utils/focusReturn.js +44 -3
  38. package/package.json +4 -4
  39. package/src/BottomSheet/BottomSheet.test.tsx +114 -0
  40. package/src/BottomSheet/BottomSheet.tsx +12 -2
  41. package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
  42. package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
  43. package/src/CodeBlock/CodeBlock.doc.mjs +7 -0
  44. package/src/CodeBlock/CodeBlock.spec.md +55 -45
  45. package/src/CodeBlock/CodeBlock.test.tsx +89 -9
  46. package/src/CodeBlock/CodeBlock.tsx +205 -25
  47. package/src/CodeBlock/__tests__/CodeBlockOverflowStop.a11y.chromium.spec.ts +125 -0
  48. package/src/CodeBlock/__tests__/DiffRowPaint.a11y.chromium.spec.ts +93 -0
  49. package/src/CodeBlock/tokenizer.test.ts +112 -0
  50. package/src/CodeBlock/tokenizer.ts +106 -0
  51. package/src/ContextMenu/ContextMenu.spec.md +1 -1
  52. package/src/ContextMenu/ContextMenu.test.tsx +36 -3
  53. package/src/ContextMenu/ContextMenu.tsx +27 -11
  54. package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
  55. package/src/Dialog/Dialog.tsx +2 -30
  56. package/src/Dialog/__tests__/Dialog.a11y.chromium.spec.ts +21 -14
  57. package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
  58. package/src/DropdownMenu/DropdownMenu.tsx +10 -3
  59. package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
  60. package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +21 -14
  61. package/src/Markdown/Markdown.spec.md +37 -23
  62. package/src/Markdown/Markdown.test.tsx +38 -0
  63. package/src/Markdown/Markdown.tsx +20 -2
  64. package/src/Markdown/plugins/semanticFence.test.tsx +3 -2
  65. package/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts +22 -15
  66. package/src/PowerSearch/PowerSearch.test.tsx +45 -0
  67. package/src/Switch/__tests__/Switch.a11y.chromium.spec.ts +25 -18
  68. package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +19 -12
  69. package/src/TextInput/__tests__/TextInput.a11y.chromium.spec.ts +21 -14
  70. package/src/ToggleButton/__tests__/ToggleButton.a11y.chromium.spec.ts +18 -11
  71. package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
  72. package/src/Typeahead/BaseTypeahead.tsx +17 -3
  73. package/src/theme/syntax/SyntaxTheme.tsx +46 -5
  74. package/src/theme/syntax/defineSyntaxTheme.diff.test.ts +91 -0
  75. package/src/theme/syntax/defineSyntaxTheme.ts +75 -15
  76. package/src/theme/syntax/index.ts +5 -1
  77. package/src/theme/syntax/tokens.ts +32 -2
  78. package/src/theme/syntax/useSyntaxTheme.diff.test.tsx +40 -0
  79. package/src/utils/focusReturn.test.ts +79 -2
  80. package/src/utils/focusReturn.ts +48 -3
@@ -166,29 +166,17 @@ test('the state inventory describes the labels the page actually renders', async
166
166
  expect(wrong).toEqual([]);
167
167
  });
168
168
 
169
- test('every expectation is exercised by at least one bound state', async ({
170
- page,
171
- }) => {
172
- // A whole-binding sweep: every state, every expectation, a fresh mount each
173
- // time. That is minutes of real browser work, not the seconds a single-state
174
- // test takes.
175
- test.setTimeout(5 * 60 * 1000);
176
- const cdp = await page.context().newCDPSession(page);
177
- const results: BindingResult[] = [];
178
- for (const state of SWITCH_BINDING_STATES) {
179
- results.push(await runState(page, cdp, state));
180
- }
181
- // An expectation that applies to nothing is not coverage, however green it
182
- // looks. This is the only place that can notice: the contract never sees the
183
- // states, so whether a condition matches a real one is a binding-side fact.
184
- expect(neverExercised(results)).toEqual([]);
185
- expect(unmatchedKnownFailures(SWITCH_KNOWN_FAILURES, results)).toEqual([]);
186
- });
169
+ // Each state's result, recorded by its own test below so the whole-suite check
170
+ // at the end reads it instead of measuring every state a second time. A state
171
+ // whose test did not run in this worker (a filtered run, or a worker restarted
172
+ // after a failure) is measured by the whole-suite check itself.
173
+ const recorded = new Map<string, BindingResult>();
187
174
 
188
175
  for (const state of SWITCH_BINDING_STATES) {
189
176
  test(`Switch [${state.id}] — ${state.summary}`, async ({page}) => {
190
177
  const cdp = await page.context().newCDPSession(page);
191
178
  const result = await runState(page, cdp, state);
179
+ recorded.set(state.id, result);
192
180
  const report = summarize(SWITCH_PATTERN, [result]);
193
181
  // eslint-disable-next-line no-console -- the report is this run's artifact
194
182
  console.log(formatReport(report));
@@ -201,3 +189,22 @@ for (const state of SWITCH_BINDING_STATES) {
201
189
  expect(report.counts.unexpectedPass).toBe(0);
202
190
  });
203
191
  }
192
+
193
+ test('every expectation is exercised by at least one bound state', async ({
194
+ page,
195
+ }) => {
196
+ // A whole-binding sweep: every state's result, read from its own test or,
197
+ // when that test did not run in this worker, measured here (minutes of real
198
+ // browser work in that case, so the budget stays).
199
+ test.setTimeout(5 * 60 * 1000);
200
+ const cdp = await page.context().newCDPSession(page);
201
+ const results: BindingResult[] = [];
202
+ for (const state of SWITCH_BINDING_STATES) {
203
+ results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
204
+ }
205
+ // An expectation that applies to nothing is not coverage, however green it
206
+ // looks. This is the only place that can notice: the contract never sees the
207
+ // states, so whether a condition matches a real one is a binding-side fact.
208
+ expect(neverExercised(results)).toEqual([]);
209
+ expect(unmatchedKnownFailures(SWITCH_KNOWN_FAILURES, results)).toEqual([]);
210
+ });
@@ -163,24 +163,18 @@ test('every binding fact matches the rendered semantic state', async ({
163
163
  expect(wrong).toEqual([]);
164
164
  });
165
165
 
166
- test('every expectation is exercised by at least one bound state', async ({
167
- page,
168
- }) => {
169
- test.setTimeout(5 * 60 * 1000);
170
- const cdp = await page.context().newCDPSession(page);
171
- const results: BindingResult[] = [];
172
- for (const state of TABS_BINDING_STATES) {
173
- results.push(await runState(page, cdp, state));
174
- }
175
- expect(neverExercised(results)).toEqual([]);
176
- expect(unmatchedKnownFailures(TABS_KNOWN_FAILURES, results)).toEqual([]);
177
- });
166
+ // Each state's result, recorded by its own test below so the whole-suite check
167
+ // at the end reads it instead of measuring every state a second time. A state
168
+ // whose test did not run in this worker (a filtered run, or a worker restarted
169
+ // after a failure) is measured by the whole-suite check itself.
170
+ const recorded = new Map<string, BindingResult>();
178
171
 
179
172
  for (const state of TABS_BINDING_STATES) {
180
173
  test(`${state.binding} [${state.id}] — ${state.summary}`, async ({page}) => {
181
174
  test.setTimeout(3 * 60 * 1000);
182
175
  const cdp = await page.context().newCDPSession(page);
183
176
  const result = await runState(page, cdp, state);
177
+ recorded.set(state.id, result);
184
178
  const report = summarize(TABS_PATTERN, [result]);
185
179
  // eslint-disable-next-line no-console -- the report is this run's artifact
186
180
  console.log(formatReport(report));
@@ -189,3 +183,16 @@ for (const state of TABS_BINDING_STATES) {
189
183
  expect(report.counts.unexpectedPass).toBe(0);
190
184
  });
191
185
  }
186
+
187
+ test('every expectation is exercised by at least one bound state', async ({
188
+ page,
189
+ }) => {
190
+ test.setTimeout(5 * 60 * 1000);
191
+ const cdp = await page.context().newCDPSession(page);
192
+ const results: BindingResult[] = [];
193
+ for (const state of TABS_BINDING_STATES) {
194
+ results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
195
+ }
196
+ expect(neverExercised(results)).toEqual([]);
197
+ expect(unmatchedKnownFailures(TABS_KNOWN_FAILURES, results)).toEqual([]);
198
+ });
@@ -176,26 +176,18 @@ test('every state declaration matches the browser accessibility node', async ({
176
176
  expect(wrong).toEqual([]);
177
177
  });
178
178
 
179
- test('every expectation is exercised and every known failure matches once', async ({
180
- page,
181
- }) => {
182
- test.setTimeout(8 * 60 * 1000);
183
- const cdp = await page.context().newCDPSession(page);
184
- const results: BindingResult[] = [];
185
- for (const state of TEXT_INPUT_BINDING_STATES) {
186
- results.push(await runState(page, cdp, state));
187
- }
188
- expect(neverExercised(results)).toEqual([]);
189
- expect(unmatchedKnownFailures(TEXT_INPUT_KNOWN_FAILURES, results)).toEqual(
190
- [],
191
- );
192
- });
179
+ // Each state's result, recorded by its own test below so the whole-suite check
180
+ // at the end reads it instead of measuring every state a second time. A state
181
+ // whose test did not run in this worker (a filtered run, or a worker restarted
182
+ // after a failure) is measured by the whole-suite check itself.
183
+ const recorded = new Map<string, BindingResult>();
193
184
 
194
185
  for (const state of TEXT_INPUT_BINDING_STATES) {
195
186
  test(`${state.binding} [${state.id}] — ${state.summary}`, async ({page}) => {
196
187
  test.setTimeout(3 * 60 * 1000);
197
188
  const cdp = await page.context().newCDPSession(page);
198
189
  const result = await runState(page, cdp, state);
190
+ recorded.set(state.id, result);
199
191
  const report = summarize(TEXT_INPUT_PATTERN, [result]);
200
192
  // eslint-disable-next-line no-console -- the report is this run's artifact
201
193
  console.log(formatReport(report));
@@ -204,3 +196,18 @@ for (const state of TEXT_INPUT_BINDING_STATES) {
204
196
  expect(report.counts.unexpectedPass).toBe(0);
205
197
  });
206
198
  }
199
+
200
+ test('every expectation is exercised and every known failure matches once', async ({
201
+ page,
202
+ }) => {
203
+ test.setTimeout(8 * 60 * 1000);
204
+ const cdp = await page.context().newCDPSession(page);
205
+ const results: BindingResult[] = [];
206
+ for (const state of TEXT_INPUT_BINDING_STATES) {
207
+ results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
208
+ }
209
+ expect(neverExercised(results)).toEqual([]);
210
+ expect(unmatchedKnownFailures(TEXT_INPUT_KNOWN_FAILURES, results)).toEqual(
211
+ [],
212
+ );
213
+ });
@@ -143,22 +143,17 @@ test('the state inventory matches rendered labels and applicability facts', asyn
143
143
  expect(wrong).toEqual([]);
144
144
  });
145
145
 
146
- test('every expectation is exercised by at least one ToggleButton state', async ({
147
- page,
148
- }) => {
149
- test.setTimeout(5 * 60 * 1000);
150
- const cdp = await page.context().newCDPSession(page);
151
- const results: BindingResult[] = [];
152
- for (const state of TOGGLE_BUTTON_BINDING_STATES) {
153
- results.push(await runState(page, cdp, state));
154
- }
155
- expect(neverExercised(results)).toEqual([]);
156
- });
146
+ // Each state's result, recorded by its own test below so the whole-suite check
147
+ // at the end reads it instead of measuring every state a second time. A state
148
+ // whose test did not run in this worker (a filtered run, or a worker restarted
149
+ // after a failure) is measured by the whole-suite check itself.
150
+ const recorded = new Map<string, BindingResult>();
157
151
 
158
152
  for (const state of TOGGLE_BUTTON_BINDING_STATES) {
159
153
  test(`ToggleButton [${state.id}] — ${state.summary}`, async ({page}) => {
160
154
  const cdp = await page.context().newCDPSession(page);
161
155
  const result = await runState(page, cdp, state);
156
+ recorded.set(state.id, result);
162
157
  const report = summarize(TOGGLE_BUTTON_PATTERN, [result]);
163
158
  // eslint-disable-next-line no-console -- the report is this run's evidence
164
159
  console.log(formatReport(report));
@@ -167,3 +162,15 @@ for (const state of TOGGLE_BUTTON_BINDING_STATES) {
167
162
  expect(report.counts.unexpectedPass).toBe(0);
168
163
  });
169
164
  }
165
+
166
+ test('every expectation is exercised by at least one ToggleButton state', async ({
167
+ page,
168
+ }) => {
169
+ test.setTimeout(5 * 60 * 1000);
170
+ const cdp = await page.context().newCDPSession(page);
171
+ const results: BindingResult[] = [];
172
+ for (const state of TOGGLE_BUTTON_BINDING_STATES) {
173
+ results.push(recorded.get(state.id) ?? (await runState(page, cdp, state)));
174
+ }
175
+ expect(neverExercised(results)).toEqual([]);
176
+ });
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * @file BaseTypeahead.test.tsx
5
5
  * @input BaseTypeahead public props and a synchronous SearchSource
6
- * @output Combobox contract tests, including stale source refresh on reopen
6
+ * @output Combobox contract tests, including stale source refresh on focus and reopen
7
7
  * @position Colocated verification for BaseTypeahead
8
8
  */
9
9
 
@@ -209,6 +209,104 @@ describe('BaseTypeahead', () => {
209
209
  expect(input).toHaveFocus();
210
210
  });
211
211
 
212
+ it.each([
213
+ ['a click', 'click'],
214
+ ['keyboard focus', 'tab'],
215
+ ] as const)(
216
+ 'bootstraps the replacement source when %s refocuses the input',
217
+ async (_label, refocus) => {
218
+ const user = userEvent.setup();
219
+ const originalSource: SearchSource<SearchableItem> = {
220
+ search: () => [],
221
+ bootstrap: () => [resultItem],
222
+ };
223
+ const updatedBootstrap = vi.fn(() => [
224
+ {id: '2', label: 'Updated result'},
225
+ ]);
226
+ const updatedSource: SearchSource<SearchableItem> = {
227
+ search: () => [],
228
+ bootstrap: updatedBootstrap,
229
+ };
230
+ const renderField = (searchSource: SearchSource<SearchableItem>) => (
231
+ <>
232
+ <BaseTypeahead
233
+ searchSource={searchSource}
234
+ value={null}
235
+ onChange={() => {}}
236
+ hasEntriesOnFocus
237
+ />
238
+ <button type="button">Sort</button>
239
+ </>
240
+ );
241
+ const {rerender} = render(renderField(originalSource));
242
+ const input = screen.getByRole('combobox');
243
+ const sortButton = screen.getByRole('button', {name: 'Sort'});
244
+
245
+ await user.click(input);
246
+ await waitFor(() => {
247
+ expect(input).toHaveAttribute('aria-expanded', 'true');
248
+ });
249
+
250
+ // Focus moves to another control, which then hands the field a new
251
+ // source (a sort or filter change rebuilding the caller's source).
252
+ await user.click(sortButton);
253
+ expect(input).not.toHaveFocus();
254
+ expect(input).toHaveAttribute('aria-expanded', 'false');
255
+ rerender(renderField(updatedSource));
256
+
257
+ if (refocus === 'click') {
258
+ await user.click(input);
259
+ } else {
260
+ await user.tab({shift: true});
261
+ }
262
+ expect(input).toHaveFocus();
263
+ await waitFor(() => {
264
+ expect(input).toHaveAttribute('aria-expanded', 'true');
265
+ expect(
266
+ screen.getByRole('option', {name: 'Updated result', hidden: true}),
267
+ ).toBeInTheDocument();
268
+ });
269
+ expect(
270
+ screen.queryByRole('option', {name: 'Result', hidden: true}),
271
+ ).not.toBeInTheDocument();
272
+ expect(updatedBootstrap).toHaveBeenCalledTimes(1);
273
+ },
274
+ );
275
+
276
+ it('re-shows cached results without bootstrapping again when the source is unchanged', async () => {
277
+ const user = userEvent.setup();
278
+ const bootstrap = vi.fn(() => [resultItem]);
279
+ const source: SearchSource<SearchableItem> = {search: () => [], bootstrap};
280
+ render(
281
+ <>
282
+ <BaseTypeahead
283
+ searchSource={source}
284
+ value={null}
285
+ onChange={() => {}}
286
+ hasEntriesOnFocus
287
+ />
288
+ <button type="button">Sort</button>
289
+ </>,
290
+ );
291
+ const input = screen.getByRole('combobox');
292
+
293
+ await user.click(input);
294
+ await waitFor(() => {
295
+ expect(input).toHaveAttribute('aria-expanded', 'true');
296
+ });
297
+ await user.click(screen.getByRole('button', {name: 'Sort'}));
298
+ expect(input).toHaveAttribute('aria-expanded', 'false');
299
+
300
+ await user.click(input);
301
+ await waitFor(() => {
302
+ expect(input).toHaveAttribute('aria-expanded', 'true');
303
+ });
304
+ expect(
305
+ screen.getByRole('option', {name: 'Result', hidden: true}),
306
+ ).toBeInTheDocument();
307
+ expect(bootstrap).toHaveBeenCalledTimes(1);
308
+ });
309
+
212
310
  it('counts grapheme clusters when enforcing minQueryLength', async () => {
213
311
  const search = vi.fn(() => [resultItem]);
214
312
  render(
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * @file BaseTypeahead.tsx
7
7
  * @input Uses React, StyleX, usePopover, TypeaheadItem
8
- * @output Exports BaseTypeahead with focused-input menu reactivation
8
+ * @output Exports BaseTypeahead; focus bootstraps a replaced search source
9
9
  * @position Core implementation; used by Typeahead and Tokenizer
10
10
  *
11
11
  * Pure combobox engine: input, search, keyboard navigation, dropdown.
@@ -610,6 +610,10 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
610
610
  // in the closure are stale (a selection cleared them) and shouldn't
611
611
  // be re-shown.
612
612
  const resultsGenRef = useRef(0);
613
+ // The generation at which the current searchSource took over. Results
614
+ // populated before it (resultsGenRef below this) came from a replaced
615
+ // source: they can never be re-shown, so focus bootstraps the new source.
616
+ const sourceGenRef = useRef(0);
613
617
 
614
618
  // Results still arriving from a replaced source must not land in the new
615
619
  // one's menu.
@@ -617,7 +621,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
617
621
  if (prevSearchSourceRef.current !== searchSource) {
618
622
  prevSearchSourceRef.current.cancel?.();
619
623
  prevSearchSourceRef.current = searchSource;
620
- searchGenRef.current++;
624
+ sourceGenRef.current = ++searchGenRef.current;
621
625
  }
622
626
 
623
627
  // Layer for dropdown
@@ -889,11 +893,21 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
889
893
  // Shared by handleFocus and handleClick: bootstrap entries if none are
890
894
  // loaded yet, or re-show cached results that haven't been invalidated by
891
895
  // a selection since (comparing the two generation refs catches that).
896
+ // Results cached from a replaced search source count as none, so focusing
897
+ // an empty field bootstraps the new source instead of opening nothing.
898
+ // Results a selection invalidated still keep the menu closed when the
899
+ // selection returns focus to the input.
892
900
  const openIfEligible = useCallback(() => {
893
901
  if (isDisabled) {
894
902
  return;
895
903
  }
896
- if (hasEntriesOnFocus && results.length === 0 && query.length === 0) {
904
+ const hasResultsFromCurrentSource =
905
+ results.length > 0 && resultsGenRef.current >= sourceGenRef.current;
906
+ if (
907
+ hasEntriesOnFocus &&
908
+ !hasResultsFromCurrentSource &&
909
+ query.length === 0
910
+ ) {
897
911
  void performBootstrap();
898
912
  } else if (
899
913
  results.length > 0 &&
@@ -20,6 +20,8 @@ import {
20
20
  type SyntaxThemeTokenKey,
21
21
  } from './defineSyntaxTheme';
22
22
  import {useMediaQuery} from '../../hooks/useMediaQuery';
23
+ import {useTheme} from '../useTheme';
24
+ import {formatColor, parseColor} from '../../utils/color';
23
25
 
24
26
  // =============================================================================
25
27
  // Context
@@ -59,6 +61,22 @@ export interface UseSyntaxThemeReturn {
59
61
  // Hook
60
62
  // =============================================================================
61
63
 
64
+ /** The strength of the default diff row paints: the muted tokens at 25%. */
65
+ const DIFF_TINT_STRENGTH = 0.25;
66
+
67
+ /**
68
+ * The colour `color-mix(in srgb, <colour> <strength>, transparent)` paints, as
69
+ * a value a canvas can use. Returns undefined for a colour the shared parser
70
+ * does not read (the CSS default then stands).
71
+ */
72
+ function mixWithTransparent(
73
+ colour: string,
74
+ strength: number,
75
+ ): string | undefined {
76
+ const parsed = parseColor(colour);
77
+ return parsed ? formatColor({...parsed, a: parsed.a * strength}) : undefined;
78
+ }
79
+
62
80
  /**
63
81
  * Access the current syntax theme's token values, resolved for the active
64
82
  * color mode. Returns null if no SyntaxTheme provider is present.
@@ -74,6 +92,7 @@ export interface UseSyntaxThemeReturn {
74
92
  */
75
93
  export function useSyntaxTheme(): UseSyntaxThemeReturn | null {
76
94
  const ctx = use(SyntaxThemeContext);
95
+ const page = useTheme();
77
96
  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
78
97
  const effectiveMode: 'light' | 'dark' = prefersDark ? 'dark' : 'light';
79
98
 
@@ -83,13 +102,35 @@ export function useSyntaxTheme(): UseSyntaxThemeReturn | null {
83
102
  }
84
103
  const resolved: Partial<Record<SyntaxThemeTokenKey, string>> = {};
85
104
  for (const key of ALL_SYNTAX_KEYS) {
86
- resolved[key] = resolveSyntaxTokenForMode(
87
- ctx.theme.__inputTokens[key],
88
- effectiveMode,
89
- );
105
+ const input = ctx.theme.__inputTokens[key];
106
+ if (input !== undefined) {
107
+ resolved[key] = resolveSyntaxTokenForMode(input, effectiveMode);
108
+ }
109
+ }
110
+ // A diff row slot the theme did not set follows its CSS default; a canvas
111
+ // consumer needs a colour it can paint, so the marker slots take the
112
+ // theme's own resolved punctuation colour and the row paints derive from
113
+ // the enclosing page theme's resolved tokens (the same mix the CSS
114
+ // default makes), never from a probe outside the nearest Theme.
115
+ for (const key of ALL_SYNTAX_KEYS) {
116
+ if (resolved[key] !== undefined) {
117
+ continue;
118
+ }
119
+ const fallback = ctx.theme.tokens[key] ?? '';
120
+ if (key === 'added-marker' || key === 'removed-marker') {
121
+ resolved[key] = resolved.punctuation ?? fallback;
122
+ } else if (key === 'added' || key === 'removed') {
123
+ const base = page.token(
124
+ key === 'added' ? '--color-success-muted' : '--color-error-muted',
125
+ );
126
+ resolved[key] =
127
+ mixWithTransparent(base, DIFF_TINT_STRENGTH) ?? fallback;
128
+ } else {
129
+ resolved[key] = fallback;
130
+ }
90
131
  }
91
132
  return resolved as Record<SyntaxThemeTokenKey, string>;
92
- }, [ctx, effectiveMode]);
133
+ }, [ctx, effectiveMode, page]);
93
134
 
94
135
  if (!ctx || !tokens) {
95
136
  return null;
@@ -0,0 +1,91 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it, vi} from 'vitest';
4
+ import {
5
+ defineSyntaxTheme,
6
+ REQUIRED_SYNTAX_KEYS,
7
+ syntaxThemeStyle,
8
+ syntaxThemeToCSS,
9
+ } from './defineSyntaxTheme';
10
+ import {syntaxTokenDefaults} from './tokens';
11
+ import {dracula} from './presets';
12
+
13
+ const base = Object.fromEntries(
14
+ REQUIRED_SYNTAX_KEYS.map(key => [key, '#123456']),
15
+ ) as Parameters<typeof defineSyntaxTheme>[0]['tokens'];
16
+
17
+ describe('diff row slots in a syntax theme', () => {
18
+ it('are optional: a theme that sets none keeps the semantic defaults', () => {
19
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
20
+ const theme = defineSyntaxTheme({name: 't', tokens: base});
21
+ expect(warn).not.toHaveBeenCalled();
22
+ expect(theme.tokens.added).toBe(
23
+ syntaxTokenDefaults['--color-syntax-added'],
24
+ );
25
+ expect(theme.tokens.removed).toBe(
26
+ syntaxTokenDefaults['--color-syntax-removed'],
27
+ );
28
+ // A defined theme that sets no header paint gets none: the header row
29
+ // sits on the theme's own background with punctuation text.
30
+ expect(theme.tokens['diff-header']).toBe('transparent');
31
+ // A theme that sets no marker colour keeps its own line-number colour.
32
+ expect(theme.tokens['added-marker']).toBe(
33
+ 'var(--color-syntax-punctuation)',
34
+ );
35
+ expect(theme.tokens['removed-marker']).toBe(
36
+ 'var(--color-syntax-punctuation)',
37
+ );
38
+ warn.mockRestore();
39
+ });
40
+
41
+ it('may be overridden to harmonize with the theme', () => {
42
+ const theme = defineSyntaxTheme({
43
+ name: 't',
44
+ tokens: {...base, added: ['#e6ffed', '#1b3a24'], removed: '#ffeef0'},
45
+ });
46
+ expect(theme.tokens.added).toBe('light-dark(#e6ffed, #1b3a24)');
47
+ expect(theme.tokens.removed).toBe('#ffeef0');
48
+ expect(theme.tokens['diff-header']).toBe('transparent');
49
+ });
50
+
51
+ it('skips unset diff slots in the CSS and style output of a hand-built definition', () => {
52
+ const tokens = {...dracula.tokens};
53
+ delete (tokens as Partial<typeof tokens>).added;
54
+ delete (tokens as Partial<typeof tokens>)['added-marker'];
55
+ const theme = {...dracula, tokens};
56
+ expect(syntaxThemeToCSS(theme)).not.toContain('undefined');
57
+ expect(syntaxThemeToCSS(theme)).not.toContain('--color-syntax-added:');
58
+ expect(syntaxThemeStyle(theme)).not.toHaveProperty('--color-syntax-added');
59
+ expect(syntaxThemeStyle(theme)).toHaveProperty('--color-syntax-removed');
60
+ });
61
+
62
+ it('leaves the shipped presets valid without them: row paints default, markers follow punctuation', () => {
63
+ expect(dracula.tokens.added).toBe(
64
+ syntaxTokenDefaults['--color-syntax-added'],
65
+ );
66
+ expect(dracula.tokens['added-marker']).toBe(
67
+ 'var(--color-syntax-punctuation)',
68
+ );
69
+ expect(dracula.tokens['removed-marker']).toBe(
70
+ 'var(--color-syntax-punctuation)',
71
+ );
72
+ });
73
+
74
+ it('defaults derive from the semantic tokens, never a literal colour', () => {
75
+ expect(syntaxTokenDefaults['--color-syntax-added']).toBe(
76
+ 'color-mix(in srgb, var(--color-success-muted) 25%, transparent)',
77
+ );
78
+ expect(syntaxTokenDefaults['--color-syntax-removed']).toBe(
79
+ 'color-mix(in srgb, var(--color-error-muted) 25%, transparent)',
80
+ );
81
+ expect(syntaxTokenDefaults['--color-syntax-diff-header']).toBe(
82
+ 'var(--color-background-muted)',
83
+ );
84
+ expect(syntaxTokenDefaults['--color-syntax-added-marker']).toBe(
85
+ 'var(--color-text-green)',
86
+ );
87
+ expect(syntaxTokenDefaults['--color-syntax-removed-marker']).toBe(
88
+ 'var(--color-text-red)',
89
+ );
90
+ });
91
+ });