@colixsystems/widget-sdk 0.165.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
@@ -75,6 +75,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
75
75
 
76
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
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
+
78
91
  ### What's new in 0.165.0 (contract 1.127.0 — unchanged)
79
92
 
80
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.
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";
@@ -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.165.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",