@colixsystems/widget-sdk 0.228.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,16 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
84
 
85
85
  ## Status
86
86
 
87
- `v0.228.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.
88
97
 
89
98
  ### What's new in 0.228.0 (contract 1.184.0)
90
99
 
@@ -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/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));
@@ -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.228.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",