@colixsystems/widget-sdk 0.270.0 → 0.272.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
@@ -86,7 +86,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
86
86
 
87
87
  ## Status
88
88
 
89
- `v0.270.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**.
89
+ `v0.272.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**.
90
+
91
+ ### What's new in 0.272.0 (contract 1.222.0)
92
+
93
+ **Breaking for authors: more routes to the page's global object are banned.** `self`, `top`, `parent`, `frames` and `opener` are other names for `window`, `frameElement` leads to the host document, and a function's `.constructor` is `Function()`. The linter now refuses them (`no-self`, `no-top`, `no-parent`, `no-frames`, `no-opener`, `no-frameelement`, `no-constructor-access`), refuses `eval` and `Function` as any reference, not only as a call (`const e = eval`, `(0, eval)(…)`), and refuses a `\u` escape that spells a letter, digit, `_` or `$` in code (`no-identifier-escape`), which would hide a name from the scan. A widget version that uses them fails lint at submission; versions already published keep running.
94
+
95
+ - **`self` / `top` / `parent` / `frames` / `opener`** are refused wherever they read a variable: `top[k]`, `{ ...self }`, `typeof parent`, `c ? frames : x`. That includes a local variable with one of these names, so rename it (`const [rootTop, setRootTop] = useState(null)`, `const parentId = …`). A property (`insets.top`, `node.parent`), an object key (`{ top: 0 }`, `const { top: topInset } = insets`) and a word in JSX text after another word on the same line (`Back to top`) are fine. JSX text that is only the word (`<Text>top</Text>`) or starts a line is refused; render it as `{"top"}` or through `t()`.
96
+ - **`.constructor`** is refused as a property read (`x.constructor`, `x?.constructor`), a destructured key (`const { constructor } = fn`) and the quoted name (`x["constructor"]`, `Reflect.get(p, "constructor")`). A class component's own `constructor(props)` method is fine.
97
+ - **`Function`** stays allowed after `instanceof` (`v instanceof Function`); prefer `typeof v === "function"`. To test for a plain object, compare `Object.getPrototypeOf(x) === Object.prototype`.
98
+
99
+ ### What's new in 0.271.0 (contract 1.221.0)
100
+
101
+ **Basic styling in one call.** Spread `BASIC_STYLE_SCHEMA` into `manifest.styleSchema` and call `basicStyle(style)` in the component: the five basic style fields (`background`, `textColor`, `align`, `padding`, `margin`) are declared and applied without wiring each by hand. See *Basic styling* under *Design & visual polish*.
90
102
 
91
103
  ### What's new in 0.270.0 (contract 1.220.0)
92
104
 
@@ -3345,6 +3357,22 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
3345
3357
 
3346
3358
  **Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.
3347
3359
 
3360
+ - **Basic styling: one schema, one call.** Every generated widget offers five whole-widget style fields: `background`, `textColor`, `align`, `padding` and `margin`. Spread `BASIC_STYLE_SCHEMA` into your manifest's `styleSchema`, then apply them with `basicStyle(style)`. It returns `{ root, text }`: spread `root` on the outermost `View` (background, align as `alignItems`, per-side padding and margin) and `text` on each `Text` in a theme text colour. A field the author left unset emits nothing, so your theme-based styles show through. `appstudio-widget lint` counts the call as reading all five. When `submit_widget_source` or the Studio's widget builder publishes a widget that calls `basicStyle()`, any of the five its manifest leaves out is declared for it.
3361
+
3362
+ ```jsx
3363
+ import { View, Text, basicStyle, BASIC_STYLE_SCHEMA } from "@colixsystems/widget-sdk";
3364
+
3365
+ export const manifest = { /* … */ styleSchema: { ...BASIC_STYLE_SCHEMA, titleColor: { type: "color", label: "Title", ui: { group: "Title" } } } };
3366
+
3367
+ export default function Hero({ title, style = {} }) {
3368
+ const basic = basicStyle(style);
3369
+ return (
3370
+ <View style={[styles.root, basic.root]}>
3371
+ <Text style={[styles.title, basic.text, style.titleColor && { color: style.titleColor }]}>{title}</Text>
3372
+ </View>
3373
+ );
3374
+ }
3375
+ ```
3348
3376
  - **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. Don't hardcode raw pixel values.
3349
3377
  - **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
3350
3378
  - **Style text by its ROLE, not by inventing a size and weight.** `theme.typography.roles` carries the workspace's type scale as six semantic tiers — `display`, `title`, `subtitle`, `body`, `caption`, `overline` — each already resolved to a `fontSize`, `fontWeight`, `fontStyle`, `lineHeight` (a MULTIPLE of the size), `letterSpacing`, `textTransform` and a `colorToken`. Pick the tier the text IS, rather than choosing `fontSize: 16, fontWeight: "600"` yourself: your widget's section title is `title`, each card or row title is `subtitle`, running text is `body`, timestamps and helper text are `caption`, a small kicker or column label is `overline`, and a headline stat is `display`. This is what stops one widget rendering a subtitle bold while the widget beside it renders one italic, and it lets the workspace re-tune every subtitle in the app from Theme Settings in one edit. **Two tokens need converting rather than copying** — `lineHeight` is a multiple (React Native wants absolute pixels, so the raw `1.15` gives you a one-pixel line box) and `colorToken` names a colour rather than being one:
@@ -3473,6 +3501,10 @@ joined — it then reads back as `{ id, name, invited: true }`, with no `email`
3473
3501
  behind it. `status: 'open'` is every invite that is neither accepted nor revoked
3474
3502
  (`pending`, `not_sent` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
3475
3503
 
3504
+ Only a signed-in member viewer sees a `USER` cell's `email`. A visitor who is not
3505
+ signed in, and an anonymous account, read a member as `{ id, name }`, so render
3506
+ `name` and treat `email` as optional.
3507
+
3476
3508
  **The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
3477
3509
  someone who is not a member yet, so the backend gates the entire invite surface —
3478
3510
  listing included — on the `users.write` capability plus a signed `users.write:*`
@@ -3756,7 +3788,7 @@ npx appstudio-widget lint path/to/widget.jsx
3756
3788
  npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
3757
3789
  ```
3758
3790
 
3759
- Scans for banned patterns (`eval`, `new Function`, dynamic `import()`, direct imports of host stores, raw axios) and for the styling rules below. **Only error-severity findings change the exit code**; warnings print and exit 0.
3791
+ Scans for banned patterns (every `CONTRACT.bannedApis` entry — `eval`, `Function`, dynamic `import()`, `window` and its other names `self` / `top` / `parent` / `frames`, `.constructor`, … — and direct imports of host stores) and for the styling rules below. **Only error-severity findings change the exit code**; warnings print and exit 0.
3760
3792
 
3761
3793
  ```js
3762
3794
  import {
@@ -0,0 +1,37 @@
1
+ // The five style fields every generated widget declares, in one call: spread
2
+ // BASIC_STYLE_SCHEMA into `manifest.styleSchema`, apply `basicStyle(style)`.
3
+ // Platform-free, so the web and native entries export this one module.
4
+
5
+ import { CONTRACT } from "./contract.js";
6
+ import { spacingOverlay } from "./card-style.js";
7
+
8
+ export const BASIC_STYLE_SCHEMA = CONTRACT.basicStyleSchema;
9
+
10
+ const ALIGN_ITEMS = Object.freeze({
11
+ left: "flex-start",
12
+ center: "center",
13
+ right: "flex-end",
14
+ });
15
+
16
+ /**
17
+ * The basic style fields as React Native styles. Each is emitted only when the
18
+ * author set it, so the widget's own theme-based styles show through.
19
+ *
20
+ * @param {object} [style] the widget's `props.style`
21
+ * @returns {{ root: object, text: object|null }} `root` for the outermost
22
+ * View (backgroundColor, alignItems, padding, margin); `text` (`{ color }`)
23
+ * for each Text coloured by a theme text token.
24
+ */
25
+ export function basicStyle(style) {
26
+ const s = style || {};
27
+ const alignItems = ALIGN_ITEMS[s.align];
28
+ return {
29
+ root: {
30
+ ...(s.background ? { backgroundColor: s.background } : null),
31
+ ...(alignItems ? { alignItems } : null),
32
+ ...spacingOverlay(s.padding, "padding"),
33
+ ...spacingOverlay(s.margin, "margin"),
34
+ },
35
+ text: s.textColor ? { color: s.textColor } : null,
36
+ };
37
+ }
package/dist/contract.cjs CHANGED
@@ -389,6 +389,38 @@ const THEME_SPACING_SCALE = Object.freeze({
389
389
  default: 1,
390
390
  });
391
391
 
392
+ // The style fields every generated widget declares, with the controls the
393
+ // Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
394
+ // `basicStyle()`, the linter, the publish gate and the Mason plan gate.
395
+ const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
396
+ const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
397
+ const BASIC_STYLE_SCHEMA = Object.freeze({
398
+ background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
399
+ textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
400
+ align: Object.freeze({
401
+ type: "select",
402
+ label: "Align",
403
+ enum: Object.freeze([
404
+ Object.freeze({ value: "left", label: "Left" }),
405
+ Object.freeze({ value: "center", label: "Center" }),
406
+ Object.freeze({ value: "right", label: "Right" }),
407
+ ]),
408
+ ui: BASIC_STYLE_GROUP,
409
+ }),
410
+ padding: Object.freeze({
411
+ type: "spacing",
412
+ label: "Padding",
413
+ validation: BASIC_SPACING_RANGE,
414
+ ui: BASIC_STYLE_GROUP,
415
+ }),
416
+ margin: Object.freeze({
417
+ type: "spacing",
418
+ label: "Margin",
419
+ validation: BASIC_SPACING_RANGE,
420
+ ui: BASIC_STYLE_GROUP,
421
+ }),
422
+ });
423
+
392
424
  // The card-surface field names shared by every widget that paints its own card
393
425
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
394
426
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -4508,6 +4540,20 @@ const BANNED_APIS = [
4508
4540
  reason:
4509
4541
  "Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
4510
4542
  },
4543
+ ...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
4544
+ identifier,
4545
+ reason:
4546
+ "Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
4547
+ })),
4548
+ {
4549
+ identifier: "frameElement",
4550
+ reason: "The element framing the page, a route to the host document.",
4551
+ },
4552
+ {
4553
+ identifier: ".constructor",
4554
+ reason:
4555
+ "Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
4556
+ },
4511
4557
  ];
4512
4558
 
4513
4559
  // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
@@ -6508,7 +6554,12 @@ const CONTRACT = deepFreeze({
6508
6554
  // every query, record, figure and aggregate on that table, app-wide.
6509
6555
  // 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
6510
6556
  // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6511
- version: "1.220.0",
6557
+ // 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
6558
+ // `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
6559
+ // 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
6560
+ // `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
6561
+ // `Function` are refused as any reference, not only as a call.
6562
+ version: "1.222.0",
6512
6563
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6513
6564
  hooks: HOOKS,
6514
6565
  primitives: PRIMITIVES,
@@ -6533,6 +6584,7 @@ const CONTRACT = deepFreeze({
6533
6584
  themeSpacingScale: THEME_SPACING_SCALE,
6534
6585
  themeMenuTypes: THEME_MENU_TYPES,
6535
6586
  themeWidgetStyles: THEME_WIDGET_STYLES,
6587
+ basicStyleSchema: BASIC_STYLE_SCHEMA,
6536
6588
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6537
6589
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6538
6590
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
package/dist/contract.js CHANGED
@@ -386,6 +386,38 @@ const THEME_SPACING_SCALE = Object.freeze({
386
386
  default: 1,
387
387
  });
388
388
 
389
+ // The style fields every generated widget declares, with the controls the
390
+ // Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
391
+ // `basicStyle()`, the linter, the publish gate and the Mason plan gate.
392
+ const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
393
+ const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
394
+ const BASIC_STYLE_SCHEMA = Object.freeze({
395
+ background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
396
+ textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
397
+ align: Object.freeze({
398
+ type: "select",
399
+ label: "Align",
400
+ enum: Object.freeze([
401
+ Object.freeze({ value: "left", label: "Left" }),
402
+ Object.freeze({ value: "center", label: "Center" }),
403
+ Object.freeze({ value: "right", label: "Right" }),
404
+ ]),
405
+ ui: BASIC_STYLE_GROUP,
406
+ }),
407
+ padding: Object.freeze({
408
+ type: "spacing",
409
+ label: "Padding",
410
+ validation: BASIC_SPACING_RANGE,
411
+ ui: BASIC_STYLE_GROUP,
412
+ }),
413
+ margin: Object.freeze({
414
+ type: "spacing",
415
+ label: "Margin",
416
+ validation: BASIC_SPACING_RANGE,
417
+ ui: BASIC_STYLE_GROUP,
418
+ }),
419
+ });
420
+
389
421
  // The card-surface field names shared by every widget that paints its own card
390
422
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
391
423
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -4505,6 +4537,20 @@ const BANNED_APIS = [
4505
4537
  reason:
4506
4538
  "Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
4507
4539
  },
4540
+ ...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
4541
+ identifier,
4542
+ reason:
4543
+ "Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
4544
+ })),
4545
+ {
4546
+ identifier: "frameElement",
4547
+ reason: "The element framing the page, a route to the host document.",
4548
+ },
4549
+ {
4550
+ identifier: ".constructor",
4551
+ reason:
4552
+ "Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
4553
+ },
4508
4554
  ];
4509
4555
 
4510
4556
  // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
@@ -6505,7 +6551,12 @@ const CONTRACT = deepFreeze({
6505
6551
  // every query, record, figure and aggregate on that table, app-wide.
6506
6552
  // 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
6507
6553
  // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6508
- version: "1.220.0",
6554
+ // 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
6555
+ // `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
6556
+ // 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
6557
+ // `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
6558
+ // `Function` are refused as any reference, not only as a call.
6559
+ version: "1.222.0",
6509
6560
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6510
6561
  hooks: HOOKS,
6511
6562
  primitives: PRIMITIVES,
@@ -6530,6 +6581,7 @@ const CONTRACT = deepFreeze({
6530
6581
  themeSpacingScale: THEME_SPACING_SCALE,
6531
6582
  themeMenuTypes: THEME_MENU_TYPES,
6532
6583
  themeWidgetStyles: THEME_WIDGET_STYLES,
6584
+ basicStyleSchema: BASIC_STYLE_SCHEMA,
6533
6585
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6534
6586
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6535
6587
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.
package/dist/index.d.ts CHANGED
@@ -5576,6 +5576,27 @@ export function roleColor(tokens: any, colors: any): string | undefined;
5576
5576
  */
5577
5577
  export function textRoleStyle(role: TextRole | string, typography: any, colors?: any): Record<string, any>;
5578
5578
 
5579
+ /**
5580
+ * The five style fields every generated widget declares — `background`,
5581
+ * `textColor`, `align`, `padding` and `margin` — as styleSchema entries.
5582
+ * Spread it into `manifest.styleSchema`; `basicStyle()` applies it.
5583
+ */
5584
+ export const BASIC_STYLE_SCHEMA: Readonly<WidgetPropertySchema>;
5585
+ /**
5586
+ * The basic style fields as React Native styles, each only when set. Spread
5587
+ * `root` on the outermost View and `text` on each Text in a theme text colour.
5588
+ *
5589
+ * @example
5590
+ * const basic = basicStyle(style);
5591
+ * <View style={[styles.root, basic.root]}>
5592
+ * <Text style={[styles.title, basic.text]}>{title}</Text>
5593
+ * </View>
5594
+ */
5595
+ export function basicStyle(style?: Record<string, any> | null): {
5596
+ root: Record<string, any>;
5597
+ text: { color: string } | null;
5598
+ };
5599
+
5579
5600
  // ----------------------------------------------------- formatted content
5580
5601
  // sc-6970 — widgets have no HTML path: they render through React Native
5581
5602
  // primitives, which have no `dangerouslySetInnerHTML` on either host. Content
package/dist/index.js CHANGED
@@ -268,6 +268,8 @@ export {
268
268
  gradientProps,
269
269
  cardGradientProps,
270
270
  } from "./card-style.js";
271
+ // The five style fields every generated widget declares, in one call.
272
+ export { BASIC_STYLE_SCHEMA, basicStyle } from "./basic-style.js";
271
273
  // The themed button; follows the theme's button, destructive and ring scopes.
272
274
  export { Button, IconButton, BUTTON_VARIANTS } from "./button.js";
273
275
  export {
@@ -261,6 +261,8 @@ export {
261
261
  gradientProps,
262
262
  cardGradientProps,
263
263
  } from "./card-style.js";
264
+ // The five style fields every generated widget declares, in one call.
265
+ export { BASIC_STYLE_SCHEMA, basicStyle } from "./basic-style.js";
264
266
  // The themed button; follows the theme's button, destructive and ring scopes.
265
267
  export { Button, IconButton, BUTTON_VARIANTS } from "./button.native.js";
266
268
  export {
package/dist/linter.cjs CHANGED
@@ -13,42 +13,151 @@
13
13
  const { CONTRACT, normaliseActionTriggerTypes } = require("./contract.cjs");
14
14
  const { LUCIDE_ICON_NAMES, LUCIDE_VERSION } = require("./lucideIconNames.cjs");
15
15
 
16
+ // Per-identifier match rule, run over the whole masked source. Most banned
17
+ // identifiers compile to a whole-word match.
16
18
  function _ruleForIdentifier(identifier, reason) {
17
19
  const id = identifier;
18
20
  if (id === "Function") {
21
+ // Any reference, so an alias (`const F = Function`) is refused too.
19
22
  return {
20
23
  id: "no-function-constructor",
21
24
  label: `${reason} (banned: Function() constructor)`,
22
- pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
25
+ pattern: /(?<![\w$])Function(?![\w$])/g,
26
+ accept: (code, index) =>
27
+ !_isMemberName(code, index) && _wordBefore(code, index) !== "instanceof",
23
28
  };
24
29
  }
25
30
  if (id === "new Function") {
26
31
  return {
27
32
  id: "no-new-function",
28
33
  label: `${reason} (banned: new Function())`,
29
- pattern: /\bnew\s+Function\s*\(/,
34
+ pattern: /\bnew\s+Function\s*\(/g,
30
35
  };
31
36
  }
32
37
  if (id === "import(") {
33
38
  return {
34
39
  id: "no-dynamic-import",
35
40
  label: `${reason} (banned: dynamic import())`,
36
- pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
41
+ pattern: /(?<![\w$.])import\s*\(/g,
37
42
  };
38
43
  }
39
44
  if (id === "eval") {
45
+ // Any reference, so indirect `(0, eval)(…)` is refused too.
40
46
  return {
41
47
  id: "no-eval",
42
48
  label: `${reason} (banned: eval)`,
43
- pattern: /\beval\s*\(/,
49
+ pattern: /(?<![\w$])eval(?![\w$])/g,
50
+ };
51
+ }
52
+ if (id === ".constructor") {
53
+ return {
54
+ id: "no-constructor-access",
55
+ label: `${reason} (banned: .constructor)`,
56
+ // Also `{ constructor: F } = fn`; a class's `constructor(` passes.
57
+ pattern: /\.\s*constructor(?![\w$])|(?<![\w$.])constructor(?![\w$])(?!\s*\()/g,
58
+ stringPattern: /(["'`])constructor\1/g,
44
59
  };
45
60
  }
46
61
  const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
- return {
48
- id: `no-${id.toLowerCase()}`,
49
- label: `${reason} (banned: ${id})`,
50
- pattern: new RegExp(`\\b${escaped}\\b`),
51
- };
62
+ const rule = { id: `no-${id.toLowerCase()}`, label: `${reason} (banned: ${id})` };
63
+ if (_GLOBAL_OBJECT_ALIASES.has(id)) {
64
+ // Also a common property name, so only a read of the variable is refused.
65
+ return {
66
+ ...rule,
67
+ pattern: new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "g"),
68
+ accept: (code, index) => _readsVariable(code, index, id.length),
69
+ };
70
+ }
71
+ return { ...rule, pattern: new RegExp(`\\b${escaped}\\b`, "g") };
72
+ }
73
+
74
+ // Other names for the page's global object that are also everyday property
75
+ // names (`insets.top`, `node.parent`).
76
+ const _GLOBAL_OBJECT_ALIASES = new Set(["self", "top", "parent", "frames", "opener"]);
77
+
78
+ // Read-leading keywords `_EXPRESSION_LEAD_WORDS` leaves out.
79
+ const _READ_LEAD_WORDS = new Set(["new", "extends", "default"]);
80
+
81
+ // Index of the last non-space character before `index`, and whether a
82
+ // newline lies between.
83
+ function _tokenBefore(code, index) {
84
+ let at = index - 1;
85
+ let newline = false;
86
+ for (; at >= 0 && /\s/.test(code[at]); at -= 1) {
87
+ if (code[at] === "\n") newline = true;
88
+ }
89
+ return { at, newline };
90
+ }
91
+
92
+ function _wordBefore(code, index) {
93
+ const { at } = _tokenBefore(code, index);
94
+ let start = at + 1;
95
+ while (start > 0 && /[\w$]/.test(code[start - 1])) start -= 1;
96
+ return code.slice(start, at + 1);
97
+ }
98
+
99
+ // `x.name` / `x?.name`, but not a spread (`...name`) and not the decimal point
100
+ // of an integer (`1.` + newline starts a new statement).
101
+ function _isMemberName(code, index) {
102
+ const { at } = _tokenBefore(code, index);
103
+ if (code[at] !== ".") return false;
104
+ if (code[at - 1] === ".") return false;
105
+ return !/(^|[^\w$.])\d[\d_]*$/.test(code.slice(Math.max(0, at - 64), at));
106
+ }
107
+
108
+ // Whether the name at `index` reads its variable. A member name, an object
109
+ // key, a write, and a word after a declaration keyword or JSX text on the
110
+ // same line do not.
111
+ function _readsVariable(code, index, length) {
112
+ if (_isMemberName(code, index)) return false;
113
+ const { at: before, newline } = _tokenBefore(code, index);
114
+ let after = index + length;
115
+ while (after < code.length && /\s/.test(code[after])) after += 1;
116
+ const prev = code[before] || "";
117
+ if (code[after] === ":" && (prev === "{" || prev === ",")) return false;
118
+ if (code[after] === "=" && !/[=>]/.test(code[after + 1] || "")) return false;
119
+ if (newline || !/[\w$]/.test(prev)) return true;
120
+ const word = _wordBefore(code, index);
121
+ return _EXPRESSION_LEAD_WORDS.has(word) || _READ_LEAD_WORDS.has(word);
122
+ }
123
+
124
+ // A `\u` escape spelling an ASCII identifier character (`\u0077indow`).
125
+ const _IDENTIFIER_ESCAPE_RE = /\\u(?:\{0*([0-9a-fA-F]{1,6})\}|([0-9a-fA-F]{4}))/g;
126
+
127
+ function _escapesAsciiIdentifier(code, index) {
128
+ // A fresh regex: matchAll clones the shared one with its lastIndex.
129
+ const at = new RegExp(_IDENTIFIER_ESCAPE_RE.source, "y");
130
+ at.lastIndex = index;
131
+ const m = at.exec(code);
132
+ const ch = String.fromCodePoint(parseInt(m[1] || m[2], 16));
133
+ return /^[\w$]$/.test(ch);
134
+ }
135
+
136
+ // One finding per rule per line, in line order, then rule order. Masking keeps
137
+ // every index, so a match's line is the source's line.
138
+ function _bannedApiFindings(source, lines) {
139
+ const masks = [_stripNonCode(source), _stripNonCode(source, { keepStrings: true })];
140
+ const hits = [];
141
+ RULES.forEach((rule, order) => {
142
+ const ruleLines = new Set();
143
+ [rule.pattern, rule.stringPattern].forEach((pattern, maskIndex) => {
144
+ if (!pattern) return;
145
+ const code = masks[maskIndex];
146
+ const lineAt = _lineCounter(code);
147
+ for (const match of code.matchAll(pattern)) {
148
+ if (!rule.accept || rule.accept(code, match.index)) ruleLines.add(lineAt(match.index));
149
+ }
150
+ });
151
+ for (const line of ruleLines) hits.push({ rule, order, line });
152
+ });
153
+ hits.sort((a, b) => a.line - b.line || a.order - b.order);
154
+ return hits.map(({ rule, line }) => ({
155
+ rule: rule.id,
156
+ severity: "error",
157
+ label: rule.label,
158
+ line,
159
+ snippet: lines[line - 1].trim().slice(0, 200),
160
+ }));
52
161
  }
53
162
 
54
163
  const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
@@ -209,7 +318,14 @@ const EXTRA_RULES = [
209
318
  {
210
319
  id: "no-auth-store-import",
211
320
  label: "widgets must not import the host's auth store",
212
- pattern: /useAuthStore/,
321
+ pattern: /useAuthStore/g,
322
+ },
323
+ {
324
+ id: "no-identifier-escape",
325
+ label:
326
+ "a \\u escape spelling a letter, digit, _ or $ hides a name from the banned-API scan; write the name plainly",
327
+ pattern: _IDENTIFIER_ESCAPE_RE,
328
+ accept: _escapesAsciiIdentifier,
213
329
  },
214
330
  ];
215
331
 
@@ -1946,11 +2062,15 @@ function lintStyleWiring(manifest, files) {
1946
2062
  const code = sources
1947
2063
  .map((s) => _stripNonCode(s, { keepStrings: true }))
1948
2064
  .join("\n");
2065
+ const appliedByHelper = callsBasicStyle(sources)
2066
+ ? _WHOLE_WIDGET_STYLE_FIELDS
2067
+ : new Set();
1949
2068
  const findings = [];
1950
2069
  for (const field of Object.keys(schema)) {
1951
2070
  // A key that is not a bare identifier cannot be scanned by name; the
1952
2071
  // manifest validator rejects those anyway.
1953
2072
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
2073
+ if (appliedByHelper.has(field)) continue;
1954
2074
  if (new RegExp(`\\b${field}\\b`).test(code)) continue;
1955
2075
  findings.push({
1956
2076
  rule: "style-field-unread",
@@ -1961,7 +2081,8 @@ function lintStyleWiring(manifest, files) {
1961
2081
  `— per instance, and app-wide under Design -> Widget appearance — ` +
1962
2082
  `moves nothing. Read it from props.style and apply it ONLY when ` +
1963
2083
  `set, so the theme still shows through while the author has chosen ` +
1964
- `none. If it is not styleable, drop it from styleSchema instead.`,
2084
+ `none. If it is not styleable, drop it from styleSchema instead.` +
2085
+ (_WHOLE_WIDGET_STYLE_FIELDS.has(field) ? _BASIC_STYLE_FIX : ""),
1965
2086
  line: 0,
1966
2087
  snippet: field,
1967
2088
  });
@@ -1970,6 +2091,26 @@ function lintStyleWiring(manifest, files) {
1970
2091
  return { ok: !hasErrors, findings };
1971
2092
  }
1972
2093
 
2094
+ const _BASIC_STYLE_CALL = /\bbasicStyle\s*\(/;
2095
+
2096
+ const _BASIC_STYLE_FIX =
2097
+ ` The one-line fix for every basic field: basicStyle(props.style) from ` +
2098
+ `@colixsystems/widget-sdk — spread its .root on the outermost View and ` +
2099
+ `its .text on each Text in a theme text colour.`;
2100
+
2101
+ /**
2102
+ * Whether the bundle calls `basicStyle()`, which applies every basic style
2103
+ * field (`CONTRACT.basicStyleSchema`) in one call.
2104
+ *
2105
+ * @param {object|string[]|string} files bundle sources, as `lintStyleWiring` takes them
2106
+ * @returns {boolean}
2107
+ */
2108
+ function callsBasicStyle(files) {
2109
+ return _bundleSources(files).some((s) =>
2110
+ _BASIC_STYLE_CALL.test(_stripNonCode(s)),
2111
+ );
2112
+ }
2113
+
1973
2114
  // sc-7799 — a group name that groups everything defeats the rule: one
1974
2115
  // catch-all fieldset reads exactly like the flat list it replaced.
1975
2116
  const _GENERIC_STYLE_GROUPS = new Set([
@@ -1982,16 +2123,9 @@ const _GENERIC_STYLE_GROUPS = new Set([
1982
2123
  ]);
1983
2124
 
1984
2125
  // The five the basic-style gate mandates; they restyle the widget as a whole.
1985
- // Lockstep with BASIC_STYLE_FIELDS (backend/src/core/services/widget-basic-style.ts)
1986
- // — a mandated field missing here reads as per-element coverage the widget does
1987
- // not have, which silently retires this rule for EVERY generated widget.
1988
- const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
1989
- "background",
1990
- "textColor",
1991
- "align",
1992
- "padding",
1993
- "margin",
1994
- ]);
2126
+ const _WHOLE_WIDGET_STYLE_FIELDS = new Set(
2127
+ Object.keys(CONTRACT.basicStyleSchema),
2128
+ );
1995
2129
 
1996
2130
  // A second element an author would style apart from the text. Pressable and
1997
2131
  // Button are deliberately absent: a touch target wrapping the only label is
@@ -2312,26 +2446,9 @@ function lintSource(source, options) {
2312
2446
  }
2313
2447
  const findings = [];
2314
2448
  const lines = source.split(/\r?\n/);
2315
- // Scan code with comments + string/template text blanked out so a banned
2316
- // identifier only fires on an actual code reference, not on the same word
2317
- // appearing in prose or string data. `codeLines` lines up 1:1 with `lines`
2318
- // (masking preserves newlines), so the reported snippet still comes from
2319
- // the original source.
2320
- const codeLines = _stripNonCode(source).split(/\r?\n/);
2321
- for (let i = 0; i < lines.length; i++) {
2322
- const codeLine = codeLines[i];
2323
- for (const rule of RULES) {
2324
- if (rule.pattern.test(codeLine)) {
2325
- findings.push({
2326
- rule: rule.id,
2327
- severity: "error",
2328
- label: rule.label,
2329
- line: i + 1,
2330
- snippet: lines[i].trim().slice(0, 200),
2331
- });
2332
- }
2333
- }
2334
- }
2449
+ // Comments and string text are masked, so a banned identifier fires only on
2450
+ // code, never on the same word in prose or string data.
2451
+ findings.push(..._bannedApiFindings(source, lines));
2335
2452
  findings.push(
2336
2453
  ..._importRules(source, options && options.manifest).map((f) => ({
2337
2454
  ...f,
@@ -2385,6 +2502,7 @@ function lintSource(source, options) {
2385
2502
  module.exports = {
2386
2503
  lintSource,
2387
2504
  lintStyleWiring,
2505
+ callsBasicStyle,
2388
2506
  lintEditability,
2389
2507
  bannedIdentifiers,
2390
2508
  narrowManifestForFile,
package/dist/linter.js CHANGED
@@ -9,47 +9,151 @@
9
9
  import { CONTRACT, normaliseActionTriggerTypes } from "./contract.js";
10
10
  import { LUCIDE_ICON_NAMES, LUCIDE_VERSION } from "./lucideIconNames.js";
11
11
 
12
- // Per-identifier match rule. Most banned identifiers compile to a
13
- // whole-word match; a few have special syntax (`Function(`, `new Function`,
14
- // `import(`) that needs a tighter regex.
12
+ // Per-identifier match rule, run over the whole masked source. Most banned
13
+ // identifiers compile to a whole-word match.
15
14
  function _ruleForIdentifier(identifier, reason) {
16
15
  const id = identifier;
17
16
  if (id === "Function") {
17
+ // Any reference, so an alias (`const F = Function`) is refused too.
18
18
  return {
19
19
  id: "no-function-constructor",
20
20
  label: `${reason} (banned: Function() constructor)`,
21
- pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
21
+ pattern: /(?<![\w$])Function(?![\w$])/g,
22
+ accept: (code, index) =>
23
+ !_isMemberName(code, index) && _wordBefore(code, index) !== "instanceof",
22
24
  };
23
25
  }
24
26
  if (id === "new Function") {
25
27
  return {
26
28
  id: "no-new-function",
27
29
  label: `${reason} (banned: new Function())`,
28
- pattern: /\bnew\s+Function\s*\(/,
30
+ pattern: /\bnew\s+Function\s*\(/g,
29
31
  };
30
32
  }
31
33
  if (id === "import(") {
32
34
  return {
33
35
  id: "no-dynamic-import",
34
36
  label: `${reason} (banned: dynamic import())`,
35
- pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
37
+ pattern: /(?<![\w$.])import\s*\(/g,
36
38
  };
37
39
  }
38
40
  if (id === "eval") {
41
+ // Any reference, so indirect `(0, eval)(…)` is refused too.
39
42
  return {
40
43
  id: "no-eval",
41
44
  label: `${reason} (banned: eval)`,
42
- pattern: /\beval\s*\(/,
45
+ pattern: /(?<![\w$])eval(?![\w$])/g,
46
+ };
47
+ }
48
+ if (id === ".constructor") {
49
+ return {
50
+ id: "no-constructor-access",
51
+ label: `${reason} (banned: .constructor)`,
52
+ // Also `{ constructor: F } = fn`; a class's `constructor(` passes.
53
+ pattern: /\.\s*constructor(?![\w$])|(?<![\w$.])constructor(?![\w$])(?!\s*\()/g,
54
+ stringPattern: /(["'`])constructor\1/g,
43
55
  };
44
56
  }
45
- // Default: word-boundary anchored. Used for the simple identifier
46
- // forms (`window`, `document`, `process`, ...).
47
57
  const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
- return {
49
- id: `no-${id.toLowerCase()}`,
50
- label: `${reason} (banned: ${id})`,
51
- pattern: new RegExp(`\\b${escaped}\\b`),
52
- };
58
+ const rule = { id: `no-${id.toLowerCase()}`, label: `${reason} (banned: ${id})` };
59
+ if (_GLOBAL_OBJECT_ALIASES.has(id)) {
60
+ // Also a common property name, so only a read of the variable is refused.
61
+ return {
62
+ ...rule,
63
+ pattern: new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "g"),
64
+ accept: (code, index) => _readsVariable(code, index, id.length),
65
+ };
66
+ }
67
+ return { ...rule, pattern: new RegExp(`\\b${escaped}\\b`, "g") };
68
+ }
69
+
70
+ // Other names for the page's global object that are also everyday property
71
+ // names (`insets.top`, `node.parent`).
72
+ const _GLOBAL_OBJECT_ALIASES = new Set(["self", "top", "parent", "frames", "opener"]);
73
+
74
+ // Read-leading keywords `_EXPRESSION_LEAD_WORDS` leaves out.
75
+ const _READ_LEAD_WORDS = new Set(["new", "extends", "default"]);
76
+
77
+ // Index of the last non-space character before `index`, and whether a
78
+ // newline lies between.
79
+ function _tokenBefore(code, index) {
80
+ let at = index - 1;
81
+ let newline = false;
82
+ for (; at >= 0 && /\s/.test(code[at]); at -= 1) {
83
+ if (code[at] === "\n") newline = true;
84
+ }
85
+ return { at, newline };
86
+ }
87
+
88
+ function _wordBefore(code, index) {
89
+ const { at } = _tokenBefore(code, index);
90
+ let start = at + 1;
91
+ while (start > 0 && /[\w$]/.test(code[start - 1])) start -= 1;
92
+ return code.slice(start, at + 1);
93
+ }
94
+
95
+ // `x.name` / `x?.name`, but not a spread (`...name`) and not the decimal point
96
+ // of an integer (`1.` + newline starts a new statement).
97
+ function _isMemberName(code, index) {
98
+ const { at } = _tokenBefore(code, index);
99
+ if (code[at] !== ".") return false;
100
+ if (code[at - 1] === ".") return false;
101
+ return !/(^|[^\w$.])\d[\d_]*$/.test(code.slice(Math.max(0, at - 64), at));
102
+ }
103
+
104
+ // Whether the name at `index` reads its variable. A member name, an object
105
+ // key, a write, and a word after a declaration keyword or JSX text on the
106
+ // same line do not.
107
+ function _readsVariable(code, index, length) {
108
+ if (_isMemberName(code, index)) return false;
109
+ const { at: before, newline } = _tokenBefore(code, index);
110
+ let after = index + length;
111
+ while (after < code.length && /\s/.test(code[after])) after += 1;
112
+ const prev = code[before] || "";
113
+ if (code[after] === ":" && (prev === "{" || prev === ",")) return false;
114
+ if (code[after] === "=" && !/[=>]/.test(code[after + 1] || "")) return false;
115
+ if (newline || !/[\w$]/.test(prev)) return true;
116
+ const word = _wordBefore(code, index);
117
+ return _EXPRESSION_LEAD_WORDS.has(word) || _READ_LEAD_WORDS.has(word);
118
+ }
119
+
120
+ // A `\u` escape spelling an ASCII identifier character (`\u0077indow`).
121
+ const _IDENTIFIER_ESCAPE_RE = /\\u(?:\{0*([0-9a-fA-F]{1,6})\}|([0-9a-fA-F]{4}))/g;
122
+
123
+ function _escapesAsciiIdentifier(code, index) {
124
+ // A fresh regex: matchAll clones the shared one with its lastIndex.
125
+ const at = new RegExp(_IDENTIFIER_ESCAPE_RE.source, "y");
126
+ at.lastIndex = index;
127
+ const m = at.exec(code);
128
+ const ch = String.fromCodePoint(parseInt(m[1] || m[2], 16));
129
+ return /^[\w$]$/.test(ch);
130
+ }
131
+
132
+ // One finding per rule per line, in line order, then rule order. Masking keeps
133
+ // every index, so a match's line is the source's line.
134
+ function _bannedApiFindings(source, lines) {
135
+ const masks = [_stripNonCode(source), _stripNonCode(source, { keepStrings: true })];
136
+ const hits = [];
137
+ RULES.forEach((rule, order) => {
138
+ const ruleLines = new Set();
139
+ [rule.pattern, rule.stringPattern].forEach((pattern, maskIndex) => {
140
+ if (!pattern) return;
141
+ const code = masks[maskIndex];
142
+ const lineAt = _lineCounter(code);
143
+ for (const match of code.matchAll(pattern)) {
144
+ if (!rule.accept || rule.accept(code, match.index)) ruleLines.add(lineAt(match.index));
145
+ }
146
+ });
147
+ for (const line of ruleLines) hits.push({ rule, order, line });
148
+ });
149
+ hits.sort((a, b) => a.line - b.line || a.order - b.order);
150
+ return hits.map(({ rule, line }) => ({
151
+ rule: rule.id,
152
+ severity: "error",
153
+ label: rule.label,
154
+ line,
155
+ snippet: lines[line - 1].trim().slice(0, 200),
156
+ }));
53
157
  }
54
158
 
55
159
  const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
@@ -73,7 +177,14 @@ const EXTRA_RULES = [
73
177
  {
74
178
  id: "no-auth-store-import",
75
179
  label: "widgets must not import the host's auth store",
76
- pattern: /useAuthStore/,
180
+ pattern: /useAuthStore/g,
181
+ },
182
+ {
183
+ id: "no-identifier-escape",
184
+ label:
185
+ "a \\u escape spelling a letter, digit, _ or $ hides a name from the banned-API scan; write the name plainly",
186
+ pattern: _IDENTIFIER_ESCAPE_RE,
187
+ accept: _escapesAsciiIdentifier,
77
188
  },
78
189
  ];
79
190
 
@@ -2009,11 +2120,15 @@ export function lintStyleWiring(manifest, files) {
2009
2120
  const code = sources
2010
2121
  .map((s) => _stripNonCode(s, { keepStrings: true }))
2011
2122
  .join("\n");
2123
+ const appliedByHelper = callsBasicStyle(sources)
2124
+ ? _WHOLE_WIDGET_STYLE_FIELDS
2125
+ : new Set();
2012
2126
  const findings = [];
2013
2127
  for (const field of Object.keys(schema)) {
2014
2128
  // A key that is not a bare identifier cannot be scanned by name; the
2015
2129
  // manifest validator rejects those anyway.
2016
2130
  if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
2131
+ if (appliedByHelper.has(field)) continue;
2017
2132
  if (new RegExp(`\\b${field}\\b`).test(code)) continue;
2018
2133
  findings.push({
2019
2134
  rule: "style-field-unread",
@@ -2024,7 +2139,8 @@ export function lintStyleWiring(manifest, files) {
2024
2139
  `— per instance, and app-wide under Design -> Widget appearance — ` +
2025
2140
  `moves nothing. Read it from props.style and apply it ONLY when ` +
2026
2141
  `set, so the theme still shows through while the author has chosen ` +
2027
- `none. If it is not styleable, drop it from styleSchema instead.`,
2142
+ `none. If it is not styleable, drop it from styleSchema instead.` +
2143
+ (_WHOLE_WIDGET_STYLE_FIELDS.has(field) ? _BASIC_STYLE_FIX : ""),
2028
2144
  line: 0,
2029
2145
  snippet: field,
2030
2146
  });
@@ -2033,6 +2149,26 @@ export function lintStyleWiring(manifest, files) {
2033
2149
  return { ok: !hasErrors, findings };
2034
2150
  }
2035
2151
 
2152
+ const _BASIC_STYLE_CALL = /\bbasicStyle\s*\(/;
2153
+
2154
+ const _BASIC_STYLE_FIX =
2155
+ ` The one-line fix for every basic field: basicStyle(props.style) from ` +
2156
+ `@colixsystems/widget-sdk — spread its .root on the outermost View and ` +
2157
+ `its .text on each Text in a theme text colour.`;
2158
+
2159
+ /**
2160
+ * Whether the bundle calls `basicStyle()`, which applies every basic style
2161
+ * field (`CONTRACT.basicStyleSchema`) in one call.
2162
+ *
2163
+ * @param {object|string[]|string} files bundle sources, as `lintStyleWiring` takes them
2164
+ * @returns {boolean}
2165
+ */
2166
+ export function callsBasicStyle(files) {
2167
+ return _bundleSources(files).some((s) =>
2168
+ _BASIC_STYLE_CALL.test(_stripNonCode(s)),
2169
+ );
2170
+ }
2171
+
2036
2172
  // sc-7799 — a group name that groups everything defeats the rule: one
2037
2173
  // catch-all fieldset reads exactly like the flat list it replaced.
2038
2174
  const _GENERIC_STYLE_GROUPS = new Set([
@@ -2045,16 +2181,9 @@ const _GENERIC_STYLE_GROUPS = new Set([
2045
2181
  ]);
2046
2182
 
2047
2183
  // The five the basic-style gate mandates; they restyle the widget as a whole.
2048
- // Lockstep with BASIC_STYLE_FIELDS (backend/src/core/services/widget-basic-style.ts)
2049
- // — a mandated field missing here reads as per-element coverage the widget does
2050
- // not have, which silently retires this rule for EVERY generated widget.
2051
- const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
2052
- "background",
2053
- "textColor",
2054
- "align",
2055
- "padding",
2056
- "margin",
2057
- ]);
2184
+ const _WHOLE_WIDGET_STYLE_FIELDS = new Set(
2185
+ Object.keys(CONTRACT.basicStyleSchema),
2186
+ );
2058
2187
 
2059
2188
  // A second element an author would style apart from the text. Pressable and
2060
2189
  // Button are deliberately absent: a touch target wrapping the only label is
@@ -2375,26 +2504,9 @@ export function lintSource(source, options) {
2375
2504
  }
2376
2505
  const findings = [];
2377
2506
  const lines = source.split(/\r?\n/);
2378
- // Scan code with comments + string/template text blanked out so a banned
2379
- // identifier only fires on an actual code reference, not on the same word
2380
- // appearing in prose or string data. `codeLines` lines up 1:1 with `lines`
2381
- // (masking preserves newlines), so the reported snippet still comes from
2382
- // the original source.
2383
- const codeLines = _stripNonCode(source).split(/\r?\n/);
2384
- for (let i = 0; i < lines.length; i++) {
2385
- const codeLine = codeLines[i];
2386
- for (const rule of RULES) {
2387
- if (rule.pattern.test(codeLine)) {
2388
- findings.push({
2389
- rule: rule.id,
2390
- severity: "error",
2391
- label: rule.label,
2392
- line: i + 1,
2393
- snippet: lines[i].trim().slice(0, 200),
2394
- });
2395
- }
2396
- }
2397
- }
2507
+ // Comments and string text are masked, so a banned identifier fires only on
2508
+ // code, never on the same word in prose or string data.
2509
+ findings.push(..._bannedApiFindings(source, lines));
2398
2510
  // REQ-WSDK-PLATFORM §3.4: import-specifier validation.
2399
2511
  findings.push(
2400
2512
  ..._importRules(source, options && options.manifest).map((f) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.270.0",
3
+ "version": "0.272.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",
package/src/contract.js CHANGED
@@ -386,6 +386,38 @@ const THEME_SPACING_SCALE = Object.freeze({
386
386
  default: 1,
387
387
  });
388
388
 
389
+ // The style fields every generated widget declares, with the controls the
390
+ // Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
391
+ // `basicStyle()`, the linter, the publish gate and the Mason plan gate.
392
+ const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
393
+ const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
394
+ const BASIC_STYLE_SCHEMA = Object.freeze({
395
+ background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
396
+ textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
397
+ align: Object.freeze({
398
+ type: "select",
399
+ label: "Align",
400
+ enum: Object.freeze([
401
+ Object.freeze({ value: "left", label: "Left" }),
402
+ Object.freeze({ value: "center", label: "Center" }),
403
+ Object.freeze({ value: "right", label: "Right" }),
404
+ ]),
405
+ ui: BASIC_STYLE_GROUP,
406
+ }),
407
+ padding: Object.freeze({
408
+ type: "spacing",
409
+ label: "Padding",
410
+ validation: BASIC_SPACING_RANGE,
411
+ ui: BASIC_STYLE_GROUP,
412
+ }),
413
+ margin: Object.freeze({
414
+ type: "spacing",
415
+ label: "Margin",
416
+ validation: BASIC_SPACING_RANGE,
417
+ ui: BASIC_STYLE_GROUP,
418
+ }),
419
+ });
420
+
389
421
  // The card-surface field names shared by every widget that paints its own card
390
422
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
391
423
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -4505,6 +4537,20 @@ const BANNED_APIS = [
4505
4537
  reason:
4506
4538
  "Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
4507
4539
  },
4540
+ ...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
4541
+ identifier,
4542
+ reason:
4543
+ "Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
4544
+ })),
4545
+ {
4546
+ identifier: "frameElement",
4547
+ reason: "The element framing the page, a route to the host document.",
4548
+ },
4549
+ {
4550
+ identifier: ".constructor",
4551
+ reason:
4552
+ "Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
4553
+ },
4508
4554
  ];
4509
4555
 
4510
4556
  // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
@@ -6505,7 +6551,12 @@ const CONTRACT = deepFreeze({
6505
6551
  // every query, record, figure and aggregate on that table, app-wide.
6506
6552
  // 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
6507
6553
  // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6508
- version: "1.220.0",
6554
+ // 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
6555
+ // `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
6556
+ // 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
6557
+ // `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
6558
+ // `Function` are refused as any reference, not only as a call.
6559
+ version: "1.222.0",
6509
6560
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6510
6561
  hooks: HOOKS,
6511
6562
  primitives: PRIMITIVES,
@@ -6530,6 +6581,7 @@ const CONTRACT = deepFreeze({
6530
6581
  themeSpacingScale: THEME_SPACING_SCALE,
6531
6582
  themeMenuTypes: THEME_MENU_TYPES,
6532
6583
  themeWidgetStyles: THEME_WIDGET_STYLES,
6584
+ basicStyleSchema: BASIC_STYLE_SCHEMA,
6533
6585
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
6534
6586
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
6535
6587
  // sc-10829 — the hook a `badgeCounter: true` entry exports by name.