@astryxdesign/core 0.7.4-canary.626f01e → 0.7.4-canary.768c2f0

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.
@@ -28,6 +28,7 @@ export interface CommandPaletteItemProps extends Omit<BaseProps<HTMLDivElement>,
28
28
  isHighlighted?: boolean;
29
29
  /**
30
30
  * Whether this item is currently selected (picker mode).
31
+ * When omitted inside CommandPalette, derived from the palette value.
31
32
  * @default false
32
33
  */
33
34
  isSelected?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"CommandPaletteItem.d.ts","sourceRoot":"","sources":["../../src/CommandPalette/CommandPaletteItem.tsx"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AAEH,OAAO,EAA0C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE9E,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAqD5C,MAAM,WAAW,uBAAwB,SAAQ,IAAI,CACnD,SAAS,CAAC,cAAc,CAAC,EACzB,UAAU,GAAG,UAAU,CACxB;IACC,yCAAyC;IACzC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,iFAAiF;IACjF,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,aAAa,EAAE,qBAAqB,EACpC,UAAU,EAAE,kBAAkB,EAC9B,UAAkB,EAClB,QAAQ,EACR,GAAG,EACH,MAAM,EACN,SAAS,EACT,KAAK,EACL,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,gBAAgB,EAC9B,GAAG,KAAK,EACT,EAAE,uBAAuB,+BAoFzB;yBAnGe,kBAAkB"}
1
+ {"version":3,"file":"CommandPaletteItem.d.ts","sourceRoot":"","sources":["../../src/CommandPalette/CommandPaletteItem.tsx"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AAEH,OAAO,EAA0C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE9E,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAqD5C,MAAM,WAAW,uBAAwB,SAAQ,IAAI,CACnD,SAAS,CAAC,cAAc,CAAC,EACzB,UAAU,GAAG,UAAU,CACxB;IACC,yCAAyC;IACzC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,iFAAiF;IACjF,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,aAAa,EAAE,qBAAqB,EACpC,UAAU,EAAE,kBAAkB,EAC9B,UAAkB,EAClB,QAAQ,EACR,GAAG,EACH,MAAM,EACN,SAAS,EACT,KAAK,EACL,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,WAAW,EACpB,YAAY,EAAE,gBAAgB,EAC9B,GAAG,KAAK,EACT,EAAE,uBAAuB,+BAwFzB;yBAvGe,kBAAkB"}
@@ -148,7 +148,11 @@ export function CommandPaletteItem({
148
148
  ...pressable,
149
149
  onClick: isDisabled ? undefined : composeEventHandlers(onClickProp, handleClick),
150
150
  onMouseEnter: onMouseEnterProp,
151
- ...mergeProps(themeProps('command-palette-item'), stylex.props(styles.item, !isDisabled && interactionOverlayStyles.backgroundColor, isHighlighted && styles.itemHighlighted, isSelected && styles.itemSelected, isDisabled && styles.itemDisabled, xstyle), className, style),
151
+ ...mergeProps(themeProps('command-palette-item', {
152
+ highlighted: isHighlighted ? 'highlighted' : null,
153
+ selected: isSelected ? 'selected' : null,
154
+ disabled: isDisabled ? 'disabled' : null
155
+ }), stylex.props(styles.item, !isDisabled && interactionOverlayStyles.backgroundColor, isHighlighted && styles.itemHighlighted, isSelected && styles.itemSelected, isDisabled && styles.itemDisabled, xstyle), className, style),
152
156
  children: children
153
157
  });
154
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.4-canary.626f01e",
3
+ "version": "0.7.4-canary.768c2f0",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -792,8 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
- "@astryxdesign/a11y-spec": "0.7.4-canary.626f01e",
796
- "@astryxdesign/cli": "0.7.4-canary.626f01e",
795
+ "@astryxdesign/a11y-spec": "0.7.4-canary.768c2f0",
796
+ "@astryxdesign/cli": "0.7.4-canary.768c2f0",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -225,7 +225,10 @@ export const docs = {
225
225
  {className: 'astryx-command-palette-group'},
226
226
  {className: 'astryx-command-palette-group-heading'},
227
227
  {className: 'astryx-command-palette-input'},
228
- {className: 'astryx-command-palette-item'},
228
+ {
229
+ className: 'astryx-command-palette-item',
230
+ states: ['highlighted', 'selected', 'disabled'],
231
+ },
229
232
  {className: 'astryx-command-palette-list'},
230
233
  ],
231
234
  },
@@ -21,7 +21,12 @@ export const docs = {
21
21
  {
22
22
  guidance: true,
23
23
  description:
24
- 'Use non-interactive child content such as icons, descriptions, and shortcut hints; activate the option itself instead of nesting buttons or links.',
24
+ 'Inside CommandPalette, each item is an option in the listbox popup of the WAI-ARIA combobox pattern: keyboard focus stays in the query field, arrow keys move the highlight through aria-activedescendant, and Enter selects the highlighted item.',
25
+ },
26
+ {
27
+ guidance: false,
28
+ description:
29
+ 'Nest buttons, links, or other interactive controls inside an item. Option semantics make descendants presentational, so use non-interactive content such as icons, descriptions, and shortcut hints, and activate the option itself.',
25
30
  },
26
31
  {
27
32
  guidance: false,
@@ -45,7 +50,12 @@ export const docs = {
45
50
  ],
46
51
  },
47
52
  theming: {
48
- targets: [{className: 'astryx-command-palette-item'}],
53
+ targets: [
54
+ {
55
+ className: 'astryx-command-palette-item',
56
+ states: ['highlighted', 'selected', 'disabled'],
57
+ },
58
+ ],
49
59
  },
50
60
  props: [
51
61
  {
@@ -79,7 +89,7 @@ export const docs = {
79
89
  name: 'isSelected',
80
90
  type: 'boolean',
81
91
  description:
82
- 'Whether this item represents the selected value in picker mode.',
92
+ 'Whether this item represents the selected value in picker mode. Derived from the CommandPalette value when omitted inside CommandPalette.',
83
93
  default: 'false',
84
94
  },
85
95
  {
@@ -117,7 +127,8 @@ export const docsZh = {
117
127
  '通过指针激活此条目后调用。CommandPalette 中的键盘选择通过根组件的 onValueChange 报告。',
118
128
  isHighlighted:
119
129
  '此条目是否为当前键盘结果。在 CommandPalette 内省略时从上下文派生。',
120
- isSelected: '此条目是否代表选择器模式中的已选值。',
130
+ isSelected:
131
+ '此条目是否代表选择器模式中的已选值。在 CommandPalette 内省略时从面板的值派生。',
121
132
  isDisabled: '条目是否不可用并禁止指针激活。',
122
133
  xstyle: 'StyleX 布局自定义样式。必须是 stylex.create() 的值。',
123
134
  },
@@ -135,7 +146,8 @@ export const docsDense = {
135
146
  onSelect:
136
147
  'called after pointer activation; palette keyboard selection uses root onValueChange',
137
148
  isHighlighted: 'active keyboard result; derived from context when omitted',
138
- isSelected: 'selected value in picker mode',
149
+ isSelected:
150
+ 'selected value in picker mode; derived from palette value when omitted',
139
151
  isDisabled: 'unavailable; suppresses pointer activation',
140
152
  xstyle: 'StyleX layout styles; must be stylex.create() value',
141
153
  },
@@ -195,17 +195,6 @@ describe('CommandPaletteItem', () => {
195
195
  expect(item).toHaveAttribute('aria-selected', 'true');
196
196
  });
197
197
 
198
- it('paints context-derived highlight when the item is not selected', () => {
199
- renderWithContext(
200
- <CommandPaletteItem value="test">Item</CommandPaletteItem>,
201
- {highlightedIndex: 0},
202
- );
203
-
204
- expect(getComputedStyle(screen.getByRole('option')).backgroundColor).toBe(
205
- 'var(--color-overlay-hover)',
206
- );
207
- });
208
-
209
198
  it('lets explicit visual state override context-derived state', () => {
210
199
  renderWithContext(
211
200
  <CommandPaletteItem value="test" isHighlighted={false} isSelected={false}>
@@ -216,7 +205,8 @@ describe('CommandPaletteItem', () => {
216
205
 
217
206
  const item = screen.getByRole('option');
218
207
  expect(item).toHaveAttribute('aria-selected', 'false');
219
- expect(getComputedStyle(item).backgroundColor).toBe('rgba(0, 0, 0, 0)');
208
+ expect(item).not.toHaveAttribute('data-selected');
209
+ expect(item).not.toHaveAttribute('data-highlighted');
220
210
  });
221
211
 
222
212
  it('protects owned option semantics from native passthrough props', () => {
@@ -354,6 +344,69 @@ describe('CommandPaletteItem', () => {
354
344
  expect(item).toHaveStyle({marginBlockStart: '8px'});
355
345
  });
356
346
 
347
+ it('reflects selected and disabled state on its theme target', () => {
348
+ render(
349
+ <>
350
+ <CommandPaletteItem value="rest">Rest</CommandPaletteItem>
351
+ <CommandPaletteItem value="selected" isSelected>
352
+ Selected
353
+ </CommandPaletteItem>
354
+ <CommandPaletteItem value="disabled" isDisabled>
355
+ Disabled
356
+ </CommandPaletteItem>
357
+ </>,
358
+ );
359
+
360
+ const rest = screen.getByRole('option', {name: 'Rest'});
361
+ expect(rest).not.toHaveAttribute('data-selected');
362
+ expect(rest).not.toHaveAttribute('data-disabled');
363
+ expect(screen.getByRole('option', {name: 'Selected'})).toHaveAttribute(
364
+ 'data-selected',
365
+ 'selected',
366
+ );
367
+ expect(screen.getByRole('option', {name: 'Disabled'})).toHaveAttribute(
368
+ 'data-disabled',
369
+ 'disabled',
370
+ );
371
+ });
372
+
373
+ it('reflects context-derived selection on its theme target', () => {
374
+ renderWithContext(
375
+ <CommandPaletteItem value="test">Item</CommandPaletteItem>,
376
+ {value: 'test'},
377
+ );
378
+
379
+ expect(screen.getByRole('option')).toHaveAttribute(
380
+ 'data-selected',
381
+ 'selected',
382
+ );
383
+ });
384
+
385
+ it('reflects explicit and context-derived highlight on its theme target', () => {
386
+ renderWithContext(
387
+ <>
388
+ <CommandPaletteItem value="test">
389
+ Context highlighted
390
+ </CommandPaletteItem>
391
+ <CommandPaletteItem value="other" isHighlighted>
392
+ Explicitly highlighted
393
+ </CommandPaletteItem>
394
+ <CommandPaletteItem value="rest">Rest</CommandPaletteItem>
395
+ </>,
396
+ {highlightedIndex: 0},
397
+ );
398
+
399
+ expect(
400
+ screen.getByRole('option', {name: 'Context highlighted'}),
401
+ ).toHaveAttribute('data-highlighted', 'highlighted');
402
+ expect(
403
+ screen.getByRole('option', {name: 'Explicitly highlighted'}),
404
+ ).toHaveAttribute('data-highlighted', 'highlighted');
405
+ expect(screen.getByRole('option', {name: 'Rest'})).not.toHaveAttribute(
406
+ 'data-highlighted',
407
+ );
408
+ });
409
+
357
410
  it('sets data-value attribute', () => {
358
411
  render(<CommandPaletteItem value="my-value">Item</CommandPaletteItem>);
359
412
  expect(screen.getByRole('option')).toHaveAttribute(
@@ -88,6 +88,7 @@ export interface CommandPaletteItemProps extends Omit<
88
88
  isHighlighted?: boolean;
89
89
  /**
90
90
  * Whether this item is currently selected (picker mode).
91
+ * When omitted inside CommandPalette, derived from the palette value.
91
92
  * @default false
92
93
  */
93
94
  isSelected?: boolean;
@@ -202,7 +203,11 @@ export function CommandPaletteItem({
202
203
  }
203
204
  onMouseEnter={onMouseEnterProp}
204
205
  {...mergeProps(
205
- themeProps('command-palette-item'),
206
+ themeProps('command-palette-item', {
207
+ highlighted: isHighlighted ? 'highlighted' : null,
208
+ selected: isSelected ? 'selected' : null,
209
+ disabled: isDisabled ? 'disabled' : null,
210
+ }),
206
211
  stylex.props(
207
212
  styles.item,
208
213
  !isDisabled && interactionOverlayStyles.backgroundColor,
@@ -2,7 +2,9 @@
2
2
 
3
3
  /**
4
4
  * @file CommandPaletteItemAudit.a11y.chromium.spec.ts
5
- * @input Exact-head CommandPaletteItem stories across semantics, states, themes, direction, and viewport sizes
5
+ * @input Exact-head CommandPaletteItem stories, plus the inline grouped palette in
6
+ * the CommandPalette audit matrix, across states, themes, direction, pointers,
7
+ * and viewport sizes
6
8
  * @output Screenshots and fail-closed sensor receipts for the CommandPaletteItem whole-component audit
7
9
  * @position Component-owned Night Watch browser evidence; CI runs it only for a CommandPaletteItem audit pull request
8
10
  */
@@ -11,7 +13,13 @@ import {createHash} from 'node:crypto';
11
13
  import {execFileSync} from 'node:child_process';
12
14
  import * as fs from 'node:fs';
13
15
  import * as path from 'node:path';
14
- import {expect, test, type Page} from '@playwright/test';
16
+ import {
17
+ expect,
18
+ test,
19
+ type CDPSession,
20
+ type Locator,
21
+ type Page,
22
+ } from '@playwright/test';
15
23
  import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
16
24
  import {
17
25
  DEFAULT_STORYBOOK_DIR,
@@ -26,8 +34,17 @@ const NARROW = {width: 320, height: 720};
26
34
  type Mode = 'light' | 'dark';
27
35
  type Direction = 'ltr' | 'rtl';
28
36
  type Theme = 'neutral' | 'butter';
29
- type Story = 'states' | 'combined-and-long';
30
- type State = 'rest' | 'hover';
37
+ type Pointer = 'fine' | 'coarse';
38
+ type Story = 'states' | 'combined-and-long' | 'palette';
39
+ type State =
40
+ | 'rest'
41
+ | 'hover'
42
+ | 'pressed'
43
+ | 'pressed-highlighted'
44
+ | 'disabled-hover'
45
+ | 'touch-press'
46
+ | 'keyboard-highlight'
47
+ | 'keyboard-selected-highlight';
31
48
 
32
49
  type AuditCase = {
33
50
  key: string;
@@ -36,6 +53,8 @@ type AuditCase = {
36
53
  direction: Direction;
37
54
  theme: Theme;
38
55
  state: State;
56
+ pointer: Pointer;
57
+ forcedColors: boolean;
39
58
  viewport: {width: number; height: number};
40
59
  };
41
60
 
@@ -57,6 +76,8 @@ function addPair(
57
76
  direction: options.direction ?? 'ltr',
58
77
  theme: options.theme ?? 'neutral',
59
78
  state: options.state ?? 'rest',
79
+ pointer: options.pointer ?? 'fine',
80
+ forcedColors: options.forcedColors ?? false,
60
81
  viewport: options.viewport ?? WIDE,
61
82
  });
62
83
  }
@@ -64,9 +85,19 @@ function addPair(
64
85
 
65
86
  addPair('states-rest');
66
87
  addPair('states-hover', {state: 'hover'});
88
+ addPair('states-pressed', {state: 'pressed'});
89
+ addPair('states-pressed-highlighted', {state: 'pressed-highlighted'});
90
+ addPair('states-disabled-hover', {state: 'disabled-hover'});
67
91
  addPair('states-custom-theme', {theme: 'butter'});
68
92
  addPair('states-rtl', {direction: 'rtl'});
69
93
  addPair('states-narrow', {viewport: NARROW});
94
+ addPair('states-coarse', {viewport: NARROW, pointer: 'coarse'});
95
+ addPair('states-touch-press', {
96
+ viewport: NARROW,
97
+ pointer: 'coarse',
98
+ state: 'touch-press',
99
+ });
100
+ addPair('states-forced-colors', {forcedColors: true});
70
101
  addPair('combined-rest', {story: 'combined-and-long'});
71
102
  addPair('combined-custom-theme', {
72
103
  story: 'combined-and-long',
@@ -76,6 +107,45 @@ addPair('combined-narrow', {
76
107
  story: 'combined-and-long',
77
108
  viewport: NARROW,
78
109
  });
110
+ addPair('palette-keyboard-highlight', {
111
+ story: 'palette',
112
+ state: 'keyboard-highlight',
113
+ });
114
+ addPair('palette-keyboard-selected-highlight', {
115
+ story: 'palette',
116
+ state: 'keyboard-selected-highlight',
117
+ });
118
+ addPair('palette-keyboard-selected-highlight-custom-theme', {
119
+ story: 'palette',
120
+ state: 'keyboard-selected-highlight',
121
+ theme: 'butter',
122
+ });
123
+
124
+ addPair('palette-keyboard-highlight-forced-colors', {
125
+ story: 'palette',
126
+ state: 'keyboard-highlight',
127
+ forcedColors: true,
128
+ });
129
+ addPair('palette-keyboard-selected-highlight-forced-colors', {
130
+ story: 'palette',
131
+ state: 'keyboard-selected-highlight',
132
+ forcedColors: true,
133
+ });
134
+
135
+ /** Option each pointer state acts on, by its accessible text. */
136
+ const POINTER_TARGET: Partial<Record<State, string>> = {
137
+ hover: 'Open file',
138
+ pressed: 'Open file',
139
+ 'pressed-highlighted': 'Search files',
140
+ 'disabled-hover': 'Archived command',
141
+ 'touch-press': 'Open file',
142
+ };
143
+
144
+ /** Option the palette keyboard states move the active descendant to. */
145
+ const KEYBOARD_TARGET: Partial<Record<State, string>> = {
146
+ 'keyboard-highlight': 'Home',
147
+ 'keyboard-selected-highlight': 'Settings',
148
+ };
79
149
 
80
150
  test.describe.configure({mode: 'serial', retries: 0});
81
151
  let server: StaticServer;
@@ -83,7 +153,9 @@ let checkoutSha: string;
83
153
  let storybookSha: string;
84
154
 
85
155
  function storyId(item: AuditCase) {
86
- return `core-commandpaletteitem--${item.story}`;
156
+ return item.story === 'palette'
157
+ ? 'core-commandpalette--audit-matrix'
158
+ : `core-commandpaletteitem--${item.story}`;
87
159
  }
88
160
 
89
161
  function storyUrl(item: AuditCase) {
@@ -96,6 +168,38 @@ async function settle(page: Page) {
96
168
  await page.waitForTimeout(40);
97
169
  }
98
170
 
171
+ async function centerOf(locator: Locator) {
172
+ const box = await locator.boundingBox();
173
+ if (box == null) {
174
+ throw new Error('target option has no rendered bounds');
175
+ }
176
+ return {x: box.x + box.width / 2, y: box.y + box.height / 2};
177
+ }
178
+
179
+ async function highlightedName(input: Locator) {
180
+ return input.evaluate(element => {
181
+ const id = element.getAttribute('aria-activedescendant');
182
+ const option = id ? document.getElementById(id) : null;
183
+ return option?.textContent?.replace(/\s+/g, ' ').trim() ?? null;
184
+ });
185
+ }
186
+
187
+ /** PageUp moves the palette to its first result; ArrowDown walks forward. */
188
+ async function highlightFromKeyboard(
189
+ page: Page,
190
+ input: Locator,
191
+ target: string,
192
+ ) {
193
+ await input.focus();
194
+ await page.keyboard.press('PageUp');
195
+ for (let step = 0; step < 4; step++) {
196
+ if ((await highlightedName(input)) === target) {
197
+ return;
198
+ }
199
+ await page.keyboard.press('ArrowDown');
200
+ }
201
+ }
202
+
99
203
  test.beforeAll(async () => {
100
204
  fs.rmSync(OUTPUT, {recursive: true, force: true});
101
205
  fs.mkdirSync(OUTPUT, {recursive: true});
@@ -133,6 +237,8 @@ for (const item of cases) {
133
237
  viewport: item.viewport,
134
238
  colorScheme: item.mode,
135
239
  reducedMotion: 'reduce',
240
+ hasTouch: item.pointer === 'coarse',
241
+ forcedColors: item.forcedColors ? 'active' : 'none',
136
242
  });
137
243
  const page = await context.newPage();
138
244
  const pageErrors: string[] = [];
@@ -140,13 +246,20 @@ for (const item of cases) {
140
246
  await page.goto(storyUrl(item), {waitUntil: 'load'});
141
247
  await page.locator('#storybook-root').waitFor();
142
248
 
143
- const listName =
144
- item.story === 'states'
145
- ? 'Command item states'
146
- : 'Combined and long command item states';
147
- const list = page.getByRole('listbox', {name: listName});
249
+ const isPalette = item.story === 'palette';
250
+ const subject = isPalette
251
+ ? page.getByRole('region', {name: 'Grouped command palette'})
252
+ : page.locator('#storybook-root');
253
+ const list = isPalette
254
+ ? subject.getByRole('listbox')
255
+ : subject.getByRole('listbox', {
256
+ name:
257
+ item.story === 'states'
258
+ ? 'Command item states'
259
+ : 'Combined and long command item states',
260
+ });
148
261
  const options = list.getByRole('option');
149
- const expectedCount = item.story === 'states' ? 4 : 2;
262
+ const expectedCount = item.story === 'combined-and-long' ? 2 : 4;
150
263
  const expectedDisabled = item.story === 'states' ? 1 : 0;
151
264
  await expect(list).toBeVisible();
152
265
  await expect(options).toHaveCount(expectedCount);
@@ -156,12 +269,51 @@ for (const item of cases) {
156
269
  await expect(
157
270
  list.locator('[role="option"][aria-disabled="true"]'),
158
271
  ).toHaveCount(expectedDisabled);
159
- await expect(list.locator('button, a, [tabindex]')).toHaveCount(0);
272
+ await expect(
273
+ list.locator('[role="option"] :is(button, a, [tabindex])'),
274
+ ).toHaveCount(0);
275
+
276
+ const pointerTarget = POINTER_TARGET[item.state];
277
+ const keyboardTarget = KEYBOARD_TARGET[item.state];
278
+ const target = pointerTarget ?? keyboardTarget ?? null;
279
+ const targetOption =
280
+ target == null ? null : options.filter({hasText: target}).first();
281
+ let cdp: CDPSession | null = null;
160
282
 
161
- if (item.state === 'hover') {
162
- await options.first().hover();
163
- }
164
283
  await settle(page);
284
+ if (item.state === 'hover' || item.state === 'disabled-hover') {
285
+ await targetOption?.hover();
286
+ } else if (
287
+ item.state === 'pressed' ||
288
+ item.state === 'pressed-highlighted'
289
+ ) {
290
+ if (targetOption == null) {
291
+ throw new Error(`${item.key}: no press target`);
292
+ }
293
+ await targetOption.hover();
294
+ await page.mouse.down();
295
+ } else if (item.state === 'touch-press') {
296
+ if (targetOption == null) {
297
+ throw new Error(`${item.key}: no touch target`);
298
+ }
299
+ const point = await centerOf(targetOption);
300
+ cdp = await context.newCDPSession(page);
301
+ await cdp.send('Input.dispatchTouchEvent', {
302
+ type: 'touchStart',
303
+ touchPoints: [{x: point.x, y: point.y, id: 1}],
304
+ });
305
+ // The touch press controller believes a stationary press after its
306
+ // onset delay; hold past it before reading the painted state.
307
+ await page.waitForTimeout(320);
308
+ } else if (keyboardTarget != null) {
309
+ await highlightFromKeyboard(
310
+ page,
311
+ subject.getByRole('combobox'),
312
+ keyboardTarget,
313
+ );
314
+ }
315
+ await page.evaluate(async () => document.fonts.ready);
316
+ await page.waitForTimeout(40);
165
317
 
166
318
  const observed = await page.evaluate(() => ({
167
319
  theme: document
@@ -173,20 +325,41 @@ for (const item of cases) {
173
325
  ).direction,
174
326
  viewport: {width: innerWidth, height: innerHeight},
175
327
  reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
328
+ coarsePointer: matchMedia('(pointer: coarse)').matches,
329
+ forcedColors: matchMedia('(forced-colors: active)').matches,
176
330
  horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
177
331
  fonts: document.fonts.status,
178
332
  }));
333
+ const listBackdrop = await list.evaluate(element => {
334
+ let node: Element | null = element;
335
+ while (node) {
336
+ const color = getComputedStyle(node).backgroundColor;
337
+ if (color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') {
338
+ return color;
339
+ }
340
+ node = node.parentElement;
341
+ }
342
+ return getComputedStyle(document.body).backgroundColor;
343
+ });
179
344
  const optionStates = await options.evaluateAll(nodes =>
180
345
  nodes.map(node => {
181
346
  const style = getComputedStyle(node);
182
347
  const box = node.getBoundingClientRect();
183
348
  return {
184
349
  name: node.textContent?.replace(/\s+/g, ' ').trim(),
350
+ id: node.id || null,
185
351
  selected: node.getAttribute('aria-selected'),
186
352
  disabled: node.getAttribute('aria-disabled'),
353
+ themeSelected: node.getAttribute('data-selected'),
354
+ themeDisabled: node.getAttribute('data-disabled'),
355
+ active: node.matches(':active'),
356
+ hovered: node.matches(':hover'),
357
+ press: node.getAttribute('data-astryx-press'),
187
358
  backgroundColor: style.backgroundColor,
359
+ backgroundImage: style.backgroundImage,
188
360
  color: style.color,
189
361
  opacity: style.opacity,
362
+ cursor: style.cursor,
190
363
  geometry: {
191
364
  x: box.x,
192
365
  y: box.y,
@@ -196,6 +369,18 @@ for (const item of cases) {
196
369
  };
197
370
  }),
198
371
  );
372
+ const combobox = isPalette
373
+ ? await subject.getByRole('combobox').evaluate(element => ({
374
+ focused: element === document.activeElement,
375
+ activeDescendant: element.getAttribute('aria-activedescendant'),
376
+ }))
377
+ : null;
378
+
379
+ const targetState =
380
+ target == null
381
+ ? null
382
+ : (optionStates.find(option => option.name?.startsWith(target)) ??
383
+ null);
199
384
  const semanticsPass =
200
385
  optionStates.length === expectedCount &&
201
386
  optionStates.filter(option => option.selected === 'true').length === 1 &&
@@ -204,11 +389,48 @@ for (const item of cases) {
204
389
  optionStates.every(
205
390
  option => option.geometry.width > 0 && option.geometry.height > 0,
206
391
  );
392
+ let statePass = true;
393
+ if (item.state === 'hover') {
394
+ statePass = targetState?.hovered === true;
395
+ } else if (item.state === 'disabled-hover') {
396
+ statePass =
397
+ targetState?.hovered === true &&
398
+ targetState.disabled === 'true' &&
399
+ targetState.backgroundColor === 'rgba(0, 0, 0, 0)' &&
400
+ targetState.cursor === 'default';
401
+ } else if (
402
+ item.state === 'pressed' ||
403
+ item.state === 'pressed-highlighted'
404
+ ) {
405
+ statePass = targetState?.active === true;
406
+ } else if (item.state === 'touch-press') {
407
+ statePass = targetState?.press === 'on';
408
+ } else if (keyboardTarget != null) {
409
+ statePass =
410
+ combobox?.focused === true &&
411
+ targetState?.id != null &&
412
+ combobox.activeDescendant === targetState.id &&
413
+ (item.state === 'keyboard-selected-highlight'
414
+ ? targetState.selected === 'true'
415
+ : targetState.selected === 'false');
416
+ }
417
+
418
+ const png = isPalette
419
+ ? await subject.screenshot({animations: 'disabled'})
420
+ : await page.screenshot({animations: 'disabled', fullPage: true});
421
+ if (item.state === 'pressed' || item.state === 'pressed-highlighted') {
422
+ await page.mouse.up();
423
+ }
424
+ if (cdp != null) {
425
+ await cdp.send('Input.dispatchTouchEvent', {
426
+ type: 'touchEnd',
427
+ touchPoints: [],
428
+ });
429
+ }
207
430
 
208
- const png = await page.screenshot({animations: 'disabled', fullPage: true});
209
431
  const image = `${item.key}.png`;
210
432
  const receipt = {
211
- schemaVersion: 1,
433
+ schemaVersion: 2,
212
434
  expected: {
213
435
  build: checkoutSha,
214
436
  storyId: storyId(item),
@@ -216,6 +438,9 @@ for (const item of cases) {
216
438
  direction: item.direction,
217
439
  theme: item.theme,
218
440
  state: item.state,
441
+ target,
442
+ pointer: item.pointer,
443
+ forcedColors: item.forcedColors,
219
444
  viewport: item.viewport,
220
445
  optionCount: expectedCount,
221
446
  disabledCount: expectedDisabled,
@@ -226,6 +451,8 @@ for (const item of cases) {
226
451
  ...observed,
227
452
  listRole: await list.getAttribute('role'),
228
453
  listName: await list.getAttribute('aria-label'),
454
+ listBackdrop,
455
+ combobox,
229
456
  optionStates,
230
457
  pageErrors,
231
458
  },
@@ -235,7 +462,7 @@ for (const item of cases) {
235
462
  width: png.readUInt32BE(16),
236
463
  height: png.readUInt32BE(20),
237
464
  },
238
- checks: {semanticsPass},
465
+ checks: {semanticsPass, statePass},
239
466
  passed:
240
467
  storybookSha === checkoutSha &&
241
468
  pageErrors.length === 0 &&
@@ -245,9 +472,12 @@ for (const item of cases) {
245
472
  observed.viewport.width === item.viewport.width &&
246
473
  observed.viewport.height === item.viewport.height &&
247
474
  observed.reducedMotion &&
475
+ observed.coarsePointer === (item.pointer === 'coarse') &&
476
+ observed.forcedColors === item.forcedColors &&
248
477
  !observed.horizontalOverflow &&
249
478
  observed.fonts === 'loaded' &&
250
- semanticsPass,
479
+ semanticsPass &&
480
+ statePass,
251
481
  };
252
482
  fs.writeFileSync(path.join(OUTPUT, image), png);
253
483
  fs.writeFileSync(
@@ -270,7 +500,7 @@ test('manifest is complete', async () => {
270
500
  ) as AuditReceipt,
271
501
  );
272
502
  const manifest = {
273
- schemaVersion: 1,
503
+ schemaVersion: 2,
274
504
  component: 'core/CommandPaletteItem',
275
505
  build: checkoutSha,
276
506
  frames: cases.map(item => ({
@@ -0,0 +1,304 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 7
4
+ kind: component
5
+ id: component:Overlay
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-10-22
11
+ owners: [cixzhang]
12
+ review_triggers: [public-api, layout, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/Overlay/Overlay.test.tsx,
16
+ apps/storybook/stories/Overlay.stories.tsx,
17
+ ]
18
+ modules: []
19
+ families: []
20
+ design_specs: [design:bidirectionality]
21
+ architecture:
22
+ [
23
+ architecture:public-component-api,
24
+ architecture:component-theming-surface,
25
+ architecture:internationalization,
26
+ ]
27
+ contributing: []
28
+ system_specs: [spec:AST-002/DEC-1, spec:AST-017/FR5, spec:AST-027/FR2]
29
+ ---
30
+
31
+ # Overlay component contract
32
+
33
+ <!-- Describe the system, not the project: present tense, what it does. No proposals, history, pull requests, or research in the record; see docs/contributing/spec-writing.md and report its rubric results in the pull request. -->
34
+
35
+ This record governs how several pieces of content are placed over an Overlay's
36
+ surface, each at a preset spot or at fractional coordinates, including the
37
+ stacking of a container that holds such content. Overlay's scrim, reveal modes,
38
+ own `position` values, and alignment remain described by its source, tests, and
39
+ `Overlay.doc.mjs` until a record claims them.
40
+
41
+ ## Contract at a glance
42
+
43
+ | Area | Contract |
44
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45
+ | Public contract | New `OverlayRegion` component, exported beside Overlay: `position` (required) is a preset (`'fill' \| 'top' \| 'bottom' \| 'center' \| 'top-start' \| 'top-end' \| 'bottom-start' \| 'bottom-end'`) or open placement `{x, y, width?, height?}` in 0–1 fractions; plus `children`, `ref`, `xstyle`, `className`, `style`. |
46
+ | Behavior | Inside a fill Overlay, a preset region sits at that spot (corners follow the writing direction) and open placement sits at fractions of the media measured from its top-left corner, never mirrored. A sized open region stays inside the media. Regions scale with the media. |
47
+ | End-user impact | Several captions, tags, badges, or hotspots over one image or video each stay on their spot of the media at every size, in either text direction. |
48
+ | Builder impact | None by default. A builder places content with `OverlayRegion` instead of hand-written absolute positioning; moving a region stays the builder's code. |
49
+ | Compatibility/readiness | Additive: a new component and export; an Overlay without a region renders and stacks exactly as before. This record is current authority; `OverlayRegion`, its consumer docs, region tests, and region stories have not shipped yet. |
50
+ | Review checks | Reject open placement that mirrors in right-to-left, corner presets that do not, JavaScript measurement or observers, drag or resize behavior inside Overlay, a role or focus stop on the region, a theme target on it, or a region placed inside a strip. |
51
+ | Governing rules | FR1–FR11 below; `design:bidirectionality/DR3`, `DR5`, `DR6`; `architecture:component-theming-surface/INV4`; `spec:AST-017/FR5`; `spec:AST-027/FR2`. |
52
+
53
+ This table is a review projection; the body below is authoritative.
54
+
55
+ ## Intent
56
+
57
+ Overlay layers content over a bounded surface, usually media, and places that
58
+ content as one block: the whole surface or a band at the top or bottom. Some
59
+ surfaces carry several pieces of content, each belonging to its own spot: two
60
+ captions drawn into an image, a badge in a corner beside a caption, a tag on a
61
+ face. A spot is either one of Overlay's familiar places or a point on the media
62
+ described as fractions of its width and height, because the media is shown at
63
+ many sizes and the spot must stay on the same pixels of it. Overlay owns that
64
+ placement because Overlay owns the surface it is measured against.
65
+
66
+ ## Compatibility and migration
67
+
68
+ - Released default preserved: `yes`; an Overlay without a region renders and
69
+ stacks exactly as before. Only a container that holds a region isolates its
70
+ stacking (FR10).
71
+ - Compatibility class: additive component and export.
72
+ - Controlled/uncontrolled behavior: not applicable; a region holds no state.
73
+ - Migration decision: none required (`spec:AST-017/FR5`).
74
+
75
+ Consumer migration instructions belong in consumer docs and release notes.
76
+
77
+ ## Ownership boundary
78
+
79
+ **Owns**
80
+
81
+ - `OverlayRegion`: its `position` values, their geometry, clamping, scaling, and
82
+ reference box.
83
+ - The rule that open placement is measured in the media's own physical space.
84
+ - The rule that regions are placed only in fill overlays, and the fallback
85
+ everywhere else.
86
+ - The stacking isolation of a container that holds a region.
87
+
88
+ **Does not own / non-goals**
89
+
90
+ - Moving, dragging, or resizing a region, including pointer capture, keyboard
91
+ steps, snapping, and when a new position is reported — owned by the product
92
+ callsite (`component:Overlay/DEC-3`).
93
+ - What a region paints: borders, fills, handles, and text — owned by the
94
+ region's content.
95
+ - Making the base content fill the surface — owned by the caller, as for every
96
+ Overlay; fractions are measured against the surface, so media that does not
97
+ fill it does not line up with them.
98
+ - Overlay's own `position` and `align` — unchanged; a region does not read or
99
+ alter them beyond FR2.
100
+ - Placement relative to a trigger or outside the surface — owned by the layer
101
+ runtime (Popover, Tooltip, HoverCard).
102
+
103
+ ## Public concepts
104
+
105
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
106
+ | -------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------- | ------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
107
+ | Preset | `fill`, `top`, `bottom`, `center`, `top-start`, `top-end`, `bottom-start`, `bottom-end` | `fill`, `top`, and `bottom` are the places Overlay's own `position` names: the whole surface, or a full-width band at that edge. `center` and the corners hold content-sized boxes at that spot. | Fill overlays (FR2) | None; required prop | `component:Overlay` | stable | Other strings are rejected by the public type |
108
+ | Open placement | `{x, y, width?, height?}`; each a number from 0 to 1 | `x`, `y`: the region's top-left corner as fractions of its reference box, measured from that box's top-left corner. `width`, `height`: the region's size as fractions, extending right and down; omitted means it fits its content. | Fill overlays (FR2) | None; required prop | `component:Overlay` | stable | Values clamped into 0–1; a non-finite coordinate is treated as 0 and a non-finite size as omitted (FR6) |
109
+
110
+ ## Behavioral and layout contract
111
+
112
+ | ID | Invariant | Basis | Draft review state |
113
+ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------ |
114
+ | FR1 | `OverlayRegion` rendered directly in the content of an Overlay or `useOverlay` host with `position="fill"`, the default, MUST position against that host's scrim box, which is the whole base surface: 0 and 1 are the media's edges, and the box matches the Overlay's `ref`. Any element between the region and the scrim that establishes a containing block for absolutely positioned descendants becomes its reference box instead. In this record, the surface means the region's reference box. | `component:Overlay/DEC-1` | settled |
115
+ | FR2 | Regions are placed only in fill hosts. A region rendered anywhere else MUST NOT be placed: in the content of an Overlay or `useOverlay` host whose `position` is `"top"` or `"bottom"`, in an Overlay's `children`, or with no Overlay or `useOverlay` host at all, it renders its content as ordinary in-flow content where it stands (in a strip, that content reveals and slides with the strip) and MUST emit one development warning per mount naming the fill-only rule. | `component:Overlay/DEC-4` | settled |
116
+ | FR3 | A preset region MUST sit at its spot inside Overlay's content inset: `fill` covers the inset box; `top` and `bottom` span its full width at that edge with content height; `center` is content-sized at its center; each corner is content-sized at that corner. Content larger than the inset box is clipped by the container, not shifted. | `component:Overlay/DEC-2` | settled |
117
+ | FR4 | Corner presets MUST resolve `start` and `end` against the writing direction of the region's own context: `top-start` is the top-left corner in left-to-right and the top-right corner in right-to-left. `fill`, `top`, `bottom`, and `center` are symmetric and do not change with direction. | `design:bidirectionality/DR3` | settled |
118
+ | FR5 | Open placement MUST measure `x` from the surface's left edge and `y` from its top edge in every writing direction, and MUST NOT mirror in right-to-left. The region's own content follows the ambient direction. | `design:bidirectionality/DR5` | settled |
119
+ | FR6 | Open coordinates and sizes MUST be clamped into 0–1 before placement. A non-finite coordinate MUST resolve to 0, and a non-finite size MUST resolve to omitted. | Invalid-value behavior | settled |
120
+ | FR7 | Open placement puts the region's top-left corner at (`x`, `y`) (`component:Overlay/DEC-6`). On an axis with a size, the region MUST be exactly that fraction of the surface, extending right or down, and MUST sit wholly inside it: its start is clamped between 0 and one minus the size. On an axis without a size, the region MUST fit its content starting at the coordinate, and any overhang past the container's edge is clipped, not shifted inward. | `component:Overlay/DEC-5` | settled |
121
+ | FR8 | When the surface changes size, every region MUST keep its spot: presets by their insets, open placement by its fractional position and size, without a re-render. | `component:Overlay/DEC-1` | settled |
122
+ | FR9 | A region MUST appear, hide, and become inert with the overlay content that contains it, following Overlay's `showOn` and `isOpen`, and MUST inherit the scrim's media theme. It adds no visibility rule of its own. | Overlay's existing reveal behavior | settled |
123
+ | FR10 | A placed region MUST paint above the fill surface's in-flow overlay content, including positioned in-flow content written after it, and a later placed region MUST paint above an earlier one. A placed region inside a wrapper that forms a stacking context is ordered only among that wrapper's content. An Overlay or `useOverlay` container with a placed region anywhere inside it MUST own an isolated stacking context; a container with no region inside it keeps its stacking unchanged. A placed region MUST NOT take part in the scrim's flow layout. | Absolute positioning within the surface; `spec:AST-027/FR2` | settled |
124
+ | FR11 | Overlay and `OverlayRegion` MUST NOT move, drag, or resize a region, and MUST NOT attach pointer, keyboard, or gesture handlers to it. A region moves only when its caller passes a new `position`. | `component:Overlay/DEC-3` | settled |
125
+
126
+ ### Allowed variation
127
+
128
+ - **AV1 — Content.** Any content may sit in a region; on an open region each
129
+ axis is sized or content-sized independently (FR7).
130
+ - **AV2 — Surface size and shape.** The surface may be any size or aspect ratio,
131
+ and may change size at any time (FR8).
132
+ - **AV3 — Mixed content.** In-flow overlay content, preset regions, and open
133
+ regions may share one surface (FR10).
134
+
135
+ ### Representative states
136
+
137
+ | State | Required invariant | Allowed variation |
138
+ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------ |
139
+ | Two captions, `top` and `bottom` presets | Each spans the inset width at its edge, like Overlay's own strips. | Caption text. |
140
+ | `top-start` badge, left-to-right and right-to-left | Top-left corner in left-to-right; top-right corner in right-to-left. | Badge content. |
141
+ | Open sized caption near the right edge | Box is the given fraction of the image and stays wholly inside, flush with that edge. | Which edge. |
142
+ | Open content-sized pin | Pin starts at its point and extends right and down; overhang past the container is clipped. | Pin content. |
143
+ | Open region, right-to-left page | Same spot on the media as left-to-right; the region's text reads right-to-left. | Writing direction. |
144
+ | Surface resized from 640px to 320px wide | Every region keeps its spot, with no re-render. | Any surface size. |
145
+ | Region in an Overlay's `children` or outside any Overlay | Is not placed: its content flows where it stands, and it warns once in development. | Location. |
146
+ | Region in a `position="bottom"` Overlay | Is not placed: its content flows in the strip like other strip content, and it warns once in development. | Strip content. |
147
+ | `showOn="hover"` overlay at rest | Regions are hidden with the rest of the overlay content. | Reveal mode. |
148
+
149
+ ### Transformation and precedence order
150
+
151
+ - **ORD1 — Placement.** Resolve the reference box (FR1, FR2) → for a preset,
152
+ place it in the content inset and resolve start/end against direction (FR3,
153
+ FR4); for open placement, clamp values (FR6) and resolve each axis as sized or
154
+ content-sized in physical space (FR5, FR7) → express the result as insets and
155
+ fractions of the box (FR8). Styling inputs compose after placement in the order
156
+ `architecture:public-component-api/INV6` gives; a caller who overrides inset or
157
+ size properties owns the result.
158
+
159
+ ### Performance and resources
160
+
161
+ - **PR1 — CSS geometry only.** Placement, clamping, and scaling MUST be achieved
162
+ with arithmetic on the given values and CSS: no measurement, observer,
163
+ listener, state, Effect, or extra render pass. The fill-only check adds no
164
+ measurement, state, or extra render pass.
165
+
166
+ ## Accessibility contract
167
+
168
+ - **AR1 — Presentation only.** `OverlayRegion` MUST add no role, accessible name,
169
+ description, focus stop, or text. Its content keeps its own semantics.
170
+ - **AR2 — Source order is reading order.** Assistive technology and keyboard
171
+ focus follow the regions' source order, not their position on the surface. A
172
+ caller renders regions in the order a reader should meet them.
173
+ - **AR3 — Movement offers non-drag paths.** Overlay's documented move pattern
174
+ for open regions pairs pointer dragging with a single-pointer path that needs
175
+ no drag, where activating a spot on the media moves the region there, and a
176
+ keyboard path that moves a region whose content has focus by a fixed step per
177
+ arrow key, with arrow directions matching the visual axes
178
+ (`design:bidirectionality/DR6`).
179
+
180
+ ## Design relationships
181
+
182
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
183
+ | -------------------- | ------------------------------------------------------------ | ----------------------------- | -------------- | ------------------ |
184
+ | Preset region | Reads as Overlay's own places, with Overlay's content inset. | Owner direction | Supporting | FR3 |
185
+ | Corner preset in RTL | Leading and trailing corners follow reading order. | `design:bidirectionality/DR3` | Supporting | FR4 |
186
+ | Open region | Stays on the same spot of the media at every size. | Owner direction | Supporting | FR7, FR8 |
187
+ | Open region in RTL | Media is physical content and does not mirror. | `design:bidirectionality/DR5` | Supporting | FR5 |
188
+
189
+ ## Family and system relationships
190
+
191
+ - `design:bidirectionality` owns direction behavior: FR4 applies DR3 to corner
192
+ presets, FR5 applies DR5 to open placement, and AR3 applies DR6 to movement.
193
+ - `architecture:component-theming-surface/INV4` keeps a non-painting box out of
194
+ the theme surface: the region is a geometry-only box that paints nothing, so it
195
+ has no theme target and its content owns its paint.
196
+ - `architecture:public-component-api` owns prop admission and styling-input
197
+ order; ORD1 applies INV6.
198
+ - `spec:AST-027/FR2` puts the isolation boundary on the surface; FR10 makes the
199
+ Overlay or `useOverlay` container that holds a region that owner.
200
+ - `spec:AST-017/FR5` classifies this as nonbreaking: it adds a new component,
201
+ changes no default, and a caller who never renders a region sees no change.
202
+
203
+ ## Verification map
204
+
205
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
206
+ | ----------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
207
+ | FR1, FR2, FR6, FR11 | `Overlay.test.tsx` region suite | Region in Overlay and `useOverlay` content; in a `bottom` Overlay; outside any Overlay; out-of-range and non-finite input; pointer and key events | Unclamped values, a region outside the scrim, a placed region in a strip or a missing warning, or a region that moves on its own fails. | `audit:Overlay/api` |
208
+ | FR3, FR4, FR5, FR7, FR8, FR10 | Real-Chromium assertions in the Overlay region story's play function | Every preset; open sized, edge-clamped, and content-sized; resized surface; RTL; a region before a positioned control | A region off its spot by more than 0.5px, a corner that does not flip or an open region that does, the in-flow control hit at a region's center, or wrong isolation, fails. | `audit:Overlay/layout` |
209
+ | FR9 | `Overlay.test.tsx` reveal case with a region | `isOpen={false}` | A region outside the scrim, or a closed scrim that is not inert, fails. | `audit:Overlay/api` |
210
+ | AR1, AR2 | `Overlay.test.tsx` DOM check of the region element | Text content | Any attribute other than `class` and `style` on the region element, or text it adds, fails. | `audit:Overlay/a11y` |
211
+ | AR3 | Region move story's play function | Arrow key and single activation on the media, LTR and RTL | ArrowRight moving a region left, or an activation that does not move the region to the activated spot, fails. | `audit:Overlay/a11y` |
212
+ | PR1 | Source review of `OverlayRegion` | All inputs | A ref read, observer, listener, or Effect added for placement fails review. | `audit:Overlay/layout` |
213
+
214
+ ## Decision log
215
+
216
+ <!-- Record the boundary or requirement, not how it was reached. A rejected alternative is at most one line here, kept only when it is consequential and likely to recur. -->
217
+
218
+ ### DEC-1 — Several placed items are Overlay regions with one `position` prop
219
+
220
+ **Reference:** `component:Overlay/DEC-1`
221
+ **Decider:** cixzhang, 2026-10-22
222
+
223
+ Overlay places one content block, so a surface with several spots needs a child
224
+ that places itself. `OverlayRegion` inside Overlay's content inherits Overlay's
225
+ surface, reveal, and media theme with no new Overlay prop, and one `position`
226
+ prop covers both kinds of spot: a named place or open placement. Sizes live
227
+ inside the open placement object, so a region never carries `width` or `height`
228
+ props whose numbers mean fractions while the same names mean pixels on Card,
229
+ Button, and Grid.
230
+
231
+ Rejected: an object form of Overlay's own `position`, because it still places
232
+ only one block.
233
+
234
+ ### DEC-2 — Presets reuse Overlay's places and add the center and corners
235
+
236
+ **Reference:** `component:Overlay/DEC-2`
237
+ **Decider:** cixzhang, 2026-10-22
238
+
239
+ `fill`, `top`, and `bottom` mean on a region what they mean on Overlay, so a
240
+ builder reads one vocabulary. Static badges and labels need the center and the
241
+ corners, which Overlay's single block cannot hold beside other content. Corners
242
+ use `start` and `end` because a leading badge leads in either direction.
243
+
244
+ ### DEC-3 — Moving a region stays at the callsite
245
+
246
+ **Reference:** `component:Overlay/DEC-3`
247
+ **Decider:** cixzhang, 2026-10-22
248
+
249
+ What moving a region means differs by product: whether the position commits on
250
+ release or live, the keyboard step, snapping, which regions move, and what
251
+ happens while a new rendering of the media loads. Overlay therefore never moves
252
+ a region; the caller converts pointer and keyboard input into new fractions
253
+ measured against the region's reference box and passes them back.
254
+
255
+ Rejected: a drag hook on Overlay, because a shared drag primitive needs its own
256
+ record and more than one caller's needs.
257
+
258
+ ### DEC-4 — Regions belong to fill overlays
259
+
260
+ **Reference:** `component:Overlay/DEC-4`
261
+ **Decider:** cixzhang, 2026-10-22
262
+
263
+ A spot on the media is meaningless inside a strip whose height follows its own
264
+ content and which slides in and out. A strip is also the containing block of
265
+ everything inside it, so CSS cannot place a region against the media from
266
+ there. In a strip overlay a region therefore renders its content as ordinary
267
+ strip content and warns in development, so the content stays visible. The
268
+ public type cannot enforce the rule, because regions arrive through `content`.
269
+ The same fallback applies outside any fill host, where there is no surface to
270
+ measure against and nothing to isolate a region's paint order.
271
+
272
+ Rejected: placing a strip's regions against the container, because escaping the
273
+ strip needs a second rendering layer that duplicates Overlay's reveal.
274
+
275
+ ### DEC-5 — Open sized axes are clamped inside; content-sized axes are not
276
+
277
+ **Reference:** `component:Overlay/DEC-5`
278
+ **Decider:** cixzhang, 2026-10-22
279
+
280
+ A sized region can be kept inside the surface with arithmetic on the given
281
+ fractions, so it never spills off the media however far it is moved. Keeping a
282
+ content-sized region inside would require measuring it, which PR1 forbids, so
283
+ the container clips any overhang.
284
+
285
+ ### DEC-6 — Open placement names the region's top-left corner
286
+
287
+ **Reference:** `component:Overlay/DEC-6`
288
+ **Decider:** cixzhang, 2026-10-22
289
+
290
+ `x` and `y` are the region's top-left corner, and `width` and `height` extend
291
+ right and down. That matches how servers and editors report boxes (left, top,
292
+ width, height) and how CSS insets read, so a reported box passes through with no
293
+ conversion and is clamped with plain arithmetic.
294
+
295
+ Rejected: the region's center, because every reported box would need converting.
296
+
297
+ ## Open questions
298
+
299
+ None.
300
+
301
+ ## Content boundary
302
+
303
+ This file does not duplicate consumer prop tables/examples, current audit
304
+ results, implementation steps, or family/system rules. It links to their owners.