@colixsystems/widget-sdk 0.164.0 → 0.166.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
@@ -73,7 +73,24 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
73
73
 
74
74
  ## Status
75
75
 
76
- `v0.164.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**.
76
+ `v0.165.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
+
78
+ ### What's new in 0.166.0 (contract 1.128.0)
79
+
80
+ **A bound colour can be translucent (sc-8893).** Binding and transparency were mutually exclusive: the Studio hid the opacity control the moment a colour field bound to a brand token, so an author who wanted "our brand colour at 20%" had to abandon the binding and freeze today's hex — which then stopped following a rebrand, the very thing binding exists to prevent.
81
+
82
+ A binding may now carry an opacity: `"theme:colors.primary/20"` is the primary brand colour at 20%.
83
+
84
+ - **`parseThemeColorTokenRef(value)` / `formatThemeColorTokenRef(path, percent)`** publish the grammar beside the existing `isThemeColorTokenRef` predicate. The BARE form inherits whatever opacity the token itself carries — a workspace may author a translucent brand colour — so `/100` is a real value, not a redundant spelling: it is how "opaque, whatever the brand does" is said. One opacity has one spelling otherwise (no leading zeros, nothing above 100).
85
+ - **`withHexAlpha(hex, percent)` / `hexAlphaPercent(hex)`** are the percent ↔ alpha-byte conversion the Studio's opacity control and both hosts' resolver share, so the 20% a panel reads back is the 20% a render paints.
86
+ - **An authored opacity replaces the token's.** With none authored the token is handed back verbatim, including a token that is itself translucent — reading the bare form as 100 would quietly paint a see-through brand colour solid.
87
+ - **A token resolving to a non-hex colour stays opaque** — alpha cannot be applied without rewriting the author's notation — and the Studio disables the opacity control for that case rather than lying about it.
88
+ - **Nothing changes for a widget author.** The host still hands your code an ordinary colour string; it is simply `#RRGGBBAA` when an opacity was authored.
89
+ - **Fully additive.** Every stored binding is unsuffixed, parses as before, and renders byte-identically.
90
+
91
+ ### What's new in 0.165.0 (contract 1.127.0 — unchanged)
92
+
93
+ **`no-hardcoded-design` flags a literal corner radius.** A radius is the app's shape: `theme.radii`, Theme Settings and the SDK's `Card` / `Button` / `ListRow` / `Tabs` all carry it, so a `borderRadius: 12` pinned in a widget is exactly where two widgets in one app stop matching. The rule now warns on a literal non-zero radius on any corner spelling; read it from `theme.radii.*` or a style field, or draw the surface with the SDK component. A computed radius (`size / 2` for a circle) and `0` are not flagged, and `// appstudio-design-ok: <reason>` licenses a genuine exception. Still a warning for a human author; like every linter finding it blocks the AI widget agent's publish, which is how generated widgets are held to it.
77
94
 
78
95
  ### What's new in 0.164.0 (contract 1.127.0)
79
96
 
@@ -827,7 +844,7 @@ Additive — one new hook, one new optional context-slice member; no existing ex
827
844
 
828
845
  **Two linter rules make the styling contract checkable, and `lintStyleWiring` joins the linter export (sc-6455).** Every visual value a widget writes reaches the app's owner through one of exactly two channels — a **theme token** (`useTheme()`), which is the app-wide default and follows a look change, or a **`styleSchema` field** read off `props.style`, which the Studio offers per instance in the widget editor *and* app-wide under **Design → Widget appearance**. A literal reaches neither: it outranks the theme permanently and no control on either surface can move it, so the owner finds a corner of their app they cannot restyle. Until now that rule was documentation only.
829
846
 
830
- - **`no-hardcoded-design`** flags a colour literal (`#rgb` / `#rrggbb` / `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()`), a `fontFamily` string literal, or a numeric `fontSize`. A `fontSize` resolved off a theme token or a style field (`style.valueSize ?? 18`) is *not* flagged — a literal `default` beside a declared field is the contract working. Raw `padding` / `margin` / `borderRadius` numbers are deliberately out of scope: measured layout legitimately carries them, so a spacing rule would be noise.
847
+ - **`no-hardcoded-design`** flags a colour literal (`#rgb` / `#rrggbb` / `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()`), a `fontFamily` string literal, a numeric `fontSize`, or (since 0.165.0) a literal non-zero corner radius (`borderRadius: 12`, any per-corner spelling). A `fontSize` resolved off a theme token or a style field (`style.valueSize ?? 18`) is *not* flagged — a literal `default` beside a declared field is the contract working — and neither is a computed radius (`size / 2`) or `0`. Raw `padding` / `margin` numbers are deliberately out of scope: measured layout legitimately carries them, so a spacing rule would be noise.
831
848
  - **`style-field-unread`** flags a `styleSchema` field whose name appears nowhere in the widget's source — a dead control the author moves to no effect. It runs over the WHOLE bundle, not per file, so a split-impl widget that reads a field in `widget.web.jsx` and not in `widget.native.jsx` is correctly counted as wired.
832
849
 
833
850
  A value that genuinely cannot be a token — a categorical series palette, a video letterbox, a scannable QR plate — is licensed with a preceding comment. **The reason is mandatory**; a bare marker licenses nothing. A marker on its own line covers the whole statement below it (bracket-balanced, so one marker covers a multi-line palette); a trailing marker covers only its own line.
package/dist/contract.cjs CHANGED
@@ -192,20 +192,64 @@ const THEME_COLOR_TOKENS = Object.freeze([
192
192
  Object.freeze({ path: "colors.secondary", label: "Secondary" }),
193
193
  ]);
194
194
 
195
- // sc-8326 — the `theme:<path>` value grammar, as a PREDICATE over a STORED
196
- // value and declared beside the allowlist it reads. Three readers have to agree
197
- // on what a binding IS: the Studio's colour picker writes one, both hosts
198
- // resolve one, and Mason's plan validator must accept one as a gradient stop.
195
+ // sc-8326 — the `theme:<path>` value grammar, as a PARSE over a STORED value
196
+ // and declared beside the allowlist it reads. Three readers have to agree on
197
+ // what a binding IS: the Studio's colour picker writes one, both hosts resolve
198
+ // one, and Mason's plan validator must accept one as a gradient stop.
199
199
  // One definition, so they cannot drift (CLAUDE.md §3).
200
200
  const THEME_TOKEN_REF_PREFIX = "theme:";
201
201
 
202
+ // sc-8893 — a binding may carry an OPACITY, `theme:<path>/<percent>`. Without
203
+ // it an author had to choose between a colour that follows the workspace brand
204
+ // and one they could make translucent; "our brand colour at 20%" was only
205
+ // expressible by freezing today's hex, which then stopped following a rebrand.
206
+ // `/` cannot occur in a token path, so an unsuffixed binding parses exactly as
207
+ // it always did and no stored value changes meaning.
208
+ const THEME_TOKEN_ALPHA_SEPARATOR = "/";
209
+ const ALPHA_OPAQUE = 100;
210
+
211
+ /**
212
+ * Parse a stored binding into `{ path, alpha }`, or `null` when the value is
213
+ * not one.
214
+ *
215
+ * `alpha` is `null` for an UNSUFFIXED binding, which inherits whatever opacity
216
+ * the token itself carries — a brand colour may be authored translucent, and
217
+ * reading the bare form as 100 would silently paint it solid. A suffix is the
218
+ * author's own opacity and REPLACES the token's, so `/100` is a real value: it
219
+ * is how "opaque, whatever the brand does" is expressed.
220
+ */
221
+ function parseThemeColorTokenRef(value) {
222
+ if (typeof value !== "string") return null;
223
+ if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return null;
224
+ const body = value.slice(THEME_TOKEN_REF_PREFIX.length);
225
+ const cut = body.indexOf(THEME_TOKEN_ALPHA_SEPARATOR);
226
+ const path = cut === -1 ? body : body.slice(0, cut);
227
+ if (!THEME_COLOR_TOKENS.some((token) => token.path === path)) return null;
228
+ if (cut === -1) return { path, alpha: null };
229
+ const percent = body.slice(cut + 1);
230
+ // One spelling per opacity: no leading zeros, nothing above 100. A malformed
231
+ // suffix reads as "not a binding", so the field falls back to its default
232
+ // rather than painting a half-parsed colour.
233
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(percent)) return null;
234
+ const alpha = Number(percent);
235
+ return alpha > ALPHA_OPAQUE ? null : { path, alpha };
236
+ }
237
+
202
238
  function isThemeColorTokenRef(value) {
203
- if (typeof value !== "string") return false;
204
- if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return false;
205
- const path = value.slice(THEME_TOKEN_REF_PREFIX.length);
206
- return THEME_COLOR_TOKENS.some((token) => token.path === path);
239
+ return parseThemeColorTokenRef(value) !== null;
207
240
  }
208
241
 
242
+ /**
243
+ * Spell one binding. `percent` of `null`/`undefined` writes the bare form — the
244
+ * binding that defers to the token's own opacity. The path is the caller's to
245
+ * take from `CONTRACT.themeColorTokens`; an unlisted one spells a string this
246
+ * module's own parse rejects.
247
+ */
248
+ function formatThemeColorTokenRef(path, percent) {
249
+ const ref = `${THEME_TOKEN_REF_PREFIX}${path}`;
250
+ if (percent === null || percent === undefined) return ref;
251
+ return `${ref}${THEME_TOKEN_ALPHA_SEPARATOR}${clampAlphaPercent(percent)}`;
252
+ }
209
253
 
210
254
  // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
211
255
  // single lever: remapping `primaryColor` to recolour "the buttons" recolours
@@ -4483,7 +4527,21 @@ const CONTRACT = deepFreeze({
4483
4527
  // and form field so every widget draws them from one implementation, plus
4484
4528
  // a `height` token on the `muted` scope. Additive: the built-ins render
4485
4529
  // exactly as before.
4486
- version: "1.127.0",
4530
+ // 1.128.0: additive (sc-8893) — a `theme:` colour binding may carry an
4531
+ // OPACITY, `theme:<path>/<percent>`. Binding and transparency used to be
4532
+ // mutually exclusive: the Studio hid the opacity control the moment a
4533
+ // field bound, so "our brand colour at 20%" forced the author back to a
4534
+ // frozen hex, which then stopped following a rebrand — the exact thing
4535
+ // binding exists to prevent. `parseThemeColorTokenRef` /
4536
+ // `formatThemeColorTokenRef` publish the grammar and
4537
+ // `withHexAlpha` / `hexAlphaPercent` the percent-to-byte conversion the
4538
+ // Studio's control and both hosts' resolver share, so 20% authored is 20%
4539
+ // painted. The BARE form inherits the token's own opacity (a brand colour
4540
+ // may be authored translucent), which makes `/100` a real value rather
4541
+ // than a redundant spelling: it is how "opaque, whatever the brand does"
4542
+ // is said. Additive: an unsuffixed binding parses and resolves exactly as
4543
+ // before, so every stored value is untouched.
4544
+ version: "1.128.0",
4487
4545
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4488
4546
  hooks: HOOKS,
4489
4547
  primitives: PRIMITIVES,
@@ -4560,6 +4618,59 @@ function isHexColor(value) {
4560
4618
  return typeof value === "string" && HEX_RE.test(value);
4561
4619
  }
4562
4620
 
4621
+ // sc-8893 — opacity as an authored PERCENTAGE, converted here and nowhere else.
4622
+ // The Studio's opacity control and the host's binding resolver both spell a
4623
+ // colour's alpha through these, so the 20% a panel reads back is the 20% a
4624
+ // render paints. 255/100 > 1, so percent → byte is injective and the inverse
4625
+ // below is exact.
4626
+ function clampAlphaPercent(value) {
4627
+ const n = Math.round(Number(value));
4628
+ // Unparseable reads as fully opaque: "cannot tell" must never turn a surface
4629
+ // see-through on its own.
4630
+ if (!Number.isFinite(n)) return ALPHA_OPAQUE;
4631
+ return Math.min(ALPHA_OPAQUE, Math.max(0, n));
4632
+ }
4633
+
4634
+ function expandShortHex(hex) {
4635
+ const body = hex.slice(1);
4636
+ if (body.length > 4) return hex;
4637
+ return `#${[...body].map((c) => c + c).join("")}`;
4638
+ }
4639
+
4640
+ /**
4641
+ * Apply `percent` opacity to a hex colour, returning `#RRGGBBAA` — or a plain
4642
+ * `#RRGGBB` at full opacity, so an opaque colour is never rewritten with a
4643
+ * redundant `ff`. A non-hex colour cannot carry alpha without being rewritten
4644
+ * into another notation, so it is returned untouched rather than invented.
4645
+ *
4646
+ * "Hex" is `isHexColor`'s grammar — 3, 6 or 8 digits. The 4-digit `#RGBA` form
4647
+ * is deliberately outside it (a gradient stop may not smuggle one past the
4648
+ * injection guard), so it is returned untouched here too.
4649
+ */
4650
+ function withHexAlpha(hex, percent) {
4651
+ if (!isHexColor(hex)) return hex;
4652
+ const base = expandShortHex(hex).slice(0, 7);
4653
+ const alpha = clampAlphaPercent(percent);
4654
+ if (alpha === ALPHA_OPAQUE) return base;
4655
+ return `${base}${Math.round((alpha * 255) / 100)
4656
+ .toString(16)
4657
+ .padStart(2, "0")}`;
4658
+ }
4659
+
4660
+ /**
4661
+ * Read a hex colour's opacity back as a percentage. No alpha pair is 100.
4662
+ *
4663
+ * The percentage is lossy in this direction — 255 alpha values map onto 101
4664
+ * percentages — so a near-opaque `fe` reads back as 100. Only a value this
4665
+ * function produced round-trips exactly.
4666
+ */
4667
+ function hexAlphaPercent(hex) {
4668
+ if (!isHexColor(hex)) return ALPHA_OPAQUE;
4669
+ const full = expandShortHex(hex);
4670
+ if (full.length !== 9) return ALPHA_OPAQUE;
4671
+ return Math.round((parseInt(full.slice(7), 16) * 100) / 255);
4672
+ }
4673
+
4563
4674
  /**
4564
4675
  * The R/G/B bytes, with any alpha pair IGNORED.
4565
4676
  *
@@ -4788,7 +4899,11 @@ module.exports = {
4788
4899
  gradientAngleToVector,
4789
4900
  normaliseComponentGradient,
4790
4901
  isThemeColorTokenRef,
4902
+ parseThemeColorTokenRef,
4903
+ formatThemeColorTokenRef,
4791
4904
  THEME_TOKEN_REF_PREFIX,
4905
+ withHexAlpha,
4906
+ hexAlphaPercent,
4792
4907
  formatMoneyIn,
4793
4908
  normaliseCurrencyCode,
4794
4909
  sameWidgetRouteValue,
package/dist/contract.js CHANGED
@@ -192,20 +192,64 @@ const THEME_COLOR_TOKENS = Object.freeze([
192
192
  Object.freeze({ path: "colors.secondary", label: "Secondary" }),
193
193
  ]);
194
194
 
195
- // sc-8326 — the `theme:<path>` value grammar, as a PREDICATE over a STORED
196
- // value and declared beside the allowlist it reads. Three readers have to agree
197
- // on what a binding IS: the Studio's colour picker writes one, both hosts
198
- // resolve one, and Mason's plan validator must accept one as a gradient stop.
195
+ // sc-8326 — the `theme:<path>` value grammar, as a PARSE over a STORED value
196
+ // and declared beside the allowlist it reads. Three readers have to agree on
197
+ // what a binding IS: the Studio's colour picker writes one, both hosts resolve
198
+ // one, and Mason's plan validator must accept one as a gradient stop.
199
199
  // One definition, so they cannot drift (CLAUDE.md §3).
200
200
  const THEME_TOKEN_REF_PREFIX = "theme:";
201
201
 
202
+ // sc-8893 — a binding may carry an OPACITY, `theme:<path>/<percent>`. Without
203
+ // it an author had to choose between a colour that follows the workspace brand
204
+ // and one they could make translucent; "our brand colour at 20%" was only
205
+ // expressible by freezing today's hex, which then stopped following a rebrand.
206
+ // `/` cannot occur in a token path, so an unsuffixed binding parses exactly as
207
+ // it always did and no stored value changes meaning.
208
+ const THEME_TOKEN_ALPHA_SEPARATOR = "/";
209
+ const ALPHA_OPAQUE = 100;
210
+
211
+ /**
212
+ * Parse a stored binding into `{ path, alpha }`, or `null` when the value is
213
+ * not one.
214
+ *
215
+ * `alpha` is `null` for an UNSUFFIXED binding, which inherits whatever opacity
216
+ * the token itself carries — a brand colour may be authored translucent, and
217
+ * reading the bare form as 100 would silently paint it solid. A suffix is the
218
+ * author's own opacity and REPLACES the token's, so `/100` is a real value: it
219
+ * is how "opaque, whatever the brand does" is expressed.
220
+ */
221
+ function parseThemeColorTokenRef(value) {
222
+ if (typeof value !== "string") return null;
223
+ if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return null;
224
+ const body = value.slice(THEME_TOKEN_REF_PREFIX.length);
225
+ const cut = body.indexOf(THEME_TOKEN_ALPHA_SEPARATOR);
226
+ const path = cut === -1 ? body : body.slice(0, cut);
227
+ if (!THEME_COLOR_TOKENS.some((token) => token.path === path)) return null;
228
+ if (cut === -1) return { path, alpha: null };
229
+ const percent = body.slice(cut + 1);
230
+ // One spelling per opacity: no leading zeros, nothing above 100. A malformed
231
+ // suffix reads as "not a binding", so the field falls back to its default
232
+ // rather than painting a half-parsed colour.
233
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(percent)) return null;
234
+ const alpha = Number(percent);
235
+ return alpha > ALPHA_OPAQUE ? null : { path, alpha };
236
+ }
237
+
202
238
  function isThemeColorTokenRef(value) {
203
- if (typeof value !== "string") return false;
204
- if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return false;
205
- const path = value.slice(THEME_TOKEN_REF_PREFIX.length);
206
- return THEME_COLOR_TOKENS.some((token) => token.path === path);
239
+ return parseThemeColorTokenRef(value) !== null;
207
240
  }
208
241
 
242
+ /**
243
+ * Spell one binding. `percent` of `null`/`undefined` writes the bare form — the
244
+ * binding that defers to the token's own opacity. The path is the caller's to
245
+ * take from `CONTRACT.themeColorTokens`; an unlisted one spells a string this
246
+ * module's own parse rejects.
247
+ */
248
+ function formatThemeColorTokenRef(path, percent) {
249
+ const ref = `${THEME_TOKEN_REF_PREFIX}${path}`;
250
+ if (percent === null || percent === undefined) return ref;
251
+ return `${ref}${THEME_TOKEN_ALPHA_SEPARATOR}${clampAlphaPercent(percent)}`;
252
+ }
209
253
 
210
254
  // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
211
255
  // single lever: remapping `primaryColor` to recolour "the buttons" recolours
@@ -4479,7 +4523,21 @@ const CONTRACT = deepFreeze({
4479
4523
  // and form field so every widget draws them from one implementation, plus
4480
4524
  // a `height` token on the `muted` scope. Additive: the built-ins render
4481
4525
  // exactly as before.
4482
- version: "1.127.0",
4526
+ // 1.128.0: additive (sc-8893) — a `theme:` colour binding may carry an
4527
+ // OPACITY, `theme:<path>/<percent>`. Binding and transparency used to be
4528
+ // mutually exclusive: the Studio hid the opacity control the moment a
4529
+ // field bound, so "our brand colour at 20%" forced the author back to a
4530
+ // frozen hex, which then stopped following a rebrand — the exact thing
4531
+ // binding exists to prevent. `parseThemeColorTokenRef` /
4532
+ // `formatThemeColorTokenRef` publish the grammar and
4533
+ // `withHexAlpha` / `hexAlphaPercent` the percent-to-byte conversion the
4534
+ // Studio's control and both hosts' resolver share, so 20% authored is 20%
4535
+ // painted. The BARE form inherits the token's own opacity (a brand colour
4536
+ // may be authored translucent), which makes `/100` a real value rather
4537
+ // than a redundant spelling: it is how "opaque, whatever the brand does"
4538
+ // is said. Additive: an unsuffixed binding parses and resolves exactly as
4539
+ // before, so every stored value is untouched.
4540
+ version: "1.128.0",
4483
4541
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4484
4542
  hooks: HOOKS,
4485
4543
  primitives: PRIMITIVES,
@@ -4556,6 +4614,59 @@ function isHexColor(value) {
4556
4614
  return typeof value === "string" && HEX_RE.test(value);
4557
4615
  }
4558
4616
 
4617
+ // sc-8893 — opacity as an authored PERCENTAGE, converted here and nowhere else.
4618
+ // The Studio's opacity control and the host's binding resolver both spell a
4619
+ // colour's alpha through these, so the 20% a panel reads back is the 20% a
4620
+ // render paints. 255/100 > 1, so percent → byte is injective and the inverse
4621
+ // below is exact.
4622
+ function clampAlphaPercent(value) {
4623
+ const n = Math.round(Number(value));
4624
+ // Unparseable reads as fully opaque: "cannot tell" must never turn a surface
4625
+ // see-through on its own.
4626
+ if (!Number.isFinite(n)) return ALPHA_OPAQUE;
4627
+ return Math.min(ALPHA_OPAQUE, Math.max(0, n));
4628
+ }
4629
+
4630
+ function expandShortHex(hex) {
4631
+ const body = hex.slice(1);
4632
+ if (body.length > 4) return hex;
4633
+ return `#${[...body].map((c) => c + c).join("")}`;
4634
+ }
4635
+
4636
+ /**
4637
+ * Apply `percent` opacity to a hex colour, returning `#RRGGBBAA` — or a plain
4638
+ * `#RRGGBB` at full opacity, so an opaque colour is never rewritten with a
4639
+ * redundant `ff`. A non-hex colour cannot carry alpha without being rewritten
4640
+ * into another notation, so it is returned untouched rather than invented.
4641
+ *
4642
+ * "Hex" is `isHexColor`'s grammar — 3, 6 or 8 digits. The 4-digit `#RGBA` form
4643
+ * is deliberately outside it (a gradient stop may not smuggle one past the
4644
+ * injection guard), so it is returned untouched here too.
4645
+ */
4646
+ function withHexAlpha(hex, percent) {
4647
+ if (!isHexColor(hex)) return hex;
4648
+ const base = expandShortHex(hex).slice(0, 7);
4649
+ const alpha = clampAlphaPercent(percent);
4650
+ if (alpha === ALPHA_OPAQUE) return base;
4651
+ return `${base}${Math.round((alpha * 255) / 100)
4652
+ .toString(16)
4653
+ .padStart(2, "0")}`;
4654
+ }
4655
+
4656
+ /**
4657
+ * Read a hex colour's opacity back as a percentage. No alpha pair is 100.
4658
+ *
4659
+ * The percentage is lossy in this direction — 255 alpha values map onto 101
4660
+ * percentages — so a near-opaque `fe` reads back as 100. Only a value this
4661
+ * function produced round-trips exactly.
4662
+ */
4663
+ function hexAlphaPercent(hex) {
4664
+ if (!isHexColor(hex)) return ALPHA_OPAQUE;
4665
+ const full = expandShortHex(hex);
4666
+ if (full.length !== 9) return ALPHA_OPAQUE;
4667
+ return Math.round((parseInt(full.slice(7), 16) * 100) / 255);
4668
+ }
4669
+
4559
4670
  /**
4560
4671
  * The R/G/B bytes, with any alpha pair IGNORED.
4561
4672
  *
@@ -4784,7 +4895,11 @@ export {
4784
4895
  gradientAngleToVector,
4785
4896
  normaliseComponentGradient,
4786
4897
  isThemeColorTokenRef,
4898
+ parseThemeColorTokenRef,
4899
+ formatThemeColorTokenRef,
4787
4900
  THEME_TOKEN_REF_PREFIX,
4901
+ withHexAlpha,
4902
+ hexAlphaPercent,
4788
4903
  formatMoneyIn,
4789
4904
  normaliseCurrencyCode,
4790
4905
  sameWidgetRouteValue,
package/dist/host.d.ts CHANGED
@@ -434,7 +434,8 @@ export function isThemeTokenRef(value: unknown): boolean;
434
434
  /**
435
435
  * Resolve one `theme:<path>` binding against a resolved widget theme. Returns
436
436
  * `undefined` for a non-binding, a token outside `CONTRACT.themeColorTokens`,
437
- * or a path that does not resolve to a colour.
437
+ * or a path that does not resolve to a colour. A binding carrying an opacity
438
+ * (`theme:<path>/<percent>`, sc-8893) resolves to `#RRGGBBAA`.
438
439
  */
439
440
  export function resolveThemeTokenRef(
440
441
  value: unknown,
package/dist/index.d.ts CHANGED
@@ -2942,6 +2942,40 @@ export const THEME_TOKEN_REF_PREFIX: string;
2942
2942
  */
2943
2943
  export function isThemeColorTokenRef(value: unknown): boolean;
2944
2944
 
2945
+ export interface ThemeColorTokenRef {
2946
+ path: string;
2947
+ /** null for a bare binding, which inherits the token's own opacity. */
2948
+ alpha: number | null;
2949
+ }
2950
+
2951
+ /**
2952
+ * sc-8893 — parse a binding into its token path and opacity percentage, or null
2953
+ * when the value is not a binding. `theme:<path>` inherits whatever opacity the
2954
+ * token carries; `theme:<path>/<percent>` replaces it with the author's.
2955
+ */
2956
+ export function parseThemeColorTokenRef(
2957
+ value: unknown,
2958
+ ): ThemeColorTokenRef | null;
2959
+
2960
+ /**
2961
+ * sc-8893 — spell one binding. A `percent` of null/undefined writes the bare
2962
+ * form, which defers to the token's own opacity.
2963
+ */
2964
+ export function formatThemeColorTokenRef(
2965
+ path: string,
2966
+ percent?: unknown,
2967
+ ): string;
2968
+
2969
+ /**
2970
+ * sc-8893 — apply an opacity percentage to a hex colour, returning `#RRGGBBAA`
2971
+ * (or plain `#RRGGBB` at 100). A non-hex colour is returned untouched. Shared
2972
+ * so the Studio's opacity control and the hosts' resolver convert identically.
2973
+ */
2974
+ export function withHexAlpha(hex: string, percent: unknown): string;
2975
+
2976
+ /** sc-8893 — read a hex colour's opacity back as a percentage (no alpha = 100). */
2977
+ export function hexAlphaPercent(hex: string): number;
2978
+
2945
2979
  // Linter
2946
2980
  export interface LintFinding {
2947
2981
  rule: string;
package/dist/index.js CHANGED
@@ -164,7 +164,11 @@ export {
164
164
  gradientAngleToVector,
165
165
  normaliseComponentGradient,
166
166
  isThemeColorTokenRef,
167
+ parseThemeColorTokenRef,
168
+ formatThemeColorTokenRef,
167
169
  THEME_TOKEN_REF_PREFIX,
170
+ withHexAlpha,
171
+ hexAlphaPercent,
168
172
  clampSpacingScale,
169
173
  scaleSpacing,
170
174
  } from "./contract.js";
@@ -157,7 +157,11 @@ export {
157
157
  gradientAngleToVector,
158
158
  normaliseComponentGradient,
159
159
  isThemeColorTokenRef,
160
+ parseThemeColorTokenRef,
161
+ formatThemeColorTokenRef,
160
162
  THEME_TOKEN_REF_PREFIX,
163
+ withHexAlpha,
164
+ hexAlphaPercent,
161
165
  clampSpacingScale,
162
166
  scaleSpacing,
163
167
  } from "./contract.js";
package/dist/linter.cjs CHANGED
@@ -1337,16 +1337,20 @@ function narrowManifestForFile(manifest, filename) {
1337
1337
  // colour or font pinned in the source outranks BOTH permanently, so neither
1338
1338
  // the workspace theme nor Design -> Widget appearance can ever reach it.
1339
1339
  //
1340
- // Deliberately narrow: colour literals, a `fontFamily` string, and a bare
1341
- // numeric `fontSize`. Raw padding/margin/borderRadius numbers are NOT flagged
1342
- // — measured layout legitimately carries them (a `flexBasis` cell width the
1343
- // designer skill itself teaches), so a spacing rule would be noise that
1344
- // devalues the three unambiguous ones.
1340
+ // Deliberately narrow: colour literals, a `fontFamily` string, a bare numeric
1341
+ // `fontSize`, and a literal non-zero corner radius. A radius is the app's
1342
+ // shape — `theme.radii`, Theme Settings, the SDK components all carry it — so
1343
+ // a pinned one is the mismatch between two widgets. Raw padding/margin numbers
1344
+ // are NOT flagged: measured layout legitimately carries them (a `flexBasis`
1345
+ // cell width the designer skill itself teaches), and a computed radius
1346
+ // (`size / 2`, a circle) is geometry, not a look.
1345
1347
  const _DESIGN_HEX_RE =
1346
1348
  /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9a-fA-F])/;
1347
1349
  const _DESIGN_FUNC_COLOR_RE = /\b(?:rgba?|hsla?)\s*\(/;
1348
1350
  const _DESIGN_FONT_FAMILY_RE = /\bfontFamily\s*:\s*["'`]\s*\S/;
1349
1351
  const _DESIGN_FONT_SIZE_RE = /\bfontSize\s*:\s*-?\d/;
1352
+ const _DESIGN_RADIUS_RE =
1353
+ /\bborder(?:Top|Bottom|Start|End)?(?:Left|Right|Start|End)?Radius\s*:\s*(?:0*[1-9]\d*(?:\.\d+)?|0\.\d*[1-9]\d*)\s*(?=[,}]|$)/;
1350
1354
  // A line that already reads the theme or the author's style object is
1351
1355
  // resolving a DECLARED fallback, not pinning a look — `style.valueSize ?? 18`
1352
1356
  // is exactly what a styleSchema `default` is for. Colour literals are held to
@@ -1411,6 +1415,8 @@ function _hardcodedDesignRules(source) {
1411
1415
  !_DESIGN_TOKEN_REF_RE.test(line)
1412
1416
  ) {
1413
1417
  what = "a pixel font size";
1418
+ } else if (_DESIGN_RADIUS_RE.test(line)) {
1419
+ what = "a pixel corner radius";
1414
1420
  }
1415
1421
  if (!what) continue;
1416
1422
  findings.push({
@@ -1419,7 +1425,8 @@ function _hardcodedDesignRules(source) {
1419
1425
  label:
1420
1426
  `${what} pinned in the source is beyond the reach of BOTH the ` +
1421
1427
  `workspace theme and the author's Style controls. Read it from ` +
1422
- `useTheme() (theme.colors.* / theme.typography.*), or declare a ` +
1428
+ `useTheme() (theme.colors.* / theme.typography.* / theme.radii.*), ` +
1429
+ `use the SDK's <Card> / <Button> / <ListRow>, or declare a ` +
1423
1430
  `styleSchema field and apply props.style.<field>. License a value ` +
1424
1431
  `that truly cannot be a token — a series palette, a video ` +
1425
1432
  `letterbox, a QR plate — with a preceding ` +
package/dist/linter.js CHANGED
@@ -1408,16 +1408,20 @@ export function narrowManifestForFile(manifest, filename) {
1408
1408
  // colour or font pinned in the source outranks BOTH permanently, so neither
1409
1409
  // the workspace theme nor Design -> Widget appearance can ever reach it.
1410
1410
  //
1411
- // Deliberately narrow: colour literals, a `fontFamily` string, and a bare
1412
- // numeric `fontSize`. Raw padding/margin/borderRadius numbers are NOT flagged
1413
- // — measured layout legitimately carries them (a `flexBasis` cell width the
1414
- // designer skill itself teaches), so a spacing rule would be noise that
1415
- // devalues the three unambiguous ones.
1411
+ // Deliberately narrow: colour literals, a `fontFamily` string, a bare numeric
1412
+ // `fontSize`, and a literal non-zero corner radius. A radius is the app's
1413
+ // shape — `theme.radii`, Theme Settings, the SDK components all carry it — so
1414
+ // a pinned one is the mismatch between two widgets. Raw padding/margin numbers
1415
+ // are NOT flagged: measured layout legitimately carries them (a `flexBasis`
1416
+ // cell width the designer skill itself teaches), and a computed radius
1417
+ // (`size / 2`, a circle) is geometry, not a look.
1416
1418
  const _DESIGN_HEX_RE =
1417
1419
  /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9a-fA-F])/;
1418
1420
  const _DESIGN_FUNC_COLOR_RE = /\b(?:rgba?|hsla?)\s*\(/;
1419
1421
  const _DESIGN_FONT_FAMILY_RE = /\bfontFamily\s*:\s*["'`]\s*\S/;
1420
1422
  const _DESIGN_FONT_SIZE_RE = /\bfontSize\s*:\s*-?\d/;
1423
+ const _DESIGN_RADIUS_RE =
1424
+ /\bborder(?:Top|Bottom|Start|End)?(?:Left|Right|Start|End)?Radius\s*:\s*(?:0*[1-9]\d*(?:\.\d+)?|0\.\d*[1-9]\d*)\s*(?=[,}]|$)/;
1421
1425
  // A line that already reads the theme or the author's style object is
1422
1426
  // resolving a DECLARED fallback, not pinning a look — `style.valueSize ?? 18`
1423
1427
  // is exactly what a styleSchema `default` is for. Colour literals are held to
@@ -1482,6 +1486,8 @@ function _hardcodedDesignRules(source) {
1482
1486
  !_DESIGN_TOKEN_REF_RE.test(line)
1483
1487
  ) {
1484
1488
  what = "a pixel font size";
1489
+ } else if (_DESIGN_RADIUS_RE.test(line)) {
1490
+ what = "a pixel corner radius";
1485
1491
  }
1486
1492
  if (!what) continue;
1487
1493
  findings.push({
@@ -1490,7 +1496,8 @@ function _hardcodedDesignRules(source) {
1490
1496
  label:
1491
1497
  `${what} pinned in the source is beyond the reach of BOTH the ` +
1492
1498
  `workspace theme and the author's Style controls. Read it from ` +
1493
- `useTheme() (theme.colors.* / theme.typography.*), or declare a ` +
1499
+ `useTheme() (theme.colors.* / theme.typography.* / theme.radii.*), ` +
1500
+ `use the SDK's <Card> / <Button> / <ListRow>, or declare a ` +
1494
1501
  `styleSchema field and apply props.style.<field>. License a value ` +
1495
1502
  `that truly cannot be a token — a series palette, a video ` +
1496
1503
  `letterbox, a QR plate — with a preceding ` +
@@ -18,7 +18,11 @@
18
18
  // type today's hex, which went stale silently. A bound field stores the TOKEN,
19
19
  // so the intent survives the rebrand.
20
20
 
21
- import { isThemeColorTokenRef, THEME_TOKEN_REF_PREFIX } from "./contract.js";
21
+ import {
22
+ parseThemeColorTokenRef,
23
+ withHexAlpha,
24
+ THEME_TOKEN_REF_PREFIX,
25
+ } from "./contract.js";
22
26
 
23
27
  // A value is a binding, not a colour, when it carries this prefix. No CSS
24
28
  // colour can begin with it, so a stored literal can never be misread as one.
@@ -26,7 +30,15 @@ import { isThemeColorTokenRef, THEME_TOKEN_REF_PREFIX } from "./contract.js";
26
30
  // grammar from there, and two spellings of it would drift.
27
31
  const PREFIX = THEME_TOKEN_REF_PREFIX;
28
32
 
29
- /** True when `value` is a theme-token binding rather than a literal colour. */
33
+ /**
34
+ * True when `value` is a theme-token binding rather than a literal colour.
35
+ *
36
+ * Deliberately the PREFIX alone, not the contract's full parse: the callers
37
+ * below use it to decide what to DROP, and a binding naming a withdrawn token
38
+ * or carrying a malformed opacity must go back to unset — where its
39
+ * `themeDefault` paints — rather than reach a renderer as the literal string
40
+ * `theme:...`.
41
+ */
30
42
  export function isThemeTokenRef(value) {
31
43
  return typeof value === "string" && value.startsWith(PREFIX);
32
44
  }
@@ -43,14 +55,16 @@ export function resolveThemeTokenRef(value, theme) {
43
55
  // The allowlist is the contract's, so what the Studio may WRITE and what a
44
56
  // host will RESOLVE cannot drift apart. sc-8326 moved the test itself there
45
57
  // too, since Mason's plan validator has to ask the same question.
46
- if (!isThemeColorTokenRef(value)) return undefined;
47
- const path = value.slice(PREFIX.length);
48
- const resolved = path
58
+ const ref = parseThemeColorTokenRef(value);
59
+ if (!ref) return undefined;
60
+ const resolved = ref.path
49
61
  .split(".")
50
62
  .reduce((o, k) => (o == null ? undefined : o[k]), theme);
51
- return typeof resolved === "string" && resolved.trim() !== ""
52
- ? resolved
53
- : undefined;
63
+ if (typeof resolved !== "string" || resolved.trim() === "") return undefined;
64
+ // sc-8893: an authored opacity REPLACES the token's. With none authored the
65
+ // token is handed back verbatim — including a token that is itself
66
+ // translucent, which reading the bare form as 100 would quietly make solid.
67
+ return ref.alpha === null ? resolved : withHexAlpha(resolved, ref.alpha);
54
68
  }
55
69
 
56
70
  function isPlainObject(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.164.0",
3
+ "version": "0.166.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",