@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 +13 -0
- package/dist/contract.cjs +124 -9
- package/dist/contract.js +124 -9
- package/dist/host.d.ts +2 -1
- package/dist/index.d.ts +34 -0
- package/dist/index.js +4 -0
- package/dist/index.native.js +4 -0
- package/dist/theme-token-refs.js +22 -8
- package/package.json +1 -1
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
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/dist/index.native.js
CHANGED
|
@@ -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/theme-token-refs.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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",
|