@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 +19 -2
- 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/linter.cjs +13 -6
- package/dist/linter.js +13 -6
- package/dist/theme-token-refs.js +22 -8
- package/package.json +1 -1
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.
|
|
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,
|
|
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
|
|
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/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,
|
|
1341
|
-
//
|
|
1342
|
-
// —
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
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.*),
|
|
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,
|
|
1412
|
-
//
|
|
1413
|
-
// —
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
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.*),
|
|
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 ` +
|
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",
|