@colixsystems/widget-sdk 0.206.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.
@@ -0,0 +1,28 @@
1
+ // The label colour for a fill an author chose. Picked by contrast between a
2
+ // FIXED dark and a fixed light: the theme's own text colour is light on a
3
+ // dark theme, so it cannot stand in for "the dark option".
4
+
5
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
6
+ import { contrastRatio, hexAlphaPercent, isHexColor, mixHex } from "./contract.js";
7
+
8
+ export const INK_DARK = DEFAULT_THEME_TOKENS.colors.onSurface;
9
+ export const INK_LIGHT = "#ffffff";
10
+
11
+ /** A fill as the opaque colour it shows once painted over `surface`. */
12
+ export function compositeOver(fill, surface) {
13
+ if (!isHexColor(fill)) return fill;
14
+ const backdrop = isHexColor(surface) ? surface : DEFAULT_THEME_TOKENS.colors.surface;
15
+ return mixHex(backdrop, fill, hexAlphaPercent(fill) / 100);
16
+ }
17
+
18
+ /**
19
+ * Whichever of the dark and light ink has the higher contrast on `fill`. A
20
+ * translucent fill is measured as it appears over `surface`.
21
+ */
22
+ export function readableInk(fill, surface) {
23
+ if (!isHexColor(fill)) return INK_DARK;
24
+ const painted = compositeOver(fill, surface);
25
+ return contrastRatio(painted, INK_DARK) >= contrastRatio(painted, INK_LIGHT)
26
+ ? INK_DARK
27
+ : INK_LIGHT;
28
+ }
@@ -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
+ }
@@ -3,7 +3,8 @@
3
3
  // the same numbers and tests can pin them without a renderer.
4
4
 
5
5
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
6
- import { CONTRACT, contrastRatio } from "./contract.js";
6
+ import { CONTRACT } from "./contract.js";
7
+ import { readableInk } from "./readable-ink.js";
7
8
 
8
9
  export const TAB_STYLES = CONTRACT.themeComponentTabStyles;
9
10
 
@@ -56,7 +57,7 @@ export function resolveTabsTokens(theme, overrides) {
56
57
  pick(overrides, scope, "activeTextColor") ??
57
58
  (style === "pill"
58
59
  ? ownAccent
59
- ? readableOn(ownAccent, colors)
60
+ ? readableInk(ownAccent, colors.surface)
60
61
  : colors.onPrimary
61
62
  : activeColor),
62
63
  textColor: pick(overrides, scope, "textColor") ?? colors.onSurfaceMuted,
@@ -66,10 +67,3 @@ export function resolveTabsTokens(theme, overrides) {
66
67
  rule: colors.border,
67
68
  };
68
69
  }
69
-
70
- // A chosen accent carries the label that reads best on it, dark or light.
71
- function readableOn(fill, colors) {
72
- const dark = colors.onSurface;
73
- const light = "#ffffff";
74
- return contrastRatio(fill, dark) >= contrastRatio(fill, light) ? dark : light;
75
- }
@@ -18,7 +18,7 @@ export const TOAST_DEFAULTS = Object.freeze({
18
18
  const KINDS = Object.freeze(["success", "error", "warning", "info"]);
19
19
 
20
20
  // `error` is the widget-facing kind; `danger` is the theme's colour role.
21
- const KIND_COLOR_ROLE = Object.freeze({
21
+ export const KIND_COLOR_ROLE = Object.freeze({
22
22
  success: "success",
23
23
  error: "danger",
24
24
  warning: "warning",
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.206.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
@@ -704,6 +704,44 @@ const THEME_COMPONENTS = Object.freeze({
704
704
  }),
705
705
  targets: Object.freeze({}),
706
706
  }),
707
+ // Inline messages: `<Banner>` reads this scope directly, so it names the
708
+ // SDK component it styles and binds no widget field.
709
+ alert: Object.freeze({
710
+ label: "Alerts",
711
+ sdkComponents: Object.freeze(["Banner"]),
712
+ tokens: Object.freeze({
713
+ radius: Object.freeze({ type: "size", min: 0, max: 32, uiDefault: "radii.md" }),
714
+ borderWidth: Object.freeze({ type: "size", min: 0, max: 4 }),
715
+ infoBackground: Object.freeze({ type: "color" }),
716
+ infoTextColor: Object.freeze({ type: "color" }),
717
+ successBackground: Object.freeze({ type: "color" }),
718
+ successTextColor: Object.freeze({ type: "color" }),
719
+ warningBackground: Object.freeze({ type: "color" }),
720
+ warningTextColor: Object.freeze({ type: "color" }),
721
+ errorBackground: Object.freeze({ type: "color" }),
722
+ errorTextColor: Object.freeze({ type: "color" }),
723
+ }),
724
+ targets: Object.freeze({}),
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
+ }),
707
745
  });
708
746
 
709
747
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -2867,7 +2905,15 @@ const PRIMITIVES = [
2867
2905
  {
2868
2906
  name: "Overlay",
2869
2907
  description:
2870
- '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.',
2871
2917
  rnComponent: null,
2872
2918
  docsUrl: null,
2873
2919
  },
@@ -2962,6 +3008,13 @@ const PRIMITIVES = [
2962
3008
  rnComponent: null,
2963
3009
  docsUrl: null,
2964
3010
  },
3011
+ {
3012
+ name: "Banner",
3013
+ description:
3014
+ 'Themed inline message. `<Banner tone="error" title="Could not save" message={err.message} action={{ label: "Retry", onPress: save }} onDismiss={() => setErr(null)} />`. THE way to show an error, warning, success or notice that belongs to a place in the widget and stays until it is resolved: a list that failed to load, a form the user cannot submit yet, a "read-only" or "offline" notice. Render it where the problem is — above the list or form it concerns. Use `useToast()` instead for a brief confirmation of something the user just did ("Saved", "Deleted") that needs no decision and may disappear on its own; never both for one event. Its fill, text colour, border and corner radius come from the app\'s theme per tone (Design > Components > Alerts), so never hand-roll a tinted box from `View` + a border + a hex. Props: `tone` ("info" default | "success" | "warning" | "error"), `title` (optional, one short line) and `message` (wraps — never truncate it): text, or an element you built when part of it needs its own styling, `icon` (a lucide name replacing the tone\'s own glyph, or `false` for none), `action` (`{ label, onPress, accessibilityLabel?, testID? }`, ONE action such as Retry), `onDismiss` (pass it to show a close control; you hide the banner in the handler), `dismissAccessibilityLabel` (default "Dismiss" — pass a translated one), `style` (spacing around it), `testID`. An `error` is marked as an alert, and the other tones as a polite status: the web and Android announce them when they appear, while iOS reads the banner in place.',
3015
+ rnComponent: null,
3016
+ docsUrl: null,
3017
+ },
2965
3018
  {
2966
3019
  name: "Accordion",
2967
3020
  description:
@@ -5521,7 +5574,18 @@ const CONTRACT = deepFreeze({
5521
5574
  // and `useOnlineVisibility()` read who of a record's members is online
5522
5575
  // and the viewer's own switch, through the new `ctx.directory.presence`.
5523
5576
  // A `datastoreTemplate` table may declare `presence: true`.
5524
- version: "1.165.0",
5577
+ // 1.166.0: additive (sc-10927) — the `Banner` primitive (a themed inline
5578
+ // error, warning, success or notice) and the `alert` component scope it
5579
+ // reads.
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",
5525
5589
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5526
5590
  hooks: HOOKS,
5527
5591
  primitives: PRIMITIVES,