@colixsystems/widget-sdk 0.207.0 → 0.210.0

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.
@@ -7,16 +7,28 @@
7
7
  // thing tests can pin rather than a literal buried in JSX.
8
8
 
9
9
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
10
+ import { hexAlphaPercent, withHexAlpha } from "./contract.js";
11
+ import { normaliseThemeComponents } from "./theme-components.js";
10
12
 
11
13
  export const OVERLAY_SIZES = Object.freeze(["sm", "md", "lg", "full"]);
12
14
 
15
+ // `dialog` is centred; `sheet` rises from the bottom edge of the screen.
16
+ export const OVERLAY_PRESENTATIONS = Object.freeze(["dialog", "sheet"]);
17
+
13
18
  export const OVERLAY_DEFAULTS = Object.freeze({
14
19
  size: "md",
20
+ presentation: "dialog",
21
+ elevation: "lg",
15
22
  // Slate-black at 55%: dark enough to mute a busy page behind the panel,
16
23
  // light enough that the page still reads as "still there, just behind".
17
24
  scrim: "rgba(15, 23, 42, 0.55)",
18
25
  // Breathing room between the panel and the screen edge on a phone.
19
26
  screenPadding: 16,
27
+ // Scrim kept visible above a sheet, so it reads as a layer over the page.
28
+ sheetTopGap: 48,
29
+ // A themed scrim with no alpha takes the default's; none may hide the page.
30
+ scrimAlphaPercent: 55,
31
+ scrimAlphaMaxPercent: 85,
20
32
  });
21
33
 
22
34
  // `full` deliberately has no ceiling — a media/PDF preview wants the screen.
@@ -31,6 +43,48 @@ export function normaliseOverlaySize(size) {
31
43
  return OVERLAY_SIZES.indexOf(size) === -1 ? OVERLAY_DEFAULTS.size : size;
32
44
  }
33
45
 
46
+ export function normaliseOverlayPresentation(presentation) {
47
+ return OVERLAY_PRESENTATIONS.indexOf(presentation) === -1
48
+ ? OVERLAY_DEFAULTS.presentation
49
+ : presentation;
50
+ }
51
+
52
+ /**
53
+ * The theme's validated `modal` component scope (Design > Components > Dialogs):
54
+ * `<Modal>` paints from all of it, `<Overlay>` from its scrim alone.
55
+ *
56
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
57
+ * @returns {Record<string, string|number>} the set tokens; `{}` when none.
58
+ */
59
+ export function modalThemeScope(theme) {
60
+ const components = theme && typeof theme === "object" ? theme.components : null;
61
+ return normaliseThemeComponents({ modal: components && components.modal }).modal || {};
62
+ }
63
+
64
+ /**
65
+ * The scrim a themed colour really paints: always see-through, so a theme (or
66
+ * a planner) that names an opaque colour dims the page instead of hiding it.
67
+ */
68
+ export function themedScrim(color) {
69
+ if (!color) return OVERLAY_DEFAULTS.scrim;
70
+ const alpha = hexAlphaPercent(color);
71
+ const { scrimAlphaPercent, scrimAlphaMaxPercent } = OVERLAY_DEFAULTS;
72
+ return withHexAlpha(
73
+ color,
74
+ alpha >= 100 ? scrimAlphaPercent : Math.min(alpha, scrimAlphaMaxPercent),
75
+ );
76
+ }
77
+
78
+ /**
79
+ * The panel's corners: all four for a dialog, the top two for a sheet, which
80
+ * meets the screen edge with square ones.
81
+ */
82
+ export function overlayCornerStyle(radius, presentation) {
83
+ return presentation === "sheet"
84
+ ? { borderTopLeftRadius: radius, borderTopRightRadius: radius }
85
+ : { borderRadius: radius };
86
+ }
87
+
34
88
  /**
35
89
  * Resolve the values one overlay is painted with, from the workspace theme.
36
90
  *
@@ -39,25 +93,35 @@ export function normaliseOverlaySize(size) {
39
93
  *
40
94
  * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
41
95
  * @param {string} size — `sm` | `md` | `lg` | `full`.
96
+ * @param {string} [presentation] — `dialog` | `sheet`; `full` is always a dialog.
42
97
  */
43
- export function resolveOverlayTokens(theme, size) {
98
+ export function resolveOverlayTokens(theme, size, presentation) {
44
99
  const safeSize = normaliseOverlaySize(size);
100
+ const isFull = safeSize === "full";
101
+ const safePresentation = isFull
102
+ ? OVERLAY_DEFAULTS.presentation
103
+ : normaliseOverlayPresentation(presentation);
104
+ const isSheet = safePresentation === "sheet";
45
105
  const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
46
106
  const fallback = DEFAULT_THEME_TOKENS;
47
107
  const colors = base.colors || fallback.colors;
48
108
  const radii = base.radii || fallback.radii;
49
109
  const spacing = base.spacing || fallback.spacing;
50
- const elevation = (base.elevation || fallback.elevation).lg || {};
110
+ const elevation =
111
+ (base.elevation || fallback.elevation)[OVERLAY_DEFAULTS.elevation] || {};
51
112
 
52
113
  return {
53
114
  size: safeSize,
54
- scrim: OVERLAY_DEFAULTS.scrim,
115
+ presentation: safePresentation,
116
+ scrim: themedScrim(modalThemeScope(base).scrim),
55
117
  surface: colors.surface || fallback.colors.surface,
56
- radius: safeSize === "full" ? 0 : radii.lg || fallback.radii.lg,
118
+ radius: isFull ? 0 : radii.lg || fallback.radii.lg,
57
119
  padding: spacing.lg || fallback.spacing.lg,
58
120
  // A `full` overlay runs edge to edge; every other size keeps a margin so
59
121
  // the scrim stays visible and the panel reads as a layer, not a screen.
60
- screenPadding: safeSize === "full" ? 0 : OVERLAY_DEFAULTS.screenPadding,
122
+ screenPadding: isFull || isSheet ? 0 : OVERLAY_DEFAULTS.screenPadding,
123
+ topGap: isSheet ? OVERLAY_DEFAULTS.sheetTopGap : 0,
124
+ verticalAlign: isSheet ? "flex-end" : "center",
61
125
  maxWidth: SIZE_MAX_WIDTH[safeSize],
62
126
  elevation,
63
127
  };
@@ -16,30 +16,51 @@
16
16
 
17
17
  import React from "react";
18
18
  import { useHostTheme } from "./hooks.js";
19
- import { resolveOverlayTokens } from "./overlay-tokens.js";
19
+ import { overlayCornerStyle, resolveOverlayTokens } from "./overlay-tokens.js";
20
+
21
+ // Kept clickable, but out of the tab order and the accessibility tree.
22
+ const SILENT_BACKDROP = {
23
+ accessible: false,
24
+ focusable: false,
25
+ tabIndex: -1,
26
+ "aria-hidden": true,
27
+ importantForAccessibility: "no",
28
+ };
20
29
 
21
30
  /**
22
31
  * Bind the shared overlay component to one platform's primitives.
23
32
  *
24
- * @param {{Modal: any, View: any, Pressable: any, StyleSheet: any}} rn
33
+ * `namesPanel` is for a host whose `Modal` cannot carry the accessible name
34
+ * (React Native's): the panel carries it there, the dialog node on the web.
35
+ *
36
+ * @param {{Modal: any, View: any, Pressable: any, StyleSheet: any,
37
+ * namesPanel?: boolean}} rn
25
38
  */
26
39
  export function makeOverlay(rn) {
27
40
  const { Modal, View, Pressable, StyleSheet } = rn;
41
+ const namesPanel = Boolean(rn.namesPanel);
28
42
 
29
43
  function Overlay({
30
44
  visible = false,
31
45
  onRequestClose,
32
46
  size = "md",
47
+ presentation = "dialog",
33
48
  dismissOnBackdropPress = true,
49
+ backdropAccessible = true,
50
+ role = "dialog",
34
51
  accessibilityLabel,
52
+ accessibilityLabelledBy,
35
53
  closeAccessibilityLabel = "Close",
54
+ initialFocusRef,
55
+ panelFrame: PanelFrame,
36
56
  style,
57
+ testID,
37
58
  children,
38
59
  }) {
39
60
  const theme = useHostTheme();
40
61
  const tokens = React.useMemo(
41
- () => resolveOverlayTokens(theme, size),
42
- [theme, size],
62
+ () => resolveOverlayTokens(theme, size, presentation),
63
+ [theme, size, presentation],
43
64
  );
44
65
  const requestClose = React.useCallback(() => {
45
66
  if (typeof onRequestClose === "function") onRequestClose();
@@ -52,8 +73,9 @@ export function makeOverlay(rn) {
52
73
  const backdrop = {
53
74
  flex: 1,
54
75
  alignItems: "center",
55
- justifyContent: "center",
76
+ justifyContent: tokens.verticalAlign,
56
77
  padding: tokens.screenPadding,
78
+ ...(tokens.topGap ? { paddingTop: tokens.topGap } : null),
57
79
  backgroundColor: tokens.scrim,
58
80
  };
59
81
  const panel = {
@@ -64,11 +86,21 @@ export function makeOverlay(rn) {
64
86
  flexShrink: 1,
65
87
  ...(tokens.maxWidth ? { maxWidth: tokens.maxWidth } : { flex: 1 }),
66
88
  backgroundColor: tokens.surface,
67
- borderRadius: tokens.radius,
89
+ ...overlayCornerStyle(tokens.radius, tokens.presentation),
68
90
  padding: tokens.padding,
69
91
  ...tokens.elevation,
70
92
  };
71
93
 
94
+ const panelNode = React.createElement(
95
+ View,
96
+ {
97
+ style: [panel, style],
98
+ accessibilityLabel: namesPanel ? accessibilityLabel : undefined,
99
+ testID,
100
+ },
101
+ children,
102
+ );
103
+
72
104
  return React.createElement(
73
105
  Modal,
74
106
  {
@@ -77,6 +109,10 @@ export function makeOverlay(rn) {
77
109
  // Not animated: a sheet must answer the tap that opened it at once.
78
110
  animationType: "none",
79
111
  onRequestClose: requestClose,
112
+ role: role === "alertdialog" ? "alertdialog" : "dialog",
113
+ "aria-label": accessibilityLabelledBy ? undefined : accessibilityLabel,
114
+ "aria-labelledby": accessibilityLabelledBy,
115
+ initialFocusRef,
80
116
  },
81
117
  React.createElement(
82
118
  View,
@@ -85,18 +121,18 @@ export function makeOverlay(rn) {
85
121
  ? React.createElement(Pressable, {
86
122
  style: StyleSheet.absoluteFill,
87
123
  onPress: requestClose,
88
- accessibilityRole: "button",
89
- accessibilityLabel: closeAccessibilityLabel,
124
+ ...(backdropAccessible
125
+ ? {
126
+ accessibilityRole: "button",
127
+ accessibilityLabel: closeAccessibilityLabel,
128
+ }
129
+ : SILENT_BACKDROP),
90
130
  })
91
131
  : null,
92
- React.createElement(
93
- View,
94
- {
95
- style: [panel, style],
96
- accessibilityLabel,
97
- },
98
- children,
99
- ),
132
+ // @internal `panelFrame`: `<Modal>` wraps its panel, never the scrim.
133
+ PanelFrame
134
+ ? React.createElement(PanelFrame, { verticalAlign: tokens.verticalAlign }, panelNode)
135
+ : panelNode,
100
136
  ),
101
137
  );
102
138
  }
@@ -8,4 +8,11 @@
8
8
  import { Modal, View, Pressable, StyleSheet } from "react-native";
9
9
  import { makeOverlay } from "./overlay-view.js";
10
10
 
11
- export const Overlay = makeOverlay({ Modal, View, Pressable, StyleSheet });
11
+ // RN's `Modal` forwards no ARIA props, so the panel carries the name here.
12
+ export const Overlay = makeOverlay({
13
+ Modal,
14
+ View,
15
+ Pressable,
16
+ StyleSheet,
17
+ namesPanel: true,
18
+ });
@@ -0,0 +1,108 @@
1
+ // REQ-THEME-SURFACE: the theme a painted surface hands its subtree. Lives in
2
+ // the SDK so the web Player's containers and `<Modal>` on both hosts re-ink
3
+ // from one implementation.
4
+
5
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
6
+ import {
7
+ INK_CONTRAST,
8
+ deriveSurfaceTokens,
9
+ inkForBackdrop,
10
+ isHexColor,
11
+ } from "./contract.js";
12
+
13
+ /**
14
+ * REQ-THEME-SURFACE: the token overrides a container's CHILDREN inherit.
15
+ *
16
+ * A container that paints its own `backgroundColor` becomes the surface its
17
+ * subtree sits on, so an unset text colour there must read against THAT fill
18
+ * rather than the page's. Without this, the only way to keep a heading legible
19
+ * inside a dark card was to write a literal hex onto the node — which is what
20
+ * froze a built app's appearance against its own theme.
21
+ *
22
+ * Returns the SAME reference when nothing changes, so an untouched page
23
+ * allocates nothing and every child sees the identical context value.
24
+ *
25
+ * A photo-backed container is deliberately NOT handled here: no colour can be
26
+ * derived from an image, so its subtree keeps what it inherited and the scrim
27
+ * plus an explicit light text colour carries the hero.
28
+ *
29
+ * The web Player calls this; the export's generated layout renderer mirrors
30
+ * it — the two hosts must not differ here (CLAUDE.md §8).
31
+ */
32
+ export function surfaceAwareTheme(tenantTheme, backgroundColor) {
33
+ const surfaceTokens = deriveSurfaceTokens(backgroundColor);
34
+ if (!surfaceTokens) return tenantTheme;
35
+ const t = tenantTheme && typeof tenantTheme === "object" ? tenantTheme : {};
36
+ const typography = t.typography || null;
37
+ // The look's UNTOUCHED ink, carried forward so nesting stays idempotent. A
38
+ // band re-inks to the platform's light ink; a white card inside it must be
39
+ // judged against the LOOK's ink, or it can only ever land back on the
40
+ // platform default and the look's voice is lost one level in.
41
+ const base =
42
+ (typography && (typography.rolesBase || typography.roles)) || null;
43
+ // `base` when nothing needs replacing, which is what RESTORES the look's ink
44
+ // on a card nested inside an already-re-inked band. Falling through to the
45
+ // inherited `roles` there would leave the band's platform ink in place and
46
+ // the look could never come back.
47
+ const nextRoles = rolesOnSurface(base, surfaceTokens, backgroundColor) || base;
48
+ // sc-9346: buttons judge this fill against the surface the look was
49
+ // designed for, taken once at the outermost section and kept on nesting.
50
+ const colors = t.colors || {};
51
+ const backdropBase =
52
+ colors.backdropBase || colors.surface || DEFAULT_THEME_TOKENS.colors.surface;
53
+ const next = {
54
+ ...t,
55
+ colors: { ...colors, ...surfaceTokens, backdrop: backgroundColor, backdropBase },
56
+ };
57
+ if (typography && nextRoles && nextRoles !== typography.roles) {
58
+ next.typography = { ...typography, roles: nextRoles, rolesBase: base };
59
+ }
60
+ return next;
61
+ }
62
+
63
+ /**
64
+ * sc-8370: re-ink the type tiers for a surface their own ink cannot be read on.
65
+ *
66
+ * `roleColor` returns a tier's explicit hex before it ever consults the surface
67
+ * token, so patching `colors` alone cannot move text that carries one — and
68
+ * since sc-8131 every look states an ink for all six tiers, chosen against that
69
+ * colourway's PAGE background. A section painting its own fill is a different
70
+ * surface, and nothing re-derived the ink for it: a headline shipped dark on a
71
+ * dark opener band.
72
+ *
73
+ * The gate is MEASURED CONTRAST, not a light/dark polarity test. Polarity is
74
+ * the tempting rule and it is wrong in both directions. The platform's own
75
+ * predicate flips at luminance 0.55, while the real break-even between the two
76
+ * inks sits near 0.20 — so every mid-tone fill in that band reads as "wants
77
+ * light text" when dark text is in fact the stronger choice. On `#a8a29e`, a
78
+ * shipped preset secondary, a polarity rule swaps a look's ink at 7.0:1 for
79
+ * the platform's at 2.4:1: it would have turned a passing section into an AA
80
+ * failure, which is the very defect this function exists to remove.
81
+ *
82
+ * So a tier is left alone whenever it is already READABLE, which is what keeps
83
+ * a look's voice everywhere it works, and is replaced only where it is not AND
84
+ * the derived token is genuinely better. A wash with no good answer — a
85
+ * white-to-black band, whose midpoint favours neither ink — therefore keeps
86
+ * what it inherited instead of committing to a colour that is wrong for half
87
+ * the band.
88
+ *
89
+ * Returns null when nothing moves, so the caller leaves `typography` untouched
90
+ * and an unaffected page keeps its identical context value.
91
+ */
92
+ function rolesOnSurface(roles, surfaceTokens, backgroundColor) {
93
+ let out = null;
94
+ for (const [role, seed] of Object.entries(
95
+ DEFAULT_THEME_TOKENS.typography.roles,
96
+ )) {
97
+ const current = roles && roles[role] && roles[role].color;
98
+ // A tier with no frozen ink already resolves through `colors` per render.
99
+ if (!isHexColor(current)) continue;
100
+ const derived = surfaceTokens[seed.colorToken];
101
+ // The quiet tiers are body-sized too, so they get no softer bar.
102
+ const ink = inkForBackdrop(current, derived, backgroundColor, INK_CONTRAST.text);
103
+ if (ink === current) continue;
104
+ if (!out) out = { ...roles };
105
+ out[role] = { ...roles[role], color: derived };
106
+ }
107
+ return out;
108
+ }
package/dist/web-modal.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // re-render rebuilt it, and its Escape and focus handling never re-armed.
8
8
  //
9
9
  // Implements only the `Modal` props `<Overlay>` passes: `visible`,
10
- // `onRequestClose` and `children`; it is never animated.
10
+ // `onRequestClose`, the dialog's role and name, `initialFocusRef` and
11
+ // `children`; it is never animated.
11
12
 
12
13
  import React from "react";
13
14
  import { createPortal } from "react-dom";
@@ -37,13 +38,25 @@ function tabStops(dialog) {
37
38
  }
38
39
 
39
40
  /**
40
- * @param {{visible?: boolean, onRequestClose?: () => void, children?: any}} props
41
+ * @param {{visible?: boolean, onRequestClose?: () => void, role?: string,
42
+ * "aria-label"?: string, "aria-labelledby"?: string,
43
+ * initialFocusRef?: {current: any}, children?: any}} props
41
44
  */
42
- export function WebModal({ visible = true, onRequestClose, children }) {
45
+ export function WebModal({
46
+ visible = true,
47
+ onRequestClose,
48
+ role = "dialog",
49
+ "aria-label": ariaLabel,
50
+ "aria-labelledby": ariaLabelledBy,
51
+ initialFocusRef,
52
+ children,
53
+ }) {
43
54
  const [container, setContainer] = React.useState(null);
44
55
  const dialogRef = React.useRef(null);
45
56
  const onCloseRef = React.useRef(onRequestClose);
46
57
  onCloseRef.current = onRequestClose;
58
+ const initialFocusRefRef = React.useRef(initialFocusRef);
59
+ initialFocusRefRef.current = initialFocusRef;
47
60
  // Taken before the dialog mounts, so a child that focuses itself on mount
48
61
  // cannot become the place focus returns to.
49
62
  const returnFocusRef = React.useRef(null);
@@ -67,7 +80,11 @@ export function WebModal({ visible = true, onRequestClose, children }) {
67
80
  openLayers.push(layer);
68
81
  const isTop = () => openLayers[openLayers.length - 1] === layer;
69
82
  const returnFocusTo = returnFocusRef.current;
70
- if (!dialog.contains(document.activeElement)) {
83
+ const wanted = initialFocusRefRef.current;
84
+ const first = wanted && wanted.current;
85
+ if (first && typeof first.focus === "function") {
86
+ first.focus({ preventScroll: true });
87
+ } else if (!dialog.contains(document.activeElement)) {
71
88
  dialog.focus({ preventScroll: true });
72
89
  }
73
90
 
@@ -127,8 +144,10 @@ export function WebModal({ visible = true, onRequestClose, children }) {
127
144
  "div",
128
145
  {
129
146
  ref: dialogRef,
130
- role: "dialog",
147
+ role,
131
148
  "aria-modal": "true",
149
+ "aria-label": ariaLabel,
150
+ "aria-labelledby": ariaLabelledBy,
132
151
  tabIndex: -1,
133
152
  style: layerStyle,
134
153
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.207.0",
3
+ "version": "0.210.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -723,6 +723,25 @@ const THEME_COMPONENTS = Object.freeze({
723
723
  }),
724
724
  targets: Object.freeze({}),
725
725
  }),
726
+ // Dialogs: `<Modal>` reads this scope directly, so it names the SDK component
727
+ // it styles and binds no widget field. `<Overlay>` reads the scrim alone.
728
+ modal: Object.freeze({
729
+ label: "Dialogs",
730
+ sdkComponents: Object.freeze(["Modal"]),
731
+ tokens: Object.freeze({
732
+ background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
733
+ titleColor: Object.freeze({ type: "color" }),
734
+ scrim: Object.freeze({
735
+ type: "color",
736
+ translucent: true,
737
+ note: "also the backdrop behind every <Overlay>",
738
+ }),
739
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.lg" }),
740
+ padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.lg" }),
741
+ shadow: Object.freeze({ type: "shadow" }),
742
+ }),
743
+ targets: Object.freeze({}),
744
+ }),
726
745
  });
727
746
 
728
747
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -2886,7 +2905,15 @@ const PRIMITIVES = [
2886
2905
  {
2887
2906
  name: "Overlay",
2888
2907
  description:
2889
- 'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget — a preview (PDF, image, video), a lightbox, a confirm dialog, a full detail panel. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `dismissOnBackdropPress` (default true), `accessibilityLabel`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
2908
+ 'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget and is not a dialog — a preview (PDF, image, video), a lightbox, a full-screen viewer. A dialog (a title, a close button, actions) is `<Modal>`, which is built on this. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `presentation` ("dialog", centred, the default | "sheet", anchored to the bottom edge), `dismissOnBackdropPress` (default true), `role` ("dialog" default | "alertdialog"; web only), `initialFocusRef` (web only), `accessibilityLabel` (names the dialog), `testID`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme (the scrim from Design > Components > Dialogs) — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
2909
+ rnComponent: null,
2910
+ docsUrl: null,
2911
+ },
2912
+ // The standard dialog, built on `<Overlay>`, so widgets stop hand-building one.
2913
+ {
2914
+ name: "Modal",
2915
+ description:
2916
+ 'Standard dialog. `<Modal visible={open} onRequestClose={() => setOpen(false)} title="Edit order" footer={<><Button variant="secondary" title="Cancel" onPress={() => setOpen(false)} /><Button title="Save" onPress={save} /></>}>…body…</Modal>`. THE way to open a dialog, a form in a pop-up, a detail panel or a bottom sheet: it draws the title, the close button, a body that scrolls when it is long, and a footer action row laid out below it, and its background, corner radius, padding, scrim, shadow and title colour come from the theme (Design > Components > Dialogs), so every dialog in the app matches. It is built on `<Overlay>`, so it renders OUTSIDE the widget layout box on both hosts and nothing can clip it. Never hand-build a header, a close button or an action row inside a bare `<Overlay>`; keep `<Overlay>` for surfaces that are not a dialog (a media or document preview, a lightbox). Props: `visible` (boolean), `onRequestClose` (fired by the close button, the backdrop press, the Escape key on web and the Android back button — always wire it), `dismissible` (default true; false hides the close button and stops the backdrop, Escape and Android back from closing it, for a dialog that must stay open while it saves — only while that work runs, and always restore it, or nobody can close the dialog), `title` (string), `footer` (the action buttons, laid out as one right-aligned row that wraps on a phone; omit for no footer), `presentation` ("dialog", centred, the default | "sheet", anchored to the bottom edge; keep a form with text inputs in "dialog" — in the exported app the keyboard handling of a sheet is not yet verified on a device), `size` ("sm" | "md" | "lg" | "full", default "md"), `dismissOnBackdropPress` (default true), `role` ("dialog" default | "alertdialog"; web only), `initialFocusRef` (a ref to the control focused on open; web only), `closeAccessibilityLabel` (default "Close" — pass your translated label), `accessibilityLabel` (names a dialog with no title) and `testID`. With no `onRequestClose` it draws no close button. Components rendered inside it read a theme re-inked for the background the dialog paints, so build the body as its own component. Do not wrap the body in your own `ScrollView` and do not re-style the panel.',
2890
2917
  rnComponent: null,
2891
2918
  docsUrl: null,
2892
2919
  },
@@ -5550,7 +5577,15 @@ const CONTRACT = deepFreeze({
5550
5577
  // 1.166.0: additive (sc-10927) — the `Banner` primitive (a themed inline
5551
5578
  // error, warning, success or notice) and the `alert` component scope it
5552
5579
  // reads.
5553
- version: "1.166.0",
5580
+ // 1.169.0: additive (sc-10924) — the `Modal` primitive: the standard dialog
5581
+ // (title, close button, scrolling body, footer action row; `presentation`
5582
+ // "dialog" | "sheet") built on `Overlay`. New `themeComponents.modal`
5583
+ // scope (background, titleColor, scrim, radius, padding, shadow), read by
5584
+ // `Modal`; `Overlay` reads its `scrim` and gains `presentation`, `role`,
5585
+ // `initialFocusRef`, `backdropAccessible` and `testID`. A scope may now
5586
+ // name `sdkComponents` and bind no widget field; a colour token may be
5587
+ // `translucent` (always painted see-through) and carry a `note`.
5588
+ version: "1.169.0",
5554
5589
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5555
5590
  hooks: HOOKS,
5556
5591
  primitives: PRIMITIVES,