@colixsystems/widget-sdk 0.227.0 → 0.229.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
@@ -84,7 +84,38 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
84
 
85
85
  ## Status
86
86
 
87
- `v0.227.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**.
87
+ `v0.229.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**.
88
+
89
+ ### What's new in 0.229.0 (contract unchanged)
90
+
91
+ **A field's focus ring and a card's border stay inside the widget.** The hosts clip a widget at its own edge, so anything painted outside it is cut off.
92
+
93
+ - **`<TextField>` and the `<Select>` trigger draw focus inside their own box.** A focused field shows a 2px ring in the accent: its border plus an inset 1px `boxShadow`, in the error colour while the field is invalid. On the web it turns the browser's outer focus outline off, because that outline sat outside the field and was cut off wherever the field touched the widget's edge. `<SearchField>` and the built-in form fields use the same field, so they get it too. The text field also takes `minWidth: 0`, so it shrinks in a row instead of pushing its neighbour past the edge. A caller's `style` still comes last.
94
+ - **New linter rule `full-width-margin-overflow` (severity `warning`, non-blocking).** It flags a style object that sets `width: "100%"` together with a non-zero side margin (`margin`, `marginHorizontal`, `marginLeft`/`Right`/`Start`/`End`, `marginInline`/`InlineStart`/`InlineEnd`; a zero, `undefined` or `null` value is not counted). The percentage is already the parent's whole width, so the margin pushes the box past its parent and the host cuts off its right edge, border included. Drop the width, because a `View` already stretches, and inset with the parent's `padding`. The scan reads one style literal, not the tree, so it stays a warning. Comments are not scanned.
95
+
96
+ `CONTRACT` is unchanged (no new field), and no export changed signature.
97
+
98
+ ### What's new in 0.228.0 (contract 1.184.0)
99
+
100
+ **A widget can ask for the whole screen — manifest `prefersFitToScreen` and `useFitScreen()`.** A page section can be set to "Fit to screen": it runs edge to edge across the app's content area (no page margins, no width cap) and takes the rest of the screen below the sections above it, on every screen size, in the Player and in the exported app. Set `prefersFitToScreen: true` in your manifest when your widget is built for that — it scrolls its own content inside the height it is given and keeps its controls in view, like a messenger, a map or a full-height board. In a fit section the host then gives your widget a box exactly the height left; a widget without the field grows with its content there, and the page scrolls. A maker who drops your widget at the top level of a page then gets the setting turned on for that section (they can switch it off, and it stays off), and the AppStudio app-builder fits the section it plans your widget into. Leave it `false` or omit it otherwise.
101
+
102
+ When your widget is that section's surface, `useFill()` is `true` and so is the new `useFitScreen()`. Drop your own frame then — border, rounded corners, shadow — so the widget meets the screen's edges cleanly; the host has already removed your outer margin.
103
+
104
+ ```jsx
105
+ import { useFill, useFitScreen, View } from "@colixsystems/widget-sdk";
106
+
107
+ export default function Board() {
108
+ const fill = useFill();
109
+ const edgeToEdge = useFitScreen();
110
+ return (
111
+ <View style={[fill ? { flex: 1, minHeight: 0 } : { height: 480 }, !edgeToEdge && { borderWidth: 1, borderRadius: 12 }]}>
112
+ {/* ... */}
113
+ </View>
114
+ );
115
+ }
116
+ ```
117
+
118
+ Additive: existing manifests omit the field and read `false`, and a host that never fits a widget leaves both hooks `false`. A published widget that must also run in an app whose Player is older than 0.228.0 reads the hook off a namespace import and treats a missing one as `false` (`import * as sdk from "@colixsystems/widget-sdk"; const useFitScreen = typeof sdk.useFitScreen === "function" ? sdk.useFitScreen : () => false;`, at module level): a named import of an export the host does not have stops the widget loading.
88
119
 
89
120
  ### What's new in 0.227.0 (contract 1.183.0)
90
121
 
package/dist/contract.cjs CHANGED
@@ -991,6 +991,24 @@ const HOOKS = [
991
991
  requiredContextSlice: ["fill"],
992
992
  scopes: null,
993
993
  },
994
+ {
995
+ name: "useFitScreen",
996
+ signature: "useFitScreen()",
997
+ description:
998
+ "Returns true when this widget is the surface of a page section set to " +
999
+ "\"Fit to screen\": the section runs edge to edge across the app's " +
1000
+ "content area and takes the rest of the screen, so the widget should " +
1001
+ "drop its own frame (border, rounded corners, shadow) and meet the " +
1002
+ "edges cleanly. useFill() is true as well then, and the host has already " +
1003
+ "removed the widget's outer margin. Defaults to false wherever the host " +
1004
+ "has not fitted the widget, so calling it is always safe. The SAME value " +
1005
+ "is injected by the web Player and the native export.",
1006
+ returnShape: {
1007
+ "(returns)": "boolean",
1008
+ },
1009
+ requiredContextSlice: ["fitScreen"],
1010
+ scopes: null,
1011
+ },
994
1012
  {
995
1013
  name: "useChildRenderer",
996
1014
  signature: "useChildRenderer()",
@@ -3572,6 +3590,13 @@ const MANIFEST_SCHEMA = {
3572
3590
  "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3573
3591
  default: false,
3574
3592
  },
3593
+ prefersFitToScreen: {
3594
+ type: "boolean",
3595
+ required: false,
3596
+ description:
3597
+ "Optional (default false). TRUE when the widget is built to own the rest of the screen: it scrolls its own content and keeps its controls in view, like a messenger, a map or a full-height board. Dropping the widget at the top level of a page in the Builder turns that section's \"Fit to screen\" setting on (the maker can switch it off, and it stays off), and the app-builder fits the section it plans the widget into. A fit section runs edge to edge across the content area and takes the rest of the screen; the widget learns it through useFill() and useFitScreen(), and the host gives it a box exactly the height left, which it scrolls inside (a widget without the field grows with its content there, and the page scrolls). Set it only for a widget that honours useFill() and scrolls inside the height it is given; leave it false (or omit) for everything else.",
3598
+ default: false,
3599
+ },
3575
3600
  events: {
3576
3601
  type: "object[]",
3577
3602
  required: true,
@@ -3647,6 +3672,16 @@ const WIDGET_CONTEXT_SHAPE = {
3647
3672
  required: false,
3648
3673
  fields: {},
3649
3674
  },
3675
+ // sc-11348 — optional host hint backing useFitScreen(): the widget is the
3676
+ // surface of a section set to Fit to screen, so it drops its own frame.
3677
+ fitScreen: {
3678
+ description:
3679
+ "Optional host layout hint (boolean). True when this widget is the " +
3680
+ "surface of a page section set to Fit to screen: edge to edge, the rest " +
3681
+ "of the screen. Backs useFitScreen(); defaults to false when absent.",
3682
+ required: false,
3683
+ fields: {},
3684
+ },
3650
3685
  user: {
3651
3686
  description:
3652
3687
  "Signed-in user, host-built context object (camelCase: { id, email, displayName, photoUrl, roles, groupIds }) — NOT a wire payload, so it is the one camelCase island among the hooks. photoUrl is the user's own profile photo as an absolute URL, or null. email is null for an account created without one. Not a data-client.",
@@ -5938,7 +5973,12 @@ const CONTRACT = deepFreeze({
5938
5973
  // `copyOf: { via, column }` (a server-kept copy read through one), and
5939
5974
  // a table may declare `constraints` (`unique` with `unlessRelated`,
5940
5975
  // `no-overlap`, `related-open`, `related-index`).
5941
- version: "1.183.0",
5976
+ // 1.184.0: additive (sc-11348) — OPTIONAL manifest field
5977
+ // `prefersFitToScreen` (dropping the widget at a page's top level turns
5978
+ // that section's Fit to screen on), and `useFitScreen()` with its
5979
+ // optional `fitScreen` host hint: the widget is a fit section's surface,
5980
+ // so it drops its own frame. Existing manifests and hosts read false.
5981
+ version: "1.184.0",
5942
5982
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5943
5983
  hooks: HOOKS,
5944
5984
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -991,6 +991,24 @@ const HOOKS = [
991
991
  requiredContextSlice: ["fill"],
992
992
  scopes: null,
993
993
  },
994
+ {
995
+ name: "useFitScreen",
996
+ signature: "useFitScreen()",
997
+ description:
998
+ "Returns true when this widget is the surface of a page section set to " +
999
+ "\"Fit to screen\": the section runs edge to edge across the app's " +
1000
+ "content area and takes the rest of the screen, so the widget should " +
1001
+ "drop its own frame (border, rounded corners, shadow) and meet the " +
1002
+ "edges cleanly. useFill() is true as well then, and the host has already " +
1003
+ "removed the widget's outer margin. Defaults to false wherever the host " +
1004
+ "has not fitted the widget, so calling it is always safe. The SAME value " +
1005
+ "is injected by the web Player and the native export.",
1006
+ returnShape: {
1007
+ "(returns)": "boolean",
1008
+ },
1009
+ requiredContextSlice: ["fitScreen"],
1010
+ scopes: null,
1011
+ },
994
1012
  {
995
1013
  name: "useChildRenderer",
996
1014
  signature: "useChildRenderer()",
@@ -3568,6 +3586,13 @@ const MANIFEST_SCHEMA = {
3568
3586
  "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3569
3587
  default: false,
3570
3588
  },
3589
+ prefersFitToScreen: {
3590
+ type: "boolean",
3591
+ required: false,
3592
+ description:
3593
+ "Optional (default false). TRUE when the widget is built to own the rest of the screen: it scrolls its own content and keeps its controls in view, like a messenger, a map or a full-height board. Dropping the widget at the top level of a page in the Builder turns that section's \"Fit to screen\" setting on (the maker can switch it off, and it stays off), and the app-builder fits the section it plans the widget into. A fit section runs edge to edge across the content area and takes the rest of the screen; the widget learns it through useFill() and useFitScreen(), and the host gives it a box exactly the height left, which it scrolls inside (a widget without the field grows with its content there, and the page scrolls). Set it only for a widget that honours useFill() and scrolls inside the height it is given; leave it false (or omit) for everything else.",
3594
+ default: false,
3595
+ },
3571
3596
  events: {
3572
3597
  type: "object[]",
3573
3598
  required: true,
@@ -3643,6 +3668,16 @@ const WIDGET_CONTEXT_SHAPE = {
3643
3668
  required: false,
3644
3669
  fields: {},
3645
3670
  },
3671
+ // sc-11348 — optional host hint backing useFitScreen(): the widget is the
3672
+ // surface of a section set to Fit to screen, so it drops its own frame.
3673
+ fitScreen: {
3674
+ description:
3675
+ "Optional host layout hint (boolean). True when this widget is the " +
3676
+ "surface of a page section set to Fit to screen: edge to edge, the rest " +
3677
+ "of the screen. Backs useFitScreen(); defaults to false when absent.",
3678
+ required: false,
3679
+ fields: {},
3680
+ },
3646
3681
  user: {
3647
3682
  description:
3648
3683
  "Signed-in user, host-built context object (camelCase: { id, email, displayName, photoUrl, roles, groupIds }) — NOT a wire payload, so it is the one camelCase island among the hooks. photoUrl is the user's own profile photo as an absolute URL, or null. email is null for an account created without one. Not a data-client.",
@@ -5934,7 +5969,12 @@ const CONTRACT = deepFreeze({
5934
5969
  // `copyOf: { via, column }` (a server-kept copy read through one), and
5935
5970
  // a table may declare `constraints` (`unique` with `unlessRelated`,
5936
5971
  // `no-overlap`, `related-open`, `related-index`).
5937
- version: "1.183.0",
5972
+ // 1.184.0: additive (sc-11348) — OPTIONAL manifest field
5973
+ // `prefersFitToScreen` (dropping the widget at a page's top level turns
5974
+ // that section's Fit to screen on), and `useFitScreen()` with its
5975
+ // optional `fitScreen` host hint: the widget is a fit section's surface,
5976
+ // so it drops its own frame. Existing manifests and hosts read false.
5977
+ version: "1.184.0",
5938
5978
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5939
5979
  hooks: HOOKS,
5940
5980
  primitives: PRIMITIVES,
@@ -71,6 +71,26 @@ export function focusHaloStyle(t, focused, radius) {
71
71
  };
72
72
  }
73
73
 
74
+ // Web: a field shell draws its own ring, so the host's outer one stays off.
75
+ export const FIELD_OUTLINE_OFF = Object.freeze({ outlineColor: "transparent" });
76
+
77
+ /**
78
+ * A field shell's state border (`<TextField>`, a `<Select>` trigger). Focus is
79
+ * the border plus an inset 1px ring, 2px in all and inside the box, so a parent
80
+ * clipping at the field's edge cannot cut it. An invalid field rings in the
81
+ * error colour while it is being fixed.
82
+ *
83
+ * @param {object} t — resolved form-control tokens.
84
+ * @param {{focused: boolean, invalid: boolean}} state
85
+ */
86
+ export function fieldStateStyle(t, { focused, invalid }) {
87
+ if (focused) {
88
+ const color = invalid ? t.errorColor : t.focusColor;
89
+ return { borderColor: color, boxShadow: `inset 0 0 0 1px ${color}` };
90
+ }
91
+ return invalid ? { borderColor: t.errorColor } : null;
92
+ }
93
+
74
94
  /**
75
95
  * The colours a switch hands the platform control. A caller's `trackColor`
76
96
  * overrides the theme per state, and iOS, which paints the off track from
package/dist/hooks.js CHANGED
@@ -488,6 +488,19 @@ export function useFill() {
488
488
  return ctx.fill === true;
489
489
  }
490
490
 
491
+ /**
492
+ * Returns `true` when this widget is the surface of a page section set to
493
+ * "Fit to screen": the section runs edge to edge across the app's content
494
+ * area and takes the rest of the screen. Drop the widget's own frame then —
495
+ * border, rounded corners, shadow — so it meets the edges cleanly; `useFill()`
496
+ * is `true` as well, and the host has already removed the outer margin.
497
+ * Defaults to `false`, and both hosts inject the same value.
498
+ */
499
+ export function useFitScreen() {
500
+ const ctx = useWidgetContextOrThrow("useFitScreen");
501
+ return ctx.fitScreen === true;
502
+ }
503
+
491
504
  /**
492
505
  * Returns the host's child-node renderer:
493
506
  * { renderNode(node) }.
package/dist/index.d.ts CHANGED
@@ -462,6 +462,13 @@ export interface WidgetManifest {
462
462
  * while the widget's page is closed so its menu badge stays live.
463
463
  */
464
464
  badgeCounter?: boolean;
465
+ /**
466
+ * Optional (sc-11348, default false). True when the widget owns the rest of
467
+ * the screen (a messenger, a map): dropping it at the top level of a page
468
+ * turns that section's "Fit to screen" on. Honour `useFill()` and
469
+ * `useFitScreen()` with it.
470
+ */
471
+ prefersFitToScreen?: boolean;
465
472
  }
466
473
 
467
474
  /**
@@ -786,6 +793,12 @@ export interface WidgetContext<TProps = unknown> {
786
793
  * Absent / `false` everywhere the host has not opted the widget into filling.
787
794
  */
788
795
  fill?: boolean;
796
+ /**
797
+ * sc-11348 — optional host hint backing `useFitScreen()`: `true` when this
798
+ * widget is the surface of a section set to Fit to screen (edge to edge,
799
+ * the rest of the screen). Absent / `false` everywhere else.
800
+ */
801
+ fitScreen?: boolean;
789
802
  /** Active end-user identity from the host-built context (camelCase, not a wire payload). `id` is null when anonymous. */
790
803
  user: {
791
804
  id: string | null;
@@ -2139,6 +2152,14 @@ export function useAnonymousAccount(): AnonymousAccountApi;
2139
2152
  */
2140
2153
  export function useFill(): boolean;
2141
2154
 
2155
+ /**
2156
+ * sc-11348 — returns `true` when this widget is the surface of a page section
2157
+ * set to "Fit to screen": it runs edge to edge and takes the rest of the
2158
+ * screen, so drop your own frame (border, rounded corners, shadow).
2159
+ * `useFill()` is `true` too. Defaults to `false`; the same on web and native.
2160
+ */
2161
+ export function useFitScreen(): boolean;
2162
+
2142
2163
  /**
2143
2164
  * The host-provided navigation surface. `goTo(pageId, params?)` navigates
2144
2165
  * to an internal app page; `goBack()` pops the stack. `goToStartPage()`
package/dist/index.js CHANGED
@@ -80,6 +80,7 @@ export {
80
80
  useUserPhoto,
81
81
  useAnonymousAccount,
82
82
  useFill,
83
+ useFitScreen,
83
84
  useNavigation,
84
85
  useRouteParams,
85
86
  usePageBadge,
@@ -80,6 +80,7 @@ export {
80
80
  useUserPhoto,
81
81
  useAnonymousAccount,
82
82
  useFill,
83
+ useFitScreen,
83
84
  useNavigation,
84
85
  useRouteParams,
85
86
  usePageBadge,
package/dist/linter.cjs CHANGED
@@ -1477,6 +1477,92 @@ function _flexBasisPercentRules(source) {
1477
1477
  return findings;
1478
1478
  }
1479
1479
 
1480
+ // sc-11277 — `width: "100%"` plus a side margin is wider than its parent.
1481
+ //
1482
+ // The percentage is the parent's whole content width and the margin is added
1483
+ // outside it, so the box overflows by the margin. The hosts clip a widget at
1484
+ // its own edge (the Player's sideways guard, a fixed-height slot), which cuts
1485
+ // off the right edge of a bordered card or input. A `View` already stretches
1486
+ // across a column, so the width is redundant; the inset belongs in the
1487
+ // parent's padding.
1488
+ //
1489
+ // A warning: the scan reads one style literal, not the tree, so a row parent
1490
+ // that absorbs the excess still matches.
1491
+ const _FULL_WIDTH_RE = /\bwidth\s*:\s*(["'`])100%\1/g;
1492
+ const _SIDE_MARGIN_KEY_RE =
1493
+ /\bmargin(?:Horizontal|Left|Right|Start|End|Inline(?:Start|End)?)?\s*:/g;
1494
+ const _ZERO_VALUE_RE =
1495
+ /^\s*(?:(["'`]?)-?0(?:\.0*)?(?:px)?\1\s*(?:[,}]|$)|(?:undefined|null)\b)/m;
1496
+
1497
+ // The innermost `{ … }` around `at`, as [start, end); null outside any.
1498
+ function _enclosingObject(code, at) {
1499
+ let start = -1;
1500
+ for (let i = at - 1, depth = 0; i >= 0; i--) {
1501
+ if (code[i] === "}") depth++;
1502
+ else if (code[i] === "{" && depth-- === 0) {
1503
+ start = i;
1504
+ break;
1505
+ }
1506
+ }
1507
+ if (start < 0) return null;
1508
+ for (let j = at, depth = 0; j < code.length; j++) {
1509
+ if (code[j] === "{") depth++;
1510
+ else if (code[j] === "}" && depth-- === 0) return [start, j + 1];
1511
+ }
1512
+ return null;
1513
+ }
1514
+
1515
+ // The object's own keys only: a nested object's margin is not this box's.
1516
+ // Blanked rather than cut, so an offset here is the same offset in the source.
1517
+ function _ownKeysMask(structure, [start, end]) {
1518
+ let body = structure.slice(start, end);
1519
+ let previous;
1520
+ do {
1521
+ previous = body;
1522
+ body = body.replace(/(?<!^)\{[^{}]*\}(?!$)/g, (nested) => " ".repeat(nested.length));
1523
+ } while (body !== previous);
1524
+ return body;
1525
+ }
1526
+
1527
+ function _hasSideMargin(code, structure, span) {
1528
+ const keys = _ownKeysMask(structure, span);
1529
+ _SIDE_MARGIN_KEY_RE.lastIndex = 0;
1530
+ let key;
1531
+ while ((key = _SIDE_MARGIN_KEY_RE.exec(keys))) {
1532
+ const valueAt = span[0] + key.index + key[0].length;
1533
+ if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) return true;
1534
+ }
1535
+ return false;
1536
+ }
1537
+
1538
+ function _fullWidthMarginRules(source) {
1539
+ const findings = [];
1540
+ const code = _stripNonCode(source, { keepStrings: true });
1541
+ // Strings blanked, so a brace inside a literal never unbalances the match.
1542
+ const structure = _stripNonCode(source);
1543
+ const sourceLines = source.split(/\r?\n/);
1544
+ _FULL_WIDTH_RE.lastIndex = 0;
1545
+ let hit;
1546
+ while ((hit = _FULL_WIDTH_RE.exec(code))) {
1547
+ const span = _enclosingObject(structure, hit.index);
1548
+ if (!span) continue;
1549
+ if (!_hasSideMargin(code, structure, span)) continue;
1550
+ const line = code.slice(0, hit.index).split(/\r?\n/).length;
1551
+ findings.push({
1552
+ rule: "full-width-margin-overflow",
1553
+ severity: "warning",
1554
+ label:
1555
+ `width: "100%" plus a side margin is wider than the parent by that ` +
1556
+ `margin, so the host clips the box's right edge and its border. Drop ` +
1557
+ `the width (a View already stretches) or move the inset to the ` +
1558
+ `parent's padding.`,
1559
+ line,
1560
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
1561
+ });
1562
+ }
1563
+ return findings;
1564
+ }
1565
+
1480
1566
  // sc-4913 — a measured frame INCLUDES the element's own padding, so cells sized
1481
1567
  // from it overflow the content box and the last one wraps into an empty column.
1482
1568
  // Mirror of linter.js (see there for the full rationale).
@@ -2100,6 +2186,8 @@ function lintSource(source, options) {
2100
2186
  // sc-7274 — soft warning: a percentage flexBasis sizes the main axis, so in a
2101
2187
  // column it claims the parent's whole height and overflows.
2102
2188
  findings.push(..._flexBasisPercentRules(source));
2189
+ // sc-11277 — soft warning: a full-width box with a side margin is clipped.
2190
+ findings.push(..._fullWidthMarginRules(source));
2103
2191
  // sc-4913 — soft warning: a measured width that includes the widget's own
2104
2192
  // padding wraps the last grid column into an empty one.
2105
2193
  findings.push(..._measuredPaddingRules(source));
package/dist/linter.js CHANGED
@@ -1518,6 +1518,92 @@ function _flexBasisPercentRules(source) {
1518
1518
  return findings;
1519
1519
  }
1520
1520
 
1521
+ // sc-11277 — `width: "100%"` plus a side margin is wider than its parent.
1522
+ //
1523
+ // The percentage is the parent's whole content width and the margin is added
1524
+ // outside it, so the box overflows by the margin. The hosts clip a widget at
1525
+ // its own edge (the Player's sideways guard, a fixed-height slot), which cuts
1526
+ // off the right edge of a bordered card or input. A `View` already stretches
1527
+ // across a column, so the width is redundant; the inset belongs in the
1528
+ // parent's padding.
1529
+ //
1530
+ // A warning: the scan reads one style literal, not the tree, so a row parent
1531
+ // that absorbs the excess still matches.
1532
+ const _FULL_WIDTH_RE = /\bwidth\s*:\s*(["'`])100%\1/g;
1533
+ const _SIDE_MARGIN_KEY_RE =
1534
+ /\bmargin(?:Horizontal|Left|Right|Start|End|Inline(?:Start|End)?)?\s*:/g;
1535
+ const _ZERO_VALUE_RE =
1536
+ /^\s*(?:(["'`]?)-?0(?:\.0*)?(?:px)?\1\s*(?:[,}]|$)|(?:undefined|null)\b)/m;
1537
+
1538
+ // The innermost `{ … }` around `at`, as [start, end); null outside any.
1539
+ function _enclosingObject(code, at) {
1540
+ let start = -1;
1541
+ for (let i = at - 1, depth = 0; i >= 0; i--) {
1542
+ if (code[i] === "}") depth++;
1543
+ else if (code[i] === "{" && depth-- === 0) {
1544
+ start = i;
1545
+ break;
1546
+ }
1547
+ }
1548
+ if (start < 0) return null;
1549
+ for (let j = at, depth = 0; j < code.length; j++) {
1550
+ if (code[j] === "{") depth++;
1551
+ else if (code[j] === "}" && depth-- === 0) return [start, j + 1];
1552
+ }
1553
+ return null;
1554
+ }
1555
+
1556
+ // The object's own keys only: a nested object's margin is not this box's.
1557
+ // Blanked rather than cut, so an offset here is the same offset in the source.
1558
+ function _ownKeysMask(structure, [start, end]) {
1559
+ let body = structure.slice(start, end);
1560
+ let previous;
1561
+ do {
1562
+ previous = body;
1563
+ body = body.replace(/(?<!^)\{[^{}]*\}(?!$)/g, (nested) => " ".repeat(nested.length));
1564
+ } while (body !== previous);
1565
+ return body;
1566
+ }
1567
+
1568
+ function _hasSideMargin(code, structure, span) {
1569
+ const keys = _ownKeysMask(structure, span);
1570
+ _SIDE_MARGIN_KEY_RE.lastIndex = 0;
1571
+ let key;
1572
+ while ((key = _SIDE_MARGIN_KEY_RE.exec(keys))) {
1573
+ const valueAt = span[0] + key.index + key[0].length;
1574
+ if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) return true;
1575
+ }
1576
+ return false;
1577
+ }
1578
+
1579
+ function _fullWidthMarginRules(source) {
1580
+ const findings = [];
1581
+ const code = _stripNonCode(source, { keepStrings: true });
1582
+ // Strings blanked, so a brace inside a literal never unbalances the match.
1583
+ const structure = _stripNonCode(source);
1584
+ const sourceLines = source.split(/\r?\n/);
1585
+ _FULL_WIDTH_RE.lastIndex = 0;
1586
+ let hit;
1587
+ while ((hit = _FULL_WIDTH_RE.exec(code))) {
1588
+ const span = _enclosingObject(structure, hit.index);
1589
+ if (!span) continue;
1590
+ if (!_hasSideMargin(code, structure, span)) continue;
1591
+ const line = code.slice(0, hit.index).split(/\r?\n/).length;
1592
+ findings.push({
1593
+ rule: "full-width-margin-overflow",
1594
+ severity: "warning",
1595
+ label:
1596
+ `width: "100%" plus a side margin is wider than the parent by that ` +
1597
+ `margin, so the host clips the box's right edge and its border. Drop ` +
1598
+ `the width (a View already stretches) or move the inset to the ` +
1599
+ `parent's padding.`,
1600
+ line,
1601
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
1602
+ });
1603
+ }
1604
+ return findings;
1605
+ }
1606
+
1521
1607
  // sc-4913 — a measured frame INCLUDES the element's own padding.
1522
1608
  //
1523
1609
  // `onLayout` reports the frame width, and padding sits inside that frame. A
@@ -2168,6 +2254,8 @@ export function lintSource(source, options) {
2168
2254
  // sc-7274 — soft warning: a percentage flexBasis sizes the main axis, so in a
2169
2255
  // column it claims the parent's whole height and overflows.
2170
2256
  findings.push(..._flexBasisPercentRules(source));
2257
+ // sc-11277 — soft warning: a full-width box with a side margin is clipped.
2258
+ findings.push(..._fullWidthMarginRules(source));
2171
2259
  // sc-4913 — soft warning: a measured width that includes the widget's own
2172
2260
  // padding wraps the last grid column into an empty one.
2173
2261
  findings.push(..._measuredPaddingRules(source));
package/dist/manifest.cjs CHANGED
@@ -331,6 +331,12 @@ function validateManifest(m) {
331
331
  "manifest.badgeCounter must be a boolean when present",
332
332
  );
333
333
 
334
+ pushIf(
335
+ errors,
336
+ manifest.prefersFitToScreen === undefined || typeof manifest.prefersFitToScreen === "boolean",
337
+ "manifest.prefersFitToScreen must be a boolean when present",
338
+ );
339
+
334
340
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
335
341
  }
336
342
 
package/dist/manifest.js CHANGED
@@ -377,6 +377,12 @@ function validateManifest(m) {
377
377
  "manifest.badgeCounter must be a boolean when present",
378
378
  );
379
379
 
380
+ pushIf(
381
+ errors,
382
+ manifest.prefersFitToScreen === undefined || typeof manifest.prefersFitToScreen === "boolean",
383
+ "manifest.prefersFitToScreen must be a boolean when present",
384
+ );
385
+
380
386
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
381
387
  }
382
388
 
@@ -13,6 +13,8 @@ import { Icon } from "./icon.js";
13
13
  import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
14
14
  import {
15
15
  CONTROL_MIN_TARGET,
16
+ FIELD_OUTLINE_OFF,
17
+ fieldStateStyle,
16
18
  nextOptionIndex,
17
19
  resolveFormControlTokens,
18
20
  selectedOption,
@@ -253,8 +255,8 @@ export function makeSelect(rn) {
253
255
  borderRadius: t.radius,
254
256
  backgroundColor: t.background,
255
257
  },
256
- focused ? { borderColor: t.focusColor } : null,
257
- invalid ? { borderColor: t.errorColor } : null,
258
+ FIELD_OUTLINE_OFF,
259
+ fieldStateStyle(t, { focused, invalid }),
258
260
  style,
259
261
  ],
260
262
  },
@@ -6,7 +6,11 @@ import React from "react";
6
6
  import { useHostTheme } from "./hooks.js";
7
7
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
8
  import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
9
- import { resolveFormControlTokens } from "./form-control-tokens.js";
9
+ import {
10
+ FIELD_OUTLINE_OFF,
11
+ fieldStateStyle,
12
+ resolveFormControlTokens,
13
+ } from "./form-control-tokens.js";
10
14
  import {
11
15
  errorMessage,
12
16
  hasNote,
@@ -45,7 +49,6 @@ export function makeTextField(rn) {
45
49
  ...others
46
50
  } = props;
47
51
  const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
48
- // Native draws no focus ring, so the border itself shows focus.
49
52
  const [focused, setFocused] = React.useState(false);
50
53
  const overrides = {};
51
54
  const rest = {};
@@ -80,10 +83,11 @@ export function makeTextField(rn) {
80
83
  color: t.textColor,
81
84
  fontFamily: t.fontFamily,
82
85
  fontSize: t.fontSize,
86
+ // An input will not shrink below its text width in a row otherwise.
87
+ minWidth: 0,
83
88
  },
84
- focused ? { borderColor: t.focusColor } : null,
85
- // After focus: an invalid field stays marked while it is being fixed.
86
- invalid ? { borderColor: t.errorColor } : null,
89
+ FIELD_OUTLINE_OFF,
90
+ fieldStateStyle(t, { focused, invalid }),
87
91
  // The frame dims a framed field as a whole.
88
92
  disabled && !framed ? { opacity: t.disabledOpacity } : null,
89
93
  style,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.227.0",
3
+ "version": "0.229.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
@@ -991,6 +991,24 @@ const HOOKS = [
991
991
  requiredContextSlice: ["fill"],
992
992
  scopes: null,
993
993
  },
994
+ {
995
+ name: "useFitScreen",
996
+ signature: "useFitScreen()",
997
+ description:
998
+ "Returns true when this widget is the surface of a page section set to " +
999
+ "\"Fit to screen\": the section runs edge to edge across the app's " +
1000
+ "content area and takes the rest of the screen, so the widget should " +
1001
+ "drop its own frame (border, rounded corners, shadow) and meet the " +
1002
+ "edges cleanly. useFill() is true as well then, and the host has already " +
1003
+ "removed the widget's outer margin. Defaults to false wherever the host " +
1004
+ "has not fitted the widget, so calling it is always safe. The SAME value " +
1005
+ "is injected by the web Player and the native export.",
1006
+ returnShape: {
1007
+ "(returns)": "boolean",
1008
+ },
1009
+ requiredContextSlice: ["fitScreen"],
1010
+ scopes: null,
1011
+ },
994
1012
  {
995
1013
  name: "useChildRenderer",
996
1014
  signature: "useChildRenderer()",
@@ -3568,6 +3586,13 @@ const MANIFEST_SCHEMA = {
3568
3586
  "Optional (default false). TRUE when every platform entry also exports useBadgeCount(props): a hook returning the count the widget puts on its page's menu entry with usePageBadge, so the app keeps that badge live while the page is closed. The host runs counters for installed marketplace widgets (the workspace's own published widgets included); the platform's built-in widgets declare none. It calls the hook with the instance's resolved props, inside the widget's own context; the hook returns a whole number, or null while the count is not known yet. It only reads, and the host enforces that: the counter's datastore and directory expose only their read methods (plus realtime subscriptions) and notification mutes only get/list, while writes, typing signals, payments, notifications.send, files, assets, AI agents, translation, link previews, device capabilities, identification and account actions all answer as unavailable. It also renders nothing, navigates nowhere, emits no events or toasts, marks nothing read, and calls none of usePageBadge, useScreenTitle, useSectionEmpty or useWidgetRoute's setter. The linter refuses a declared counter whose entry has no useBadgeCount export. The AppStudio widget agent leaves this unset: a generated widget never declares a badge counter.",
3569
3587
  default: false,
3570
3588
  },
3589
+ prefersFitToScreen: {
3590
+ type: "boolean",
3591
+ required: false,
3592
+ description:
3593
+ "Optional (default false). TRUE when the widget is built to own the rest of the screen: it scrolls its own content and keeps its controls in view, like a messenger, a map or a full-height board. Dropping the widget at the top level of a page in the Builder turns that section's \"Fit to screen\" setting on (the maker can switch it off, and it stays off), and the app-builder fits the section it plans the widget into. A fit section runs edge to edge across the content area and takes the rest of the screen; the widget learns it through useFill() and useFitScreen(), and the host gives it a box exactly the height left, which it scrolls inside (a widget without the field grows with its content there, and the page scrolls). Set it only for a widget that honours useFill() and scrolls inside the height it is given; leave it false (or omit) for everything else.",
3594
+ default: false,
3595
+ },
3571
3596
  events: {
3572
3597
  type: "object[]",
3573
3598
  required: true,
@@ -3643,6 +3668,16 @@ const WIDGET_CONTEXT_SHAPE = {
3643
3668
  required: false,
3644
3669
  fields: {},
3645
3670
  },
3671
+ // sc-11348 — optional host hint backing useFitScreen(): the widget is the
3672
+ // surface of a section set to Fit to screen, so it drops its own frame.
3673
+ fitScreen: {
3674
+ description:
3675
+ "Optional host layout hint (boolean). True when this widget is the " +
3676
+ "surface of a page section set to Fit to screen: edge to edge, the rest " +
3677
+ "of the screen. Backs useFitScreen(); defaults to false when absent.",
3678
+ required: false,
3679
+ fields: {},
3680
+ },
3646
3681
  user: {
3647
3682
  description:
3648
3683
  "Signed-in user, host-built context object (camelCase: { id, email, displayName, photoUrl, roles, groupIds }) — NOT a wire payload, so it is the one camelCase island among the hooks. photoUrl is the user's own profile photo as an absolute URL, or null. email is null for an account created without one. Not a data-client.",
@@ -5934,7 +5969,12 @@ const CONTRACT = deepFreeze({
5934
5969
  // `copyOf: { via, column }` (a server-kept copy read through one), and
5935
5970
  // a table may declare `constraints` (`unique` with `unlessRelated`,
5936
5971
  // `no-overlap`, `related-open`, `related-index`).
5937
- version: "1.183.0",
5972
+ // 1.184.0: additive (sc-11348) — OPTIONAL manifest field
5973
+ // `prefersFitToScreen` (dropping the widget at a page's top level turns
5974
+ // that section's Fit to screen on), and `useFitScreen()` with its
5975
+ // optional `fitScreen` host hint: the widget is a fit section's surface,
5976
+ // so it drops its own frame. Existing manifests and hosts read false.
5977
+ version: "1.184.0",
5938
5978
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5939
5979
  hooks: HOOKS,
5940
5980
  primitives: PRIMITIVES,