@colixsystems/widget-sdk 0.179.0 → 0.180.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.
package/README.md CHANGED
@@ -57,7 +57,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
57
57
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
58
58
  | **FILES** | `useAssetsByTag(tag, { type })` (optional: the whole argument, and `type` within it) | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
59
59
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query)` (optional: query) | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
60
- | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
60
+ | **DIRECTORY** | `useUsers(query)` (optional: query) | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove, sendPasswordReset }` | `directory.users.*` — `users.read:*` plus the `users.read` (or `users.write`) capability, else `FORBIDDEN` (edits, incl. `sendPasswordReset()`, also `users.write:*`; `remove()` also `users.delete:*`) |
61
61
  | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
62
62
  | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
63
63
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
@@ -74,7 +74,17 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
74
74
 
75
75
  ## Status
76
76
 
77
- `v0.179.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
77
+ `v0.180.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
78
+
79
+ ### What's new in 0.180.0 (contract 1.141.0)
80
+
81
+ **`<Button>` stays readable on a coloured section.** A section that paints its own fill (a card or band on the theme's colours, or a gradient wash) already gave its text a colour that reads there. Its buttons kept the page's paint, so an `outlined` button drew its label and border in the primary colour on a band washed in that same colour, and a `secondary` fill disappeared into it. A button there is now measured against the section by the same rule as its text: a colour that already reads is kept, and one the section makes unreadable takes the section's text colour, but only when that reads better. "The section makes it unreadable" is measured against the surface the look was designed for: a colour moves only when the section costs it at least a third of the contrast it had there, so a white or lightly tinted panel changes nothing.
82
+
83
+ - `outlined`: a label under 4.5:1 and a border under 3:1 against the section are re-inked.
84
+ - `secondary`: a fill under 3:1 against the section becomes the section's outlined button.
85
+ - `primary` and `danger`: a fill under 3:1 turns inverse, with the section's text colour as its fill and its own colour as its label, so it still leads the quieter buttons. Where that label would not reach 4.5:1, or you set the label colour, the fill is kept and gains an edge in the section's text colour instead.
86
+
87
+ A colour you pass in `fields`, `style` (`backgroundColor`, `borderColor`) or `labelStyle` (`color`), or that the app's button look sets, is never changed. A `<Card>` inside such a section becomes the surface its content reads: it re-derives `surface`, `onSurface`, `border` and the rest from its own fill, as a layout container does, and its buttons are measured against it. Inside a section `useTheme().colors.backdrop` is the fill your widget sits on and `colors.backdropBase` the surface the look was designed for; on the page itself both are absent.
78
88
 
79
89
  ### What's new in 0.179.0 (contract 1.140.0)
80
90
 
@@ -2,6 +2,8 @@
2
2
  // the built-in widgets (frontend `_shared/buttonStyle.js` re-exports these).
3
3
  // Platform-free, so both hosts resolve a button identically.
4
4
 
5
+ import { INK_CONTRAST, contrastRatio, inkForBackdrop, isHexColor } from "./contract.js";
6
+
5
7
  export const BUTTON_PADDING_MAX = 64;
6
8
  export const BUTTON_BORDER_WIDTH_MAX = 8;
7
9
 
@@ -58,3 +60,95 @@ export function selfAlignStyle(style) {
58
60
  const alignSelf = SELF_ALIGNMENTS[style && style.align];
59
61
  return alignSelf ? { alignSelf } : null;
60
62
  }
63
+
64
+ // How much of its page contrast a section must cost a colour before it moves:
65
+ // a tint or neutral panel never does, a coloured band always does.
66
+ const SECTION_LOSS = 1.5;
67
+
68
+ /**
69
+ * A variant's own paint, measured against the painted section it sits on
70
+ * (`colors.backdrop`) by the rule that section's text follows. A colour moves
71
+ * only where it is unreadable there AND the section is what broke it, measured
72
+ * against the surface the look was designed for (`colors.backdropBase`). Only
73
+ * what the look painted moves; `owned` names what the maker set.
74
+ *
75
+ * A transparent button re-inks its label and border separately. A fill the
76
+ * section swallows is replaced: a quiet `secondary` becomes the section's
77
+ * outlined button, and an emphatic fill turns inverse (the section's ink as
78
+ * fill, its own colour as label), so it still outranks the quiet ones. Where
79
+ * that label would not read, or the maker chose it, the fill gains an edge.
80
+ *
81
+ * @param {string} variant
82
+ * @param {{container: object, ink: string}} paint
83
+ * @param {object} colors - the surface-aware theme colours.
84
+ * @param {{fill?: boolean, label?: boolean, border?: boolean}} [owned]
85
+ * @returns {{container: object, ink: string}} `paint` itself when nothing moves.
86
+ */
87
+ export function paintOnBackdrop(variant, paint, colors, owned = {}) {
88
+ const { backdrop, backdropBase, onSurface: ink } = colors || {};
89
+ if (owned.fill || ![backdrop, backdropBase, ink].every(isHexColor)) return paint;
90
+ const reink = (colour, minimum) =>
91
+ isHexColor(colour) && brokenBySection(colour, backdrop, backdropBase, minimum)
92
+ ? inkForBackdrop(colour, ink, backdrop, minimum)
93
+ : colour;
94
+ const { container } = paint;
95
+ const fill = container.backgroundColor;
96
+ if (fill === "transparent") return reinkOutline(paint, reink, owned);
97
+ if (reink(fill, INK_CONTRAST.boundary) === fill) return paint;
98
+ if (!owned.label && variant === "secondary") {
99
+ return {
100
+ container: { backgroundColor: "transparent", borderWidth: 1, borderColor: ink },
101
+ ink,
102
+ };
103
+ }
104
+ if (!owned.label && contrastRatio(fill, ink) >= INK_CONTRAST.text) {
105
+ const edge = container.borderColor ? { borderColor: ink } : null;
106
+ return { container: { ...container, backgroundColor: ink, ...edge }, ink: fill };
107
+ }
108
+ if (owned.border) return paint;
109
+ return { container: { ...container, borderWidth: 1, borderColor: ink }, ink: paint.ink };
110
+ }
111
+
112
+ function brokenBySection(colour, backdrop, base, minimum) {
113
+ const here = contrastRatio(colour, backdrop);
114
+ return here < minimum && here * SECTION_LOSS <= contrastRatio(colour, base);
115
+ }
116
+
117
+ function reinkOutline(paint, reink, owned) {
118
+ const { borderColor } = paint.container;
119
+ const label = owned.label ? paint.ink : reink(paint.ink, INK_CONTRAST.text);
120
+ const border = owned.border ? borderColor : reink(borderColor, INK_CONTRAST.boundary);
121
+ if (label === paint.ink && border === borderColor) return paint;
122
+ return { container: { ...paint.container, borderColor: border }, ink: label };
123
+ }
124
+
125
+ const SIDE_BORDER_COLORS = [
126
+ "borderColor",
127
+ "borderTopColor",
128
+ "borderRightColor",
129
+ "borderBottomColor",
130
+ "borderLeftColor",
131
+ ];
132
+
133
+ /**
134
+ * Which of a button's fill, label and border its caller set, so
135
+ * `paintOnBackdrop` never replaces them: the surface `fields`, and colours in
136
+ * the flattened container `style` and `labelStyle`.
137
+ */
138
+ export function ownedButtonPaint(variant, fields, style, labelStyle) {
139
+ const f = fields || {};
140
+ const s = style || {};
141
+ const danger = variant === "danger";
142
+ return {
143
+ fill: Boolean(
144
+ f.background || f.gradient || s.backgroundColor || (danger && f.dangerBackground),
145
+ ),
146
+ label: Boolean(f.textColor || (labelStyle && labelStyle.color) || (danger && f.dangerTextColor)),
147
+ border: Boolean(
148
+ f.borderColor ||
149
+ f.ringColor ||
150
+ SIDE_BORDER_COLORS.some((key) => s[key]) ||
151
+ (danger && f.dangerBorderColor),
152
+ ),
153
+ };
154
+ }
@@ -8,7 +8,12 @@ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
8
  import { lucideGlyph } from "./icon.js";
9
9
  import { applyThemeComponentStyle } from "./theme-components.js";
10
10
  import { cornerRadiusOverlay, shadowStyle, gradientProps } from "./card-style.js";
11
- import { buttonSurfaceOverlay, buttonSurfaceTextOverlay } from "./button-style.js";
11
+ import {
12
+ buttonSurfaceOverlay,
13
+ buttonSurfaceTextOverlay,
14
+ ownedButtonPaint,
15
+ paintOnBackdrop,
16
+ } from "./button-style.js";
12
17
 
13
18
  export const BUTTON_VARIANTS = Object.freeze(["primary", "secondary", "outlined", "danger"]);
14
19
 
@@ -39,8 +44,8 @@ function variantPaint(variant, colors) {
39
44
  }
40
45
 
41
46
  // Precedence, weakest first: variant, danger scope, ring, then the fields.
42
- function containerLayers(variant, f, colors) {
43
- const paint = variantPaint(variant, colors);
47
+ function containerLayers(variant, f, colors, owned) {
48
+ const paint = paintOnBackdrop(variant, variantPaint(variant, colors), colors, owned);
44
49
  let ink = paint.ink;
45
50
  const layers = [paint.container];
46
51
  if (variant === "danger") {
@@ -115,7 +120,13 @@ export function makeButton(rn) {
115
120
  ? fields || {}
116
121
  : applyThemeComponentStyle(THEMED_AS, theme, { style: fields }).style || {};
117
122
  const resolvedVariant = BUTTON_VARIANTS.includes(variant) ? variant : "primary";
118
- const { layers, ink } = containerLayers(resolvedVariant, f, colors);
123
+ const owned = ownedButtonPaint(
124
+ resolvedVariant,
125
+ f,
126
+ StyleSheet.flatten(style),
127
+ StyleSheet.flatten(labelStyle),
128
+ );
129
+ const { layers, ink } = containerLayers(resolvedVariant, f, colors, owned);
119
130
 
120
131
  const fontSize =
121
132
  typeof f.fontSize === "number" && Number.isFinite(f.fontSize)
@@ -5,7 +5,7 @@
5
5
  // Every resolver emits ONLY the fields that are set, so a widget's own base
6
6
  // style keeps whatever the author left alone.
7
7
 
8
- import { CONTRACT, normaliseComponentGradient } from "./contract.js";
8
+ import { CONTRACT, isHexColor, mixHex, normaliseComponentGradient } from "./contract.js";
9
9
  import { normaliseCornerRadius, cornerRadiusStyle } from "./corner-radius.js";
10
10
  import { normaliseSpacing, spacingStyle, isZeroSpacing } from "./spacing.js";
11
11
 
@@ -118,6 +118,35 @@ export function gradientProps(value) {
118
118
  : null;
119
119
  }
120
120
 
121
+ /**
122
+ * The colour a card's buttons are measured against: its opaque flat fill, or
123
+ * a gradient's midpoint, as a layout container's ink basis is read. `null`
124
+ * (a translucent or unknown fill) keeps the backdrop the card inherited.
125
+ *
126
+ * @param {object|Array} styles - the card's container style, as composed.
127
+ * @param {{colors: string[]}|null} gradient - its `gradientProps`.
128
+ */
129
+ export function cardBackdrop(styles, gradient) {
130
+ if (gradient) {
131
+ const [from, to] = gradient.colors;
132
+ return isOpaqueHex(from) && isOpaqueHex(to) ? mixHex(from, to, 0.5) : null;
133
+ }
134
+ let fill = null;
135
+ for (const style of [styles].flat(Infinity)) {
136
+ if (style && typeof style === "object" && style.backgroundColor !== undefined) {
137
+ fill = style.backgroundColor;
138
+ }
139
+ }
140
+ return isOpaqueHex(fill) ? fill : null;
141
+ }
142
+
143
+ // The colour maths ignores alpha, so a see-through fill cannot be judged.
144
+ function isOpaqueHex(value) {
145
+ if (!isHexColor(value)) return false;
146
+ const hex = value.slice(1);
147
+ return hex.length !== 8 || hex.slice(6).toLowerCase() === "ff";
148
+ }
149
+
121
150
  /** The card-surface gradient (`cardGradient`). */
122
151
  export function cardGradientProps(style) {
123
152
  return gradientProps(style && style.cardGradient);
package/dist/card-view.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // swap lives here once rather than in each widget.
7
7
 
8
8
  import React from "react";
9
- import { useHostTheme } from "./hooks.js";
9
+ import { useHostTheme, BackdropScope } from "./hooks.js";
10
10
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
11
11
  import { CONTRACT } from "./contract.js";
12
12
  import {
@@ -14,6 +14,7 @@ import {
14
14
  cardSurfaceStyle,
15
15
  cardGradientProps,
16
16
  cardLayoutStyle,
17
+ cardBackdrop,
17
18
  } from "./card-style.js";
18
19
  import { normaliseThemeComponents } from "./theme-components.js";
19
20
 
@@ -134,12 +135,17 @@ export function makeCard(rn) {
134
135
 
135
136
  const overlay = withoutMargin ? cardSurfaceStyle(fields) : cardOverlay(fields);
136
137
  const gradient = cardGradientProps(fields);
138
+ const basis = cardBackdrop(
139
+ [base, overlay, typeof containerStyle === "function" ? null : containerStyle],
140
+ gradient,
141
+ );
142
+ const scoped = React.createElement(BackdropScope, { basis }, children);
137
143
 
138
144
  if (!gradient) {
139
145
  return React.createElement(
140
146
  Container,
141
147
  { style: composeStyle([base, overlay, layout], containerStyle), ...rest },
142
- children,
148
+ scoped,
143
149
  );
144
150
  }
145
151
 
@@ -156,7 +162,7 @@ export function makeCard(rn) {
156
162
  style: composeStyle([base, paint, layout], containerStyle),
157
163
  ...rest,
158
164
  },
159
- children,
165
+ scoped,
160
166
  );
161
167
  }
162
168
 
@@ -179,7 +185,7 @@ export function makeCard(rn) {
179
185
  ),
180
186
  ...rest,
181
187
  },
182
- children,
188
+ scoped,
183
189
  ),
184
190
  );
185
191
  }
package/dist/contract.cjs CHANGED
@@ -2480,7 +2480,7 @@ const PRIMITIVES = [
2480
2480
  {
2481
2481
  name: "Button",
2482
2482
  description:
2483
- 'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. Never hand-roll a button from `Pressable` + background + radius.',
2483
+ 'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. On a section that paints its own fill (`theme.colors.backdrop` there), a variant colour that the section makes unreadable takes that section\'s text colour, as its text does: an outlined label and border, a secondary whose fill disappears becomes outlined, and a primary or danger fill turns inverse (that text colour as its fill, its own colour as its label). A white or lightly tinted panel changes nothing, and a button inside a `<Card>` is measured against that card. A colour you pass in `fields`, `style` or `labelStyle`, or that the app\'s button look sets, is never changed. Never hand-roll a button from `Pressable` + background + radius.',
2484
2484
  rnComponent: null,
2485
2485
  docsUrl: null,
2486
2486
  },
@@ -4764,7 +4764,10 @@ const CONTRACT = deepFreeze({
4764
4764
  // (boolean, default false): the widget draws live figures about its
4765
4765
  // records, so the app-builder counts it as the page's figures when it
4766
4766
  // reuses it. Existing manifests omit it and read false.
4767
- version: "1.140.0",
4767
+ // 1.141.0: additive (sc-9346) — inside a section that paints its own fill,
4768
+ // `theme.colors.backdrop` is that fill, and `<Button>` re-inks a variant
4769
+ // colour that cannot be read against it by the rule its text follows.
4770
+ version: "1.141.0",
4768
4771
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4769
4772
  hooks: HOOKS,
4770
4773
  primitives: PRIMITIVES,
@@ -5037,6 +5040,25 @@ function deriveSurfaceTokens(backgroundColor) {
5037
5040
  };
5038
5041
  }
5039
5042
 
5043
+ // sc-8370: WCAG 1.4.3 for text, 1.4.11 for a control's boundary.
5044
+ const INK_CONTRAST = Object.freeze({ text: 4.5, boundary: 3 });
5045
+
5046
+ /**
5047
+ * The measured re-ink rule every painted section applies — to its text tiers
5048
+ * and to its buttons alike. `current` is kept while it reaches `minimum`
5049
+ * against `backdrop`; below that, `candidate` replaces it only when it is
5050
+ * genuinely more readable there. A backdrop with no good answer keeps what it
5051
+ * had rather than committing to a colour that is wrong for half of it.
5052
+ */
5053
+ function inkForBackdrop(current, candidate, backdrop, minimum) {
5054
+ if (!isHexColor(current) || !isHexColor(candidate) || !isHexColor(backdrop)) {
5055
+ return current;
5056
+ }
5057
+ const held = contrastRatio(current, backdrop);
5058
+ if (held >= minimum) return current;
5059
+ return contrastRatio(candidate, backdrop) > held ? candidate : current;
5060
+ }
5061
+
5040
5062
  /**
5041
5063
  * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
5042
5064
  * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
@@ -5119,6 +5141,8 @@ module.exports = {
5119
5141
  readableTextColor,
5120
5142
  deriveAccentTints,
5121
5143
  deriveSurfaceTokens,
5144
+ inkForBackdrop,
5145
+ INK_CONTRAST,
5122
5146
  gradientAngleToVector,
5123
5147
  normaliseComponentGradient,
5124
5148
  isThemeColorTokenRef,
package/dist/contract.js CHANGED
@@ -2480,7 +2480,7 @@ const PRIMITIVES = [
2480
2480
  {
2481
2481
  name: "Button",
2482
2482
  description:
2483
- 'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. Never hand-roll a button from `Pressable` + background + radius.',
2483
+ 'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Theme Settings > Buttons, and Destructive / Focus ring for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. On a section that paints its own fill (`theme.colors.backdrop` there), a variant colour that the section makes unreadable takes that section\'s text colour, as its text does: an outlined label and border, a secondary whose fill disappears becomes outlined, and a primary or danger fill turns inverse (that text colour as its fill, its own colour as its label). A white or lightly tinted panel changes nothing, and a button inside a `<Card>` is measured against that card. A colour you pass in `fields`, `style` or `labelStyle`, or that the app\'s button look sets, is never changed. Never hand-roll a button from `Pressable` + background + radius.',
2484
2484
  rnComponent: null,
2485
2485
  docsUrl: null,
2486
2486
  },
@@ -4760,7 +4760,10 @@ const CONTRACT = deepFreeze({
4760
4760
  // (boolean, default false): the widget draws live figures about its
4761
4761
  // records, so the app-builder counts it as the page's figures when it
4762
4762
  // reuses it. Existing manifests omit it and read false.
4763
- version: "1.140.0",
4763
+ // 1.141.0: additive (sc-9346) — inside a section that paints its own fill,
4764
+ // `theme.colors.backdrop` is that fill, and `<Button>` re-inks a variant
4765
+ // colour that cannot be read against it by the rule its text follows.
4766
+ version: "1.141.0",
4764
4767
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4765
4768
  hooks: HOOKS,
4766
4769
  primitives: PRIMITIVES,
@@ -5033,6 +5036,25 @@ function deriveSurfaceTokens(backgroundColor) {
5033
5036
  };
5034
5037
  }
5035
5038
 
5039
+ // sc-8370: WCAG 1.4.3 for text, 1.4.11 for a control's boundary.
5040
+ const INK_CONTRAST = Object.freeze({ text: 4.5, boundary: 3 });
5041
+
5042
+ /**
5043
+ * The measured re-ink rule every painted section applies — to its text tiers
5044
+ * and to its buttons alike. `current` is kept while it reaches `minimum`
5045
+ * against `backdrop`; below that, `candidate` replaces it only when it is
5046
+ * genuinely more readable there. A backdrop with no good answer keeps what it
5047
+ * had rather than committing to a colour that is wrong for half of it.
5048
+ */
5049
+ function inkForBackdrop(current, candidate, backdrop, minimum) {
5050
+ if (!isHexColor(current) || !isHexColor(candidate) || !isHexColor(backdrop)) {
5051
+ return current;
5052
+ }
5053
+ const held = contrastRatio(current, backdrop);
5054
+ if (held >= minimum) return current;
5055
+ return contrastRatio(candidate, backdrop) > held ? candidate : current;
5056
+ }
5057
+
5036
5058
  /**
5037
5059
  * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
5038
5060
  * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
@@ -5115,6 +5137,8 @@ export {
5115
5137
  readableTextColor,
5116
5138
  deriveAccentTints,
5117
5139
  deriveSurfaceTokens,
5140
+ inkForBackdrop,
5141
+ INK_CONTRAST,
5118
5142
  gradientAngleToVector,
5119
5143
  normaliseComponentGradient,
5120
5144
  isThemeColorTokenRef,
package/dist/hooks.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  // sc-5717 — "is this key still at its declared default", shared with the
47
47
  // host codec so the hook and the encoder can never disagree about it.
48
48
  sameWidgetRouteValue,
49
+ deriveSurfaceTokens,
49
50
  } from "./contract.js";
50
51
 
51
52
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
@@ -67,6 +68,27 @@ export function WidgetContextProvider({ value, children }) {
67
68
  return React.createElement(HostWidgetContext.Provider, { value }, children);
68
69
  }
69
70
 
71
+ /**
72
+ * @internal — a surface painting its own fill inside a coloured section
73
+ * becomes the surface its content reads and its buttons are measured against
74
+ * (`<Card>` uses it), by the derivation a layout container applies.
75
+ * Outside such a section there is no backdrop and nothing changes.
76
+ */
77
+ export function BackdropScope({ basis, children }) {
78
+ const ctx = useContext(HostWidgetContext);
79
+ const theme = ctx && ctx.workspace ? ctx.workspace.theme : null;
80
+ const colors = theme && theme.colors;
81
+ const scoped = Boolean(basis && colors && colors.backdrop && colors.backdrop !== basis);
82
+ const value = useMemo(
83
+ () =>
84
+ scoped
85
+ ? { ...ctx, workspace: { ...ctx.workspace, theme: { ...theme, colors: { ...colors, ...deriveSurfaceTokens(basis), backdrop: basis } } } }
86
+ : ctx,
87
+ [scoped, ctx, theme, colors, basis],
88
+ );
89
+ return React.createElement(HostWidgetContext.Provider, { value }, children);
90
+ }
91
+
70
92
  // Exported for ./toast.js + future hook modules outside hooks.js. Internal
71
93
  // utility — widget code should not import this directly.
72
94
  export function useWidgetContextOrThrow(hookName) {
package/dist/host.d.ts CHANGED
@@ -39,6 +39,21 @@ export type ThemeWidgetStyles = Record<string, ThemeComponentStyle>;
39
39
  */
40
40
  export function normaliseThemeComponents(raw: unknown): ThemeComponents;
41
41
 
42
+ /** Minimum contrast: `text` for labels (WCAG 1.4.3), `boundary` for edges (1.4.11). */
43
+ export const INK_CONTRAST: Readonly<{ text: number; boundary: number }>;
44
+
45
+ /**
46
+ * The measured re-ink rule a painted section applies to its text and buttons:
47
+ * `current` while it reaches `minimum` against `backdrop`, else `candidate`
48
+ * when that reads better there, else `current`.
49
+ */
50
+ export function inkForBackdrop(
51
+ current: string,
52
+ candidate: string,
53
+ backdrop: string,
54
+ minimum: number,
55
+ ): string;
56
+
42
57
  /**
43
58
  * REQ-THEME-ELEMENT host helper: validates a raw `themeConfig.widgetStyles` blob.
44
59
  * Structural only — the key space is the workspace's widget catalog, not the
package/dist/host.js CHANGED
@@ -41,7 +41,7 @@ export {
41
41
  // surface — the page background and every container that carries its own fill.
42
42
  // Host plumbing, deliberately off the author entry point: a widget reads the
43
43
  // already-correct `useTheme().colors`, it never derives them itself.
44
- export { deriveSurfaceTokens } from "./contract.js";
44
+ export { deriveSurfaceTokens, inkForBackdrop, INK_CONTRAST } from "./contract.js";
45
45
 
46
46
  // REQ-WSDK-PLATFORM §6: the host half of `useToast()` — the queue, the
47
47
  // auto-dismiss timing, and the themed values a toast is painted with. The
package/dist/index.d.ts CHANGED
@@ -393,6 +393,11 @@ export interface ThemeTokens {
393
393
  onSurface: string;
394
394
  danger: string;
395
395
  [k: string]: string;
396
+ } & {
397
+ /** Inside a section painting its own fill: that fill. Absent on the page. */
398
+ backdrop?: string;
399
+ /** The surface the look was designed for, which `backdrop` is judged against. */
400
+ backdropBase?: string;
396
401
  };
397
402
  /** sc-3696: the shared elevation vocabulary. Spread a level into a style. */
398
403
  elevation: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.179.0",
3
+ "version": "0.180.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",