@colixsystems/widget-sdk 0.139.0 → 0.141.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
@@ -70,7 +70,27 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
70
70
 
71
71
  ## Status
72
72
 
73
- `v0.137.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**.
73
+ `v0.141.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**.
74
+
75
+ ### What's new in 0.141.0 (contract 1.109.0)
76
+
77
+ **The app's global navigation is the sidebar alone — `top-bar` and `bottom-tabs` leave `CONTRACT.themeMenuTypes` (sc-7352, REQ-NAV-STRUCTURE).** A top bar is now a PAGE's local bar drawing a saved menu (REQ-NAV-LOCAL, sc-7103): the same tab row the `top-bar` shape used to draw, themed by the same `topBar` tokens (`resolveTopBarTokens` is unchanged), worn by one page under the sidebar. Two "top bars" competing for one word and one token set was the reason.
78
+
79
+ - `normaliseNavigation` resolves a stored `top-bar` or `bottom-tabs` to `sidebar`, so a host never has to guard the value. `menuItemCap` / `quickBarCap` keep their signatures (`quickBarCap("sidebar")` is still 5).
80
+ - The global menu never lived in the shape (it is the pages' `show_in_menu` flags), so a workspace that stored a retired shape already draws the sidebar with the same menu. The sc-7352 backfill deletes the stored key BEFORE the shapes disappear; a bottom-tabs app's phone strip becomes the sidebar's quick bar.
81
+ - Host-integration surface only; no author-facing hook, prop or manifest field changed. `CONTRACT.version` → `1.109.0`.
82
+
83
+ ### What's new in 0.140.0 (contract 1.108.0)
84
+
85
+ **Three rules make a generated widget's EDITABILITY checkable, and `lintEditability` joins the linter export (sc-7799).** The rules that already had a deterministic gate — the three basic style fields, `no-hardcoded-design`, `style-field-unread` — held every time. The ones left to prose drifted: `ui.group`, per-element coverage and author-editable copy were guidance only, so some widgets shipped a grouped Style section covering every element while others shipped the three basics and nothing else, and wording was routinely pinned in the source where no author could reach it.
86
+
87
+ - **`style-group-missing`** — a `styleSchema` field with no `ui.group`, or one filed under a catch-all name (`Advanced`, `Other`, `Misc`, `Style`, `Styles`, `General`) that rebuilds the flat list grouping exists to split. The three basics belong under `"Basics"`; each per-element field under the Title Case singular name of the element it styles (`"Card"`, `"Title"`, `"Row"`).
88
+ - **`style-elements-uncovered`** — a widget that renders several visual elements while its `styleSchema` carries nothing beyond `background`/`textColor`/`align`. A genuinely single-element widget is exempt by construction.
89
+ - **`text-literal-unexposed`** — a string literal sitting directly in a `<Text>`: copy that reaches neither the author nor the translation pipeline.
90
+
91
+ All three are `warning`, so `appstudio-widget lint` reports them and still exits 0 — the human author decides when to act. The AI widget agent publishes with no human in the loop and `_lintFindingsToPublishChecks` turns every finding into a publish check regardless of severity, so the same three **block** an agent publish and drive a repair turn, with no new severity plumbing.
92
+
93
+ `lintEditability(manifest, files)` is bundle-level like `lintStyleWiring`, and needs `--manifest` for the same reason. It counts rendered elements PER FILE, though: joining `widget.web.jsx` and `widget.native.jsx` would double every tag and call a single-`<Text>` split-impl widget multi-element.
74
94
 
75
95
  ### What's new in 0.139.0 (contract 1.108.0)
76
96
 
@@ -1762,15 +1782,22 @@ npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.j
1762
1782
  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.
1763
1783
 
1764
1784
  ```js
1765
- import { lintSource, lintStyleWiring } from "@colixsystems/widget-sdk/linter";
1785
+ import {
1786
+ lintSource,
1787
+ lintStyleWiring,
1788
+ lintEditability,
1789
+ } from "@colixsystems/widget-sdk/linter";
1766
1790
  const report = lintSource(source, { manifest });
1767
1791
  // Bundle-level — pass every file the widget ships:
1768
1792
  const wiring = lintStyleWiring(manifest, { "widget.jsx": source });
1793
+ const editability = lintEditability(manifest, { "widget.jsx": source });
1769
1794
  ```
1770
1795
 
1771
1796
  Two rules keep a widget's look reachable from the Studio (sc-6455). `no-hardcoded-design` flags a colour literal, a `fontFamily` string, or a numeric `fontSize` — values that outrank the theme permanently and that no control can move. `style-field-unread` flags a `styleSchema` field the source never reads, which renders a control that does nothing. Both are warnings for a human author and blocking for the AI widget agent. License a genuinely un-tokenizable value with a preceding `// appstudio-design-ok: <reason>` (the reason is required); see *What's new in 0.110.0*.
1772
1797
 
1773
- Pass `--manifest` to enable `style-field-unread` — it needs the manifest, and it needs every source at once so a split-impl widget's per-host field reads are seen together.
1798
+ Three more rules keep a widget EDITABLE once it ships (sc-7799), via the bundle-level `lintEditability(manifest, files)`. `style-group-missing` flags a `styleSchema` field with no `ui.group` — or one under a catch-all name — so a long Style section reads as labelled per-element fieldsets. `style-elements-uncovered` flags a multi-element widget whose Style section still only restyles the widget as a whole. `text-literal-unexposed` flags copy pinned in a `<Text>` — wording no control can change and no locale can translate. All three are warnings here and blocking for the AI widget agent; see *What's new in 0.140.0*.
1799
+
1800
+ Pass `--manifest` to enable `style-field-unread` and the three editability rules — they need the manifest, and every source at once so a split-impl widget's per-host reads are seen together.
1774
1801
 
1775
1802
  `flex-basis-percent` flags a literal percentage `flexBasis` (sc-7274) — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
1776
1803
 
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { argv, exit, stderr, stdout } from "node:process";
11
- import { lintSource, lintStyleWiring } from "./linter.js";
11
+ import { lintSource, lintStyleWiring, lintEditability } from "./linter.js";
12
12
  import { startDevServer } from "./devserver.js";
13
13
 
14
14
  function usage() {
@@ -106,9 +106,14 @@ async function runLint(rest) {
106
106
  const wiring = manifest
107
107
  ? lintStyleWiring(manifest, files)
108
108
  : { ok: true, findings: [] };
109
+ // sc-7799 — same manifest+bundle shape as the dead-control gate above.
110
+ const editability = manifest
111
+ ? lintEditability(manifest, files)
112
+ : { ok: true, findings: [] };
109
113
  const all = perFile
110
114
  .flatMap(([, r]) => r.findings)
111
- .concat(wiring.findings);
115
+ .concat(wiring.findings)
116
+ .concat(editability.findings);
112
117
  if (all.length === 0) {
113
118
  stdout.write(`${label}: clean\n`);
114
119
  exit(0);
@@ -116,7 +121,7 @@ async function runLint(rest) {
116
121
  // sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
117
122
  // for warnings, so the CLI printed "clean" and dropped them. A warning nobody
118
123
  // sees is pointless. Report every finding; only errors change the exit code.
119
- const ok = perFile.every(([, r]) => r.ok) && wiring.ok;
124
+ const ok = perFile.every(([, r]) => r.ok) && wiring.ok && editability.ok;
120
125
  const errors = all.filter((f) => f.severity !== "warning").length;
121
126
  const stream = ok ? stdout : stderr;
122
127
  stream.write(
@@ -131,6 +136,10 @@ async function runLint(rest) {
131
136
  stream.write(`manifest.styleSchema:\n`);
132
137
  writeFindings(wiring.findings, stream);
133
138
  }
139
+ if (editability.findings.length > 0) {
140
+ stream.write(`manifest.editability:\n`);
141
+ writeFindings(editability.findings, stream);
142
+ }
134
143
  exit(ok ? 0 : 1);
135
144
  }
136
145
 
package/dist/contract.cjs CHANGED
@@ -217,36 +217,20 @@ const THEME_WIDGET_STYLES = Object.freeze({
217
217
  // PlayerChrome and the compiler's navigator -- so a type cannot be offered to an
218
218
  // author without every host actually drawing it.
219
219
  //
220
- // `maxItems` caps how many menu pages the chrome draws at once, and its meaning
221
- // differs per type deliberately. The sidebar's mobile quick bar is a SECONDARY
222
- // curated bar, so its own cap may drop a page -- the rail still lists every one.
223
- // A bottom-tabs strip IS the menu, so its cap must never drop one: the surplus
224
- // moves behind a "More" sheet instead.
220
+ // REQ-NAV-STRUCTURE (sc-7352): the app's GLOBAL navigation has exactly one
221
+ // shape, the sidebar. A top bar is a PAGE's local bar drawing a saved menu
222
+ // (REQ-NAV-LOCAL), themed by the `topBar` tokens, never an app-wide menu shape.
223
+ // `maxItems` is the menu cap (none for a rail); `quickBarMaxItems` caps the
224
+ // SECONDARY mobile quick bar, which may drop a page because the rail and the
225
+ // drawer still list every one.
225
226
  const THEME_MENU_TYPES = Object.freeze({
226
227
  sidebar: Object.freeze({
227
228
  name: "Sidebar",
228
229
  summary:
229
230
  "A persistent left rail on desktop; a hamburger drawer plus the optional bottom quick bar on mobile.",
230
231
  maxItems: null,
231
- // The SECONDARY mobile quick bar's cap. It may drop a page past it, because
232
- // the rail and the drawer still list every one -- the opposite of
233
- // `bottom-tabs`' `maxItems`, where the strip IS the menu.
234
232
  quickBarMaxItems: 5,
235
233
  }),
236
- "top-bar": Object.freeze({
237
- name: "Top bar",
238
- summary:
239
- "A tab row beneath the app header at every width -- icon and label per page, scrolling sideways when it runs out of room. No rail at any width.",
240
- maxItems: null,
241
- quickBarMaxItems: null,
242
- }),
243
- "bottom-tabs": Object.freeze({
244
- name: "Bottom tabs",
245
- summary:
246
- "A sticky bottom strip at every width -- the phone-native shape. Pages past the cap move behind a More sheet.",
247
- maxItems: 4,
248
- quickBarMaxItems: null,
249
- }),
250
234
  });
251
235
  const THEME_SPACING_SCALE = Object.freeze({
252
236
  min: 0.5,
@@ -3976,7 +3960,17 @@ const CONTRACT = deepFreeze({
3976
3960
  // a stored hex is untouched and still renders byte-identically, and an
3977
3961
  // unresolvable binding falls back to the field's `themeDefault` rather
3978
3962
  // than painting blank.
3979
- version: "1.108.0",
3963
+ // 1.109.0: BREAKING on the pre-1.0 channel (sc-7352) — `themeMenuTypes`
3964
+ // loses `top-bar` and `bottom-tabs`: the app's global navigation is the
3965
+ // sidebar alone, and a top bar is a page's LOCAL bar drawing a saved menu
3966
+ // (REQ-NAV-LOCAL), themed by the same `topBar` tokens. A stored
3967
+ // `navigation.menuType` of a retired shape resolves to `sidebar` through
3968
+ // `normaliseNavigation`, so no host has to guard the value; the global
3969
+ // menu never lived in the shape, so such apps already draw the sidebar,
3970
+ // and the sc-7352 backfill deletes the stored key (a bottom-tabs app's
3971
+ // strip becomes the sidebar's quick bar) BEFORE this ships.
3972
+ // `menuItemCap` / `quickBarCap` keep their signatures.
3973
+ version: "1.109.0",
3980
3974
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3981
3975
  hooks: HOOKS,
3982
3976
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -217,36 +217,20 @@ const THEME_WIDGET_STYLES = Object.freeze({
217
217
  // PlayerChrome and the compiler's navigator -- so a type cannot be offered to an
218
218
  // author without every host actually drawing it.
219
219
  //
220
- // `maxItems` caps how many menu pages the chrome draws at once, and its meaning
221
- // differs per type deliberately. The sidebar's mobile quick bar is a SECONDARY
222
- // curated bar, so its own cap may drop a page -- the rail still lists every one.
223
- // A bottom-tabs strip IS the menu, so its cap must never drop one: the surplus
224
- // moves behind a "More" sheet instead.
220
+ // REQ-NAV-STRUCTURE (sc-7352): the app's GLOBAL navigation has exactly one
221
+ // shape, the sidebar. A top bar is a PAGE's local bar drawing a saved menu
222
+ // (REQ-NAV-LOCAL), themed by the `topBar` tokens, never an app-wide menu shape.
223
+ // `maxItems` is the menu cap (none for a rail); `quickBarMaxItems` caps the
224
+ // SECONDARY mobile quick bar, which may drop a page because the rail and the
225
+ // drawer still list every one.
225
226
  const THEME_MENU_TYPES = Object.freeze({
226
227
  sidebar: Object.freeze({
227
228
  name: "Sidebar",
228
229
  summary:
229
230
  "A persistent left rail on desktop; a hamburger drawer plus the optional bottom quick bar on mobile.",
230
231
  maxItems: null,
231
- // The SECONDARY mobile quick bar's cap. It may drop a page past it, because
232
- // the rail and the drawer still list every one -- the opposite of
233
- // `bottom-tabs`' `maxItems`, where the strip IS the menu.
234
232
  quickBarMaxItems: 5,
235
233
  }),
236
- "top-bar": Object.freeze({
237
- name: "Top bar",
238
- summary:
239
- "A tab row beneath the app header at every width -- icon and label per page, scrolling sideways when it runs out of room. No rail at any width.",
240
- maxItems: null,
241
- quickBarMaxItems: null,
242
- }),
243
- "bottom-tabs": Object.freeze({
244
- name: "Bottom tabs",
245
- summary:
246
- "A sticky bottom strip at every width -- the phone-native shape. Pages past the cap move behind a More sheet.",
247
- maxItems: 4,
248
- quickBarMaxItems: null,
249
- }),
250
234
  });
251
235
  const THEME_SPACING_SCALE = Object.freeze({
252
236
  min: 0.5,
@@ -3976,7 +3960,17 @@ const CONTRACT = deepFreeze({
3976
3960
  // a stored hex is untouched and still renders byte-identically, and an
3977
3961
  // unresolvable binding falls back to the field's `themeDefault` rather
3978
3962
  // than painting blank.
3979
- version: "1.108.0",
3963
+ // 1.109.0: BREAKING on the pre-1.0 channel (sc-7352) — `themeMenuTypes`
3964
+ // loses `top-bar` and `bottom-tabs`: the app's global navigation is the
3965
+ // sidebar alone, and a top bar is a page's LOCAL bar drawing a saved menu
3966
+ // (REQ-NAV-LOCAL), themed by the same `topBar` tokens. A stored
3967
+ // `navigation.menuType` of a retired shape resolves to `sidebar` through
3968
+ // `normaliseNavigation`, so no host has to guard the value; the global
3969
+ // menu never lived in the shape, so such apps already draw the sidebar,
3970
+ // and the sc-7352 backfill deletes the stored key (a bottom-tabs app's
3971
+ // strip becomes the sidebar's quick bar) BEFORE this ships.
3972
+ // `menuItemCap` / `quickBarCap` keep their signatures.
3973
+ version: "1.109.0",
3980
3974
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3981
3975
  hooks: HOOKS,
3982
3976
  primitives: PRIMITIVES,
package/dist/host.d.ts CHANGED
@@ -150,10 +150,11 @@ export function createToastController(
150
150
  ): ToastController;
151
151
 
152
152
  // REQ-NAV-STRUCTURE — the navigation SHAPE a host draws its chrome in. The
153
- // vocabulary is closed by `CONTRACT.themeMenuTypes`; these are the resolvers
154
- // both hosts switch on.
153
+ // vocabulary is closed by `CONTRACT.themeMenuTypes`; since sc-7352 it holds
154
+ // the sidebar alone (a top bar is a page's local bar, REQ-NAV-LOCAL), and any
155
+ // stored value outside it resolves to `sidebar`.
155
156
 
156
- export type ThemeMenuType = "sidebar" | "top-bar" | "bottom-tabs";
157
+ export type ThemeMenuType = "sidebar";
157
158
 
158
159
  export interface ResolvedNavigation {
159
160
  menuType: ThemeMenuType;
@@ -168,8 +169,7 @@ export function normaliseNavigation(navigation: unknown): ResolvedNavigation;
168
169
 
169
170
  /**
170
171
  * How many menu pages this shape may draw at once, or `null` for no cap. The
171
- * cap's meaning differs per type: the sidebar's quick bar may drop a page past
172
- * it, a bottom-tabs strip must move its surplus behind a More sheet.
172
+ * sidebar has none — its rail and drawer list every menu page.
173
173
  */
174
174
  export function menuItemCap(menuType: string): number | null;
175
175
 
@@ -232,16 +232,14 @@ export interface TopBarTokens {
232
232
  * label plus its indicator. Falls back to the RAIL's `activeColor` and then
233
233
  * to the brand, so an app states its navigation colour once. */
234
234
  activeColor: string;
235
- /** How a `tabs` menu row marks its current page. `underline` keeps the tab in
236
- * the bar's surface behind an indicator; `attached` makes it a folder tab
237
- * that takes `contentSurface` and sits over the row's divider. Meaningless
238
- * under the `links` style, which draws no tabs. */
235
+ /** How a local bar marks its current page. `underline` keeps the tab in the
236
+ * bar's surface behind an indicator; `attached` cuts the bar away under the
237
+ * current tab so the page shows through it. */
239
238
  tabStyle: "underline" | "attached";
240
- /** The surface an `attached` tab AND its content panel share — always
241
- * opaque, because a translucent tab would show the bar through the page it
242
- * is part of. Honours `tabActiveBackgroundColor`, so moving the tab moves
243
- * the page with it. */
244
- contentSurface: string;
239
+ /** Under `attached`, the colour the PANEL behind the row paints — the
240
+ * authored `tabActiveBackgroundColor`, alpha kept — or null for the page's
241
+ * own wash. The current tab paints nothing itself. */
242
+ contentSurface: string | null;
245
243
  /** A tab's own surface, authored or null. Null paints none and the bar shows
246
244
  * through. */
247
245
  tabBackgroundColor: string | null;
package/dist/host.js CHANGED
@@ -52,15 +52,14 @@ export {
52
52
  TOAST_DEFAULTS,
53
53
  } from "./toast-host.js";
54
54
 
55
- // REQ-NAV-STRUCTURE: the navigation-shape resolver both hosts draw their chrome
56
- // from. `normaliseNavigation` turns a stored `theme_config.navigation` block into
57
- // the resolved `{ menuType }` the chrome switches on; `menuItemCap` states how
58
- // many menu pages that shape may draw at once. One implementation, so a menu
59
- // type cannot mean one thing in the Player and another in the Expo export.
60
- // `resolveFooterTokens` is the footer strip's own token set, falling back to
61
- // the sidebar's so an untouched workspace keeps the appearance it has. It
62
- // exists because the strip IS the menu under `bottom-tabs`, where the sidebar
63
- // panel is hidden and those tokens can no longer be set at all.
55
+ // REQ-NAV-STRUCTURE: the navigation resolver both hosts draw their chrome from.
56
+ // `normaliseNavigation` turns a stored `theme_config.navigation` block into the
57
+ // resolved `{ menuType }` — always `sidebar` since sc-7352 retired the other
58
+ // shapes, so a stored value never needs guarding; `menuItemCap` / `quickBarCap`
59
+ // state the caps. One implementation, so the chrome cannot mean one thing in
60
+ // the Player and another in the Expo export. `resolveFooterTokens` is the quick
61
+ // bar's own token set, falling back to the sidebar's so an untouched workspace
62
+ // keeps the appearance it has.
64
63
  // REQ-THEME-14: `resolveSidebarTokens` and `resolveTopBarTokens` are the same
65
64
  // idea for the rail and the app bar, whose defaults used to live inline in each
66
65
  // host and had already drifted apart.
package/dist/linter.cjs CHANGED
@@ -1438,11 +1438,7 @@ function lintStyleWiring(manifest, files) {
1438
1438
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
1439
1439
  return empty;
1440
1440
  }
1441
- let sources = [];
1442
- if (typeof files === "string") sources = [files];
1443
- else if (Array.isArray(files)) sources = files;
1444
- else if (files && typeof files === "object") sources = Object.values(files);
1445
- sources = sources.filter((s) => typeof s === "string" && s.length > 0);
1441
+ const sources = _bundleSources(files);
1446
1442
  if (sources.length === 0) return empty;
1447
1443
  const code = sources
1448
1444
  .map((s) => _stripNonCode(s, { keepStrings: true }))
@@ -1471,6 +1467,266 @@ function lintStyleWiring(manifest, files) {
1471
1467
  return { ok: !hasErrors, findings };
1472
1468
  }
1473
1469
 
1470
+ // sc-7799 — a group name that groups everything defeats the rule: one
1471
+ // catch-all fieldset reads exactly like the flat list it replaced.
1472
+ const _GENERIC_STYLE_GROUPS = new Set([
1473
+ "advanced",
1474
+ "other",
1475
+ "misc",
1476
+ "style",
1477
+ "styles",
1478
+ "general",
1479
+ ]);
1480
+
1481
+ // The three the basic-style gate mandates; they restyle the widget as a whole.
1482
+ const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
1483
+ "background",
1484
+ "textColor",
1485
+ "align",
1486
+ ]);
1487
+
1488
+ // A second element an author would style apart from the text. Pressable and
1489
+ // Button are deliberately absent: a touch target wrapping the only label is
1490
+ // one element, not two.
1491
+ const _SECOND_ELEMENT_TAGS = ["Image", "TextInput", "FlatList", "SectionList"];
1492
+
1493
+ const _SEPARATOR_GLYPHS = new Set(["-", "–", "—", "•", "·", "…", "|", "/"]);
1494
+
1495
+ // Names ride the finding; the remainder is counted, so one finding stays
1496
+ // inside the agent's per-message budget however many fields are wrong.
1497
+ const _EDITABILITY_LIST_CAP = 3;
1498
+
1499
+ // Tighter for copy: each entry carries the text AND its line, so six of them
1500
+ // push the message past the report's 300-char truncation.
1501
+ const _LITERAL_LIST_CAP = 2;
1502
+
1503
+ // Shared by both bundle-level gates — one normaliser, not two.
1504
+ function _bundleSources(files) {
1505
+ let sources = [];
1506
+ if (typeof files === "string") sources = [files];
1507
+ else if (Array.isArray(files)) sources = files;
1508
+ else if (files && typeof files === "object") sources = Object.values(files);
1509
+ return sources.filter((s) => typeof s === "string" && s.length > 0);
1510
+ }
1511
+
1512
+ function _plainObject(value) {
1513
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1514
+ }
1515
+
1516
+ // Both the count and each entry are bounded: the backend wraps the label in
1517
+ // `[manifest.editability] … (line N: snippet)` and truncates at 300, so an
1518
+ // unbounded name list loses the tail of the advice that follows it.
1519
+ function _clip(text, max) {
1520
+ return text.length > max ? `${text.slice(0, max)}…` : text;
1521
+ }
1522
+
1523
+ function _quoteList(names) {
1524
+ const shown = names.slice(0, _EDITABILITY_LIST_CAP).map((n) => _clip(n, 16));
1525
+ const more = names.length - shown.length;
1526
+ return shown.join(", ") + (more > 0 ? `, …(+${more} more)` : "");
1527
+ }
1528
+
1529
+ // Copy worth a control: a separator glyph or a bare number is chrome.
1530
+ function _isAuthoredCopy(text) {
1531
+ if (text.length < 2) return false;
1532
+ if (!/[A-Za-z]/.test(text)) return false;
1533
+ return !_SEPARATOR_GLYPHS.has(text);
1534
+ }
1535
+
1536
+ // The `>` that CLOSES a JSX tag, skipping quoted attribute values and any `>`
1537
+ // inside a `{…}` expression — `onPress={() => x}` carries one, and treating it
1538
+ // as the tag end reports the handler body as copy.
1539
+ function _tagEnd(code, from) {
1540
+ let depth = 0;
1541
+ let quote = null;
1542
+ for (let i = from; i < code.length; i++) {
1543
+ const ch = code[i];
1544
+ if (quote) {
1545
+ if (ch === "\\") i++;
1546
+ else if (ch === quote) quote = null;
1547
+ continue;
1548
+ }
1549
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
1550
+ else if (ch === "{") depth++;
1551
+ else if (ch === "}") depth--;
1552
+ else if (ch === ">" && depth <= 0) return i;
1553
+ }
1554
+ return -1;
1555
+ }
1556
+
1557
+ // The balanced `{…}` starting at `from`, or null.
1558
+ function _bracedSpan(code, from) {
1559
+ if (code[from] !== "{") return null;
1560
+ let depth = 0;
1561
+ let quote = null;
1562
+ for (let i = from; i < code.length; i++) {
1563
+ const ch = code[i];
1564
+ if (quote) {
1565
+ if (ch === "\\") i++;
1566
+ else if (ch === quote) quote = null;
1567
+ continue;
1568
+ }
1569
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
1570
+ else if (ch === "{") depth++;
1571
+ else if (ch === "}") {
1572
+ depth--;
1573
+ if (depth === 0) return code.slice(from + 1, i);
1574
+ }
1575
+ }
1576
+ return null;
1577
+ }
1578
+
1579
+ // Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
1580
+ // NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
1581
+ // A braced expression carrying an identifier is left alone: it may already be
1582
+ // a prop, and under-firing is right for a gate that blocks a publish.
1583
+ function _textLiteralOccurrences(codes, masks) {
1584
+ const byText = new Map();
1585
+ for (let f = 0; f < codes.length; f++) {
1586
+ const code = codes[f];
1587
+ // Locate tags on the string-blanked mask, so markup HELD IN a string is
1588
+ // not read as markup. Both masks preserve offsets, so the child is read
1589
+ // back from the kept-strings source at the very same index.
1590
+ const mask = masks[f];
1591
+ const re = /<Text\b/g;
1592
+ let m;
1593
+ while ((m = re.exec(mask)) !== null) {
1594
+ const end = _tagEnd(mask, m.index);
1595
+ if (end === -1) continue;
1596
+ let i = end + 1;
1597
+ while (i < code.length && /\s/.test(code[i])) i++;
1598
+ let text = null;
1599
+ if (code[i] === "{") {
1600
+ const inner = _bracedSpan(code, i);
1601
+ const lit =
1602
+ inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
1603
+ // A template carrying `${…}` interpolates a value, so it is not a literal.
1604
+ if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
1605
+ } else {
1606
+ const child = code.slice(i).match(/^([^<>{}\n]+)/);
1607
+ if (child) text = child[1];
1608
+ }
1609
+ if (text === null) continue;
1610
+ text = text.trim();
1611
+ if (!_isAuthoredCopy(text) || byText.has(text)) continue;
1612
+ // Split-impl carries the same copy twice; report the string, not the file.
1613
+ byText.set(text, code.slice(0, i).split("\n").length);
1614
+ }
1615
+ }
1616
+ return [...byText.entries()].map(([text, line]) => ({ text, line }));
1617
+ }
1618
+
1619
+ // Per FILE: joining widget.web.jsx and widget.native.jsx doubles every tag.
1620
+ // A second element must be a DISTINCT visual kind beside the text — two
1621
+ // `<Text>` tags are as often a loading branch and its content as a title and
1622
+ // a body, and a blocking gate may not guess.
1623
+ function _rendersSeveralElements(code) {
1624
+ if (!/<Text[\s>/]/.test(code)) return false;
1625
+ return _SECOND_ELEMENT_TAGS.some((tag) =>
1626
+ new RegExp(`<${tag}[\\s>/]`).test(code),
1627
+ );
1628
+ }
1629
+
1630
+ function _styleGroupFindings(manifest) {
1631
+ const schema = _plainObject(manifest) ? manifest.styleSchema : null;
1632
+ if (!_plainObject(schema)) return [];
1633
+ const bad = Object.keys(schema).filter((field) => {
1634
+ const def = schema[field];
1635
+ const group = _plainObject(def) && _plainObject(def.ui) ? def.ui.group : "";
1636
+ if (typeof group !== "string" || group.trim().length === 0) return true;
1637
+ return _GENERIC_STYLE_GROUPS.has(group.trim().toLowerCase());
1638
+ });
1639
+ if (bad.length === 0) return [];
1640
+ return [
1641
+ {
1642
+ rule: "style-group-missing",
1643
+ severity: "warning",
1644
+ label:
1645
+ `styleSchema fields with no usable ui.group: ${_quoteList(
1646
+ bad.map((f) => `"${f}"`),
1647
+ )}. Group EVERY field — basics under "Basics", each per-element ` +
1648
+ `field under the element it styles. A catch-all name is not a group.`,
1649
+ line: 0,
1650
+ snippet: _clip(bad[0], 12),
1651
+ },
1652
+ ];
1653
+ }
1654
+
1655
+ function _styleCoverageFindings(manifest, codes) {
1656
+ const schema = _plainObject(manifest) ? manifest.styleSchema : null;
1657
+ if (!_plainObject(schema)) return [];
1658
+ const fields = Object.keys(schema);
1659
+ if (fields.length === 0) return [];
1660
+ if (fields.some((f) => !_WHOLE_WIDGET_STYLE_FIELDS.has(f))) return [];
1661
+ if (!codes.some(_rendersSeveralElements)) return [];
1662
+ return [
1663
+ {
1664
+ rule: "style-elements-uncovered",
1665
+ severity: "warning",
1666
+ label:
1667
+ `The widget renders more than one visual element but its styleSchema ` +
1668
+ `stops at background/textColor/align. Add 2-4 grouped per-element ` +
1669
+ `fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
1670
+ `each applied ONLY when set.`,
1671
+ line: 0,
1672
+ snippet: "",
1673
+ },
1674
+ ];
1675
+ }
1676
+
1677
+ function _textLiteralFindings(occurrences) {
1678
+ if (occurrences.length === 0) return [];
1679
+ // Each entry is bounded so a long line of copy cannot push the whole
1680
+ // message past the report's truncation and take the line numbers with it.
1681
+ const listed = occurrences
1682
+ .slice(0, _LITERAL_LIST_CAP)
1683
+ .map((o) => `"${_clip(o.text, 16)}" (line ${o.line})`);
1684
+ const more = occurrences.length - listed.length;
1685
+ return [
1686
+ {
1687
+ rule: "text-literal-unexposed",
1688
+ severity: "warning",
1689
+ label:
1690
+ `Copy pinned in a <Text> reaches neither author nor translation: ` +
1691
+ `${listed.join(", ")}${more > 0 ? `, …(+${more} more)` : ""}. ` +
1692
+ `Declare each as a propertySchema string defaulting to that text ` +
1693
+ `and render the prop; fixed chrome uses t().`,
1694
+ line: occurrences[0].line,
1695
+ snippet: _clip(occurrences[0].text, 12),
1696
+ },
1697
+ ];
1698
+ }
1699
+
1700
+ /**
1701
+ * sc-7799 — the editability gates: what an author can still change once the
1702
+ * widget ships. Bundle-level like `lintStyleWiring`, and for the same reason
1703
+ * — one manifest serves both halves of a split-impl widget.
1704
+ *
1705
+ * Each rule emits ONE consolidated finding, because the agent's draft-findings
1706
+ * report keeps only the first dozen checks and truncates each message: a
1707
+ * per-field finding on a 14-field manifest would evict the render-time crash
1708
+ * sitting behind it.
1709
+ *
1710
+ * @param {object} manifest widget manifest (`styleSchema` only)
1711
+ * @param {object|string[]|string} files bundle sources — a `{ name: source }`
1712
+ * map, an array of sources, or one source string
1713
+ * @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
1714
+ */
1715
+ function lintEditability(manifest, files) {
1716
+ const sources = _bundleSources(files);
1717
+ if (sources.length === 0) return { ok: true, findings: [] };
1718
+ const codes = sources.map((s) => _stripNonCode(s, { keepStrings: true }));
1719
+ const masks = sources.map((s) => _stripNonCode(s, { keepStrings: false }));
1720
+ const findings = [
1721
+ ..._styleGroupFindings(manifest),
1722
+ ..._styleCoverageFindings(manifest, codes),
1723
+ ..._textLiteralFindings(_textLiteralOccurrences(codes, masks)),
1724
+ ];
1725
+ // Every rule here is a warning by construction: the CLI must stay exit-0 for
1726
+ // a human author, while the agent blocks on any finding regardless.
1727
+ return { ok: true, findings };
1728
+ }
1729
+
1474
1730
  function lintSource(source, options) {
1475
1731
  if (typeof source !== "string") {
1476
1732
  return {
@@ -1553,6 +1809,7 @@ function lintSource(source, options) {
1553
1809
  module.exports = {
1554
1810
  lintSource,
1555
1811
  lintStyleWiring,
1812
+ lintEditability,
1556
1813
  bannedIdentifiers,
1557
1814
  narrowManifestForFile,
1558
1815
  };
package/dist/linter.js CHANGED
@@ -1509,11 +1509,7 @@ export function lintStyleWiring(manifest, files) {
1509
1509
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
1510
1510
  return empty;
1511
1511
  }
1512
- let sources = [];
1513
- if (typeof files === "string") sources = [files];
1514
- else if (Array.isArray(files)) sources = files;
1515
- else if (files && typeof files === "object") sources = Object.values(files);
1516
- sources = sources.filter((s) => typeof s === "string" && s.length > 0);
1512
+ const sources = _bundleSources(files);
1517
1513
  if (sources.length === 0) return empty;
1518
1514
  const code = sources
1519
1515
  .map((s) => _stripNonCode(s, { keepStrings: true }))
@@ -1542,6 +1538,266 @@ export function lintStyleWiring(manifest, files) {
1542
1538
  return { ok: !hasErrors, findings };
1543
1539
  }
1544
1540
 
1541
+ // sc-7799 — a group name that groups everything defeats the rule: one
1542
+ // catch-all fieldset reads exactly like the flat list it replaced.
1543
+ const _GENERIC_STYLE_GROUPS = new Set([
1544
+ "advanced",
1545
+ "other",
1546
+ "misc",
1547
+ "style",
1548
+ "styles",
1549
+ "general",
1550
+ ]);
1551
+
1552
+ // The three the basic-style gate mandates; they restyle the widget as a whole.
1553
+ const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
1554
+ "background",
1555
+ "textColor",
1556
+ "align",
1557
+ ]);
1558
+
1559
+ // A second element an author would style apart from the text. Pressable and
1560
+ // Button are deliberately absent: a touch target wrapping the only label is
1561
+ // one element, not two.
1562
+ const _SECOND_ELEMENT_TAGS = ["Image", "TextInput", "FlatList", "SectionList"];
1563
+
1564
+ const _SEPARATOR_GLYPHS = new Set(["-", "–", "—", "•", "·", "…", "|", "/"]);
1565
+
1566
+ // Names ride the finding; the remainder is counted, so one finding stays
1567
+ // inside the agent's per-message budget however many fields are wrong.
1568
+ const _EDITABILITY_LIST_CAP = 3;
1569
+
1570
+ // Tighter for copy: each entry carries the text AND its line, so six of them
1571
+ // push the message past the report's 300-char truncation.
1572
+ const _LITERAL_LIST_CAP = 2;
1573
+
1574
+ // Shared by both bundle-level gates — one normaliser, not two.
1575
+ function _bundleSources(files) {
1576
+ let sources = [];
1577
+ if (typeof files === "string") sources = [files];
1578
+ else if (Array.isArray(files)) sources = files;
1579
+ else if (files && typeof files === "object") sources = Object.values(files);
1580
+ return sources.filter((s) => typeof s === "string" && s.length > 0);
1581
+ }
1582
+
1583
+ function _plainObject(value) {
1584
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1585
+ }
1586
+
1587
+ // Both the count and each entry are bounded: the backend wraps the label in
1588
+ // `[manifest.editability] … (line N: snippet)` and truncates at 300, so an
1589
+ // unbounded name list loses the tail of the advice that follows it.
1590
+ function _clip(text, max) {
1591
+ return text.length > max ? `${text.slice(0, max)}…` : text;
1592
+ }
1593
+
1594
+ function _quoteList(names) {
1595
+ const shown = names.slice(0, _EDITABILITY_LIST_CAP).map((n) => _clip(n, 16));
1596
+ const more = names.length - shown.length;
1597
+ return shown.join(", ") + (more > 0 ? `, …(+${more} more)` : "");
1598
+ }
1599
+
1600
+ // Copy worth a control: a separator glyph or a bare number is chrome.
1601
+ function _isAuthoredCopy(text) {
1602
+ if (text.length < 2) return false;
1603
+ if (!/[A-Za-z]/.test(text)) return false;
1604
+ return !_SEPARATOR_GLYPHS.has(text);
1605
+ }
1606
+
1607
+ // The `>` that CLOSES a JSX tag, skipping quoted attribute values and any `>`
1608
+ // inside a `{…}` expression — `onPress={() => x}` carries one, and treating it
1609
+ // as the tag end reports the handler body as copy.
1610
+ function _tagEnd(code, from) {
1611
+ let depth = 0;
1612
+ let quote = null;
1613
+ for (let i = from; i < code.length; i++) {
1614
+ const ch = code[i];
1615
+ if (quote) {
1616
+ if (ch === "\\") i++;
1617
+ else if (ch === quote) quote = null;
1618
+ continue;
1619
+ }
1620
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
1621
+ else if (ch === "{") depth++;
1622
+ else if (ch === "}") depth--;
1623
+ else if (ch === ">" && depth <= 0) return i;
1624
+ }
1625
+ return -1;
1626
+ }
1627
+
1628
+ // The balanced `{…}` starting at `from`, or null.
1629
+ function _bracedSpan(code, from) {
1630
+ if (code[from] !== "{") return null;
1631
+ let depth = 0;
1632
+ let quote = null;
1633
+ for (let i = from; i < code.length; i++) {
1634
+ const ch = code[i];
1635
+ if (quote) {
1636
+ if (ch === "\\") i++;
1637
+ else if (ch === quote) quote = null;
1638
+ continue;
1639
+ }
1640
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
1641
+ else if (ch === "{") depth++;
1642
+ else if (ch === "}") {
1643
+ depth--;
1644
+ if (depth === 0) return code.slice(from + 1, i);
1645
+ }
1646
+ }
1647
+ return null;
1648
+ }
1649
+
1650
+ // Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
1651
+ // NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
1652
+ // A braced expression carrying an identifier is left alone: it may already be
1653
+ // a prop, and under-firing is right for a gate that blocks a publish.
1654
+ function _textLiteralOccurrences(codes, masks) {
1655
+ const byText = new Map();
1656
+ for (let f = 0; f < codes.length; f++) {
1657
+ const code = codes[f];
1658
+ // Locate tags on the string-blanked mask, so markup HELD IN a string is
1659
+ // not read as markup. Both masks preserve offsets, so the child is read
1660
+ // back from the kept-strings source at the very same index.
1661
+ const mask = masks[f];
1662
+ const re = /<Text\b/g;
1663
+ let m;
1664
+ while ((m = re.exec(mask)) !== null) {
1665
+ const end = _tagEnd(mask, m.index);
1666
+ if (end === -1) continue;
1667
+ let i = end + 1;
1668
+ while (i < code.length && /\s/.test(code[i])) i++;
1669
+ let text = null;
1670
+ if (code[i] === "{") {
1671
+ const inner = _bracedSpan(code, i);
1672
+ const lit =
1673
+ inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
1674
+ // A template carrying `${…}` interpolates a value, so it is not a literal.
1675
+ if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
1676
+ } else {
1677
+ const child = code.slice(i).match(/^([^<>{}\n]+)/);
1678
+ if (child) text = child[1];
1679
+ }
1680
+ if (text === null) continue;
1681
+ text = text.trim();
1682
+ if (!_isAuthoredCopy(text) || byText.has(text)) continue;
1683
+ // Split-impl carries the same copy twice; report the string, not the file.
1684
+ byText.set(text, code.slice(0, i).split("\n").length);
1685
+ }
1686
+ }
1687
+ return [...byText.entries()].map(([text, line]) => ({ text, line }));
1688
+ }
1689
+
1690
+ // Per FILE: joining widget.web.jsx and widget.native.jsx doubles every tag.
1691
+ // A second element must be a DISTINCT visual kind beside the text — two
1692
+ // `<Text>` tags are as often a loading branch and its content as a title and
1693
+ // a body, and a blocking gate may not guess.
1694
+ function _rendersSeveralElements(code) {
1695
+ if (!/<Text[\s>/]/.test(code)) return false;
1696
+ return _SECOND_ELEMENT_TAGS.some((tag) =>
1697
+ new RegExp(`<${tag}[\\s>/]`).test(code),
1698
+ );
1699
+ }
1700
+
1701
+ function _styleGroupFindings(manifest) {
1702
+ const schema = _plainObject(manifest) ? manifest.styleSchema : null;
1703
+ if (!_plainObject(schema)) return [];
1704
+ const bad = Object.keys(schema).filter((field) => {
1705
+ const def = schema[field];
1706
+ const group = _plainObject(def) && _plainObject(def.ui) ? def.ui.group : "";
1707
+ if (typeof group !== "string" || group.trim().length === 0) return true;
1708
+ return _GENERIC_STYLE_GROUPS.has(group.trim().toLowerCase());
1709
+ });
1710
+ if (bad.length === 0) return [];
1711
+ return [
1712
+ {
1713
+ rule: "style-group-missing",
1714
+ severity: "warning",
1715
+ label:
1716
+ `styleSchema fields with no usable ui.group: ${_quoteList(
1717
+ bad.map((f) => `"${f}"`),
1718
+ )}. Group EVERY field — basics under "Basics", each per-element ` +
1719
+ `field under the element it styles. A catch-all name is not a group.`,
1720
+ line: 0,
1721
+ snippet: _clip(bad[0], 12),
1722
+ },
1723
+ ];
1724
+ }
1725
+
1726
+ function _styleCoverageFindings(manifest, codes) {
1727
+ const schema = _plainObject(manifest) ? manifest.styleSchema : null;
1728
+ if (!_plainObject(schema)) return [];
1729
+ const fields = Object.keys(schema);
1730
+ if (fields.length === 0) return [];
1731
+ if (fields.some((f) => !_WHOLE_WIDGET_STYLE_FIELDS.has(f))) return [];
1732
+ if (!codes.some(_rendersSeveralElements)) return [];
1733
+ return [
1734
+ {
1735
+ rule: "style-elements-uncovered",
1736
+ severity: "warning",
1737
+ label:
1738
+ `The widget renders more than one visual element but its styleSchema ` +
1739
+ `stops at background/textColor/align. Add 2-4 grouped per-element ` +
1740
+ `fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
1741
+ `each applied ONLY when set.`,
1742
+ line: 0,
1743
+ snippet: "",
1744
+ },
1745
+ ];
1746
+ }
1747
+
1748
+ function _textLiteralFindings(occurrences) {
1749
+ if (occurrences.length === 0) return [];
1750
+ // Each entry is bounded so a long line of copy cannot push the whole
1751
+ // message past the report's truncation and take the line numbers with it.
1752
+ const listed = occurrences
1753
+ .slice(0, _LITERAL_LIST_CAP)
1754
+ .map((o) => `"${_clip(o.text, 16)}" (line ${o.line})`);
1755
+ const more = occurrences.length - listed.length;
1756
+ return [
1757
+ {
1758
+ rule: "text-literal-unexposed",
1759
+ severity: "warning",
1760
+ label:
1761
+ `Copy pinned in a <Text> reaches neither author nor translation: ` +
1762
+ `${listed.join(", ")}${more > 0 ? `, …(+${more} more)` : ""}. ` +
1763
+ `Declare each as a propertySchema string defaulting to that text ` +
1764
+ `and render the prop; fixed chrome uses t().`,
1765
+ line: occurrences[0].line,
1766
+ snippet: _clip(occurrences[0].text, 12),
1767
+ },
1768
+ ];
1769
+ }
1770
+
1771
+ /**
1772
+ * sc-7799 — the editability gates: what an author can still change once the
1773
+ * widget ships. Bundle-level like `lintStyleWiring`, and for the same reason
1774
+ * — one manifest serves both halves of a split-impl widget.
1775
+ *
1776
+ * Each rule emits ONE consolidated finding, because the agent's draft-findings
1777
+ * report keeps only the first dozen checks and truncates each message: a
1778
+ * per-field finding on a 14-field manifest would evict the render-time crash
1779
+ * sitting behind it.
1780
+ *
1781
+ * @param {object} manifest widget manifest (`styleSchema` only)
1782
+ * @param {object|string[]|string} files bundle sources — a `{ name: source }`
1783
+ * map, an array of sources, or one source string
1784
+ * @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
1785
+ */
1786
+ export function lintEditability(manifest, files) {
1787
+ const sources = _bundleSources(files);
1788
+ if (sources.length === 0) return { ok: true, findings: [] };
1789
+ const codes = sources.map((s) => _stripNonCode(s, { keepStrings: true }));
1790
+ const masks = sources.map((s) => _stripNonCode(s, { keepStrings: false }));
1791
+ const findings = [
1792
+ ..._styleGroupFindings(manifest),
1793
+ ..._styleCoverageFindings(manifest, codes),
1794
+ ..._textLiteralFindings(_textLiteralOccurrences(codes, masks)),
1795
+ ];
1796
+ // Every rule here is a warning by construction: the CLI must stay exit-0 for
1797
+ // a human author, while the agent blocks on any finding regardless.
1798
+ return { ok: true, findings };
1799
+ }
1800
+
1545
1801
  export function lintSource(source, options) {
1546
1802
  if (typeof source !== "string") {
1547
1803
  return {
@@ -59,10 +59,8 @@ function normaliseNavigation(navigation) {
59
59
  }
60
60
 
61
61
  /**
62
- * How many MENU pages the chrome may draw at once, or `null` for no cap.
63
- *
64
- * Where the chrome IS the menu (`bottom-tabs`) this cap must never drop a page:
65
- * the surplus moves behind a More sheet. Contrast `quickBarCap` below.
62
+ * How many MENU pages the chrome may draw at once, or `null` for no cap. The
63
+ * sidebar has none. Contrast `quickBarCap` below.
66
64
  *
67
65
  * @param {string} menuType — a resolved menu type.
68
66
  * @returns {number|null}
@@ -90,13 +88,17 @@ function quickBarCap(menuType) {
90
88
  }
91
89
 
92
90
  // The strip's surface was hard-coded white on BOTH hosts and its items read the
93
- // sidebar's tokens — which is fine while it is the sidebar's secondary quick bar
94
- // and fatal once it IS the menu (`bottom-tabs` hides the sidebar panel, so those
95
- // tokens have nowhere to be set). `footer` gives it tokens of its own.
91
+ // sidebar's tokens. `footer` gives the quick bar tokens of its own.
92
+ //
93
+ // Every colour falls back to the sidebar's, so a workspace that never opens the
94
+ // panel keeps exactly the appearance it has today; only an explicit `footer`
95
+ // value moves anything.
96
96
  //
97
- // Every field falls back to the sidebar's, so a workspace that never opens the
98
- // Footer panel keeps exactly the appearance it has today; only an explicit
99
- // `footer` value moves anything.
97
+ // sc-7695: this block is the BOTTOM EDGE's, not the quick bar's alone. A page's
98
+ // own bottom bar (REQ-NAV-LOCAL) is the same strip worn by one page and reads
99
+ // exactly these values — the Design panel that edits them is named for that
100
+ // bar, since the quick bar is on its way out (sc-7753) and the look outlives
101
+ // it. No key is the bar's alone: two strips, one look.
100
102
  function resolveFooterTokens(theme) {
101
103
  const config = isPlainObject(theme) ? theme : {};
102
104
  const footer = isPlainObject(config.footer) ? config.footer : {};
@@ -176,11 +178,6 @@ function pageColor(config, alphaRule) {
176
178
  );
177
179
  }
178
180
 
179
- // An `attached` tab: opaque, or the bar shows through the page it joins.
180
- function pageSurface(config) {
181
- return pageColor(config, opaqueOr);
182
- }
183
-
184
181
  // sc-6596: what an UNSET rail or bar is painted. Read at full strength — an
185
182
  // alpha on the bottom layer would mean nothing to chrome above it.
186
183
  function chromeSurface(config) {
@@ -223,12 +220,6 @@ function tabIndicatorOr(value) {
223
220
  return Math.min(Math.max(Math.round(value), 0), TAB_INDICATOR_MAX);
224
221
  }
225
222
 
226
- function opaqueOr(value) {
227
- return isHexColor(value) && value.length !== 9 && value.length !== 5
228
- ? value
229
- : null;
230
- }
231
-
232
223
  // A gradient stop as the page readers admit one: flat hex, never alpha.
233
224
  function flatHexOrNull(value) {
234
225
  return isHexColor(value) && value.length !== 9 ? value : null;
@@ -330,18 +321,15 @@ function resolveTopBarTokens(theme) {
330
321
  // surface and meets the page beneath it, so the two read as one plane. Meaningless on a shape that draws no tab row — a host
331
322
  // reads it only where it has tabs to draw.
332
323
  tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
333
- // The surface an `attached` tab AND its content panel share. One value for
334
- // both, so the join cannot come apart: whatever the current tab is painted,
335
- // the page beneath it is painted too. An authored active surface therefore
336
- // moves both. Resolved HERE rather than by each host, because a tab that
337
- // fails to match the page it is joined to reads as a mismatched box instead
338
- // of a tab, and two hosts computing it separately is exactly how that
339
- // drifts. Opaque by construction: a translucent tab would show the bar
340
- // through the page it claims to be part of.
341
- contentSurface: hexOr(
342
- opaqueOr(topBar.tabActiveBackgroundColor),
343
- pageSurface(config),
344
- ),
324
+ // The surface the PANEL behind an `attached` row paints — the authored
325
+ // active colour, alpha and all, or nothing. Under `attached` the current
326
+ // tab itself paints NOTHING: it is a hole in the bar, and what shows
327
+ // through it is this panel, or the page's own wash when this is null. So
328
+ // the tab is exactly as see-through as the page, a gradient stays one
329
+ // gradient, and a translucent page never doubles up under a tab (sc-7352 —
330
+ // painting the tab a copy of the page could never do any of that). One
331
+ // value, resolved HERE, so both hosts paint the panel the same colour.
332
+ contentSurface: hexOrNull(topBar.tabActiveBackgroundColor),
345
333
  // REQ-NAV-STRUCTURE: the underline that marks the current tab, in px. Zero
346
334
  // turns it OFF and leaves the tab marked by its label colour alone, which
347
335
  // is a real choice rather than an unstyled state — so unlike the chrome
@@ -360,9 +348,9 @@ function resolveTopBarTokens(theme) {
360
348
  // and the bar shows through it, which is what the row did before the keys
361
349
  // existed — so an app that never set them is unchanged.
362
350
  tabBackgroundColor: hexOrNull(topBar.tabBackgroundColor),
363
- // Only meaningful where the style draws no panel: under `attached` the
364
- // active surface IS `contentSurface` above. Null under `underline` leaves
365
- // the current tab unpainted, marked by its indicator alone.
351
+ // Under `underline` the current tab's own surface, or null for none. Under
352
+ // `attached` the tab paints nothing and this colour is the PANEL's — see
353
+ // `contentSurface` above.
366
354
  tabActiveBackgroundColor: hexOrNull(topBar.tabActiveBackgroundColor),
367
355
  };
368
356
  }
@@ -50,10 +50,8 @@ export function normaliseNavigation(navigation) {
50
50
  }
51
51
 
52
52
  /**
53
- * How many MENU pages the chrome may draw at once, or `null` for no cap.
54
- *
55
- * Where the chrome IS the menu (`bottom-tabs`) this cap must never drop a page:
56
- * the surplus moves behind a More sheet. Contrast `quickBarCap` below.
53
+ * How many MENU pages the chrome may draw at once, or `null` for no cap. The
54
+ * sidebar has none. Contrast `quickBarCap` below.
57
55
  *
58
56
  * @param {string} menuType — a resolved menu type.
59
57
  * @returns {number|null}
@@ -81,13 +79,17 @@ export function quickBarCap(menuType) {
81
79
  }
82
80
 
83
81
  // The strip's surface was hard-coded white on BOTH hosts and its items read the
84
- // sidebar's tokens — which is fine while it is the sidebar's secondary quick bar
85
- // and fatal once it IS the menu (`bottom-tabs` hides the sidebar panel, so those
86
- // tokens have nowhere to be set). `footer` gives it tokens of its own.
82
+ // sidebar's tokens. `footer` gives the quick bar tokens of its own.
83
+ //
84
+ // Every colour falls back to the sidebar's, so a workspace that never opens the
85
+ // panel keeps exactly the appearance it has today; only an explicit `footer`
86
+ // value moves anything.
87
87
  //
88
- // Every field falls back to the sidebar's, so a workspace that never opens the
89
- // Footer panel keeps exactly the appearance it has today; only an explicit
90
- // `footer` value moves anything.
88
+ // sc-7695: this block is the BOTTOM EDGE's, not the quick bar's alone. A page's
89
+ // own bottom bar (REQ-NAV-LOCAL) is the same strip worn by one page and reads
90
+ // exactly these values — the Design panel that edits them is named for that
91
+ // bar, since the quick bar is on its way out (sc-7753) and the look outlives
92
+ // it. No key is the bar's alone: two strips, one look.
91
93
  export function resolveFooterTokens(theme) {
92
94
  const config = isPlainObject(theme) ? theme : {};
93
95
  const footer = isPlainObject(config.footer) ? config.footer : {};
@@ -167,11 +169,6 @@ function pageColor(config, alphaRule) {
167
169
  );
168
170
  }
169
171
 
170
- // An `attached` tab: opaque, or the bar shows through the page it joins.
171
- function pageSurface(config) {
172
- return pageColor(config, opaqueOr);
173
- }
174
-
175
172
  // sc-6596: what an UNSET rail or bar is painted. Read at full strength — an
176
173
  // alpha on the bottom layer would mean nothing to chrome above it.
177
174
  function chromeSurface(config) {
@@ -214,12 +211,6 @@ function tabIndicatorOr(value) {
214
211
  return Math.min(Math.max(Math.round(value), 0), TAB_INDICATOR_MAX);
215
212
  }
216
213
 
217
- function opaqueOr(value) {
218
- return isHexColor(value) && value.length !== 9 && value.length !== 5
219
- ? value
220
- : null;
221
- }
222
-
223
214
  // A gradient stop as the page readers admit one: flat hex, never alpha.
224
215
  function flatHexOrNull(value) {
225
216
  return isHexColor(value) && value.length !== 9 ? value : null;
@@ -321,18 +312,15 @@ export function resolveTopBarTokens(theme) {
321
312
  // surface and meets the page beneath it, so the two read as one plane. Meaningless on a shape that draws no tab row — a host
322
313
  // reads it only where it has tabs to draw.
323
314
  tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
324
- // The surface an `attached` tab AND its content panel share. One value for
325
- // both, so the join cannot come apart: whatever the current tab is painted,
326
- // the page beneath it is painted too. An authored active surface therefore
327
- // moves both. Resolved HERE rather than by each host, because a tab that
328
- // fails to match the page it is joined to reads as a mismatched box instead
329
- // of a tab, and two hosts computing it separately is exactly how that
330
- // drifts. Opaque by construction: a translucent tab would show the bar
331
- // through the page it claims to be part of.
332
- contentSurface: hexOr(
333
- opaqueOr(topBar.tabActiveBackgroundColor),
334
- pageSurface(config),
335
- ),
315
+ // The surface the PANEL behind an `attached` row paints — the authored
316
+ // active colour, alpha and all, or nothing. Under `attached` the current
317
+ // tab itself paints NOTHING: it is a hole in the bar, and what shows
318
+ // through it is this panel, or the page's own wash when this is null. So
319
+ // the tab is exactly as see-through as the page, a gradient stays one
320
+ // gradient, and a translucent page never doubles up under a tab (sc-7352 —
321
+ // painting the tab a copy of the page could never do any of that). One
322
+ // value, resolved HERE, so both hosts paint the panel the same colour.
323
+ contentSurface: hexOrNull(topBar.tabActiveBackgroundColor),
336
324
  // REQ-NAV-STRUCTURE: the underline that marks the current tab, in px. Zero
337
325
  // turns it OFF and leaves the tab marked by its label colour alone, which
338
326
  // is a real choice rather than an unstyled state — so unlike the chrome
@@ -351,9 +339,9 @@ export function resolveTopBarTokens(theme) {
351
339
  // and the bar shows through it, which is what the row did before the keys
352
340
  // existed — so an app that never set them is unchanged.
353
341
  tabBackgroundColor: hexOrNull(topBar.tabBackgroundColor),
354
- // Only meaningful where the style draws no panel: under `attached` the
355
- // active surface IS `contentSurface` above. Null under `underline` leaves
356
- // the current tab unpainted, marked by its indicator alone.
342
+ // Under `underline` the current tab's own surface, or null for none. Under
343
+ // `attached` the tab paints nothing and this colour is the PANEL's — see
344
+ // `contentSurface` above.
357
345
  tabActiveBackgroundColor: hexOrNull(topBar.tabActiveBackgroundColor),
358
346
  };
359
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.139.0",
3
+ "version": "0.141.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",
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "scripts": {
51
51
  "build": "node scripts/build.js",
52
- "test": "node --test src/__tests__/contract.test.js src/__tests__/theme-token-refs.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-flex-basis-percent.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/flatten-entry.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/overlay-tokens.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-camera.test.js src/__tests__/hooks-barcode.test.js src/__tests__/hooks-image-editor.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js src/__tests__/style-group.test.js"
52
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/theme-token-refs.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-flex-basis-percent.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-editability.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/flatten-entry.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/overlay-tokens.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-camera.test.js src/__tests__/hooks-barcode.test.js src/__tests__/hooks-image-editor.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js src/__tests__/style-group.test.js"
53
53
  },
54
54
  "engines": {
55
55
  "node": ">=18"