@colixsystems/widget-sdk 0.97.0 → 0.99.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/dist/host.d.ts CHANGED
@@ -133,3 +133,52 @@ export function resolveToastTokens(
133
133
  export function createToastController(
134
134
  opts?: ToastControllerOptions,
135
135
  ): ToastController;
136
+
137
+ // REQ-NAV-STRUCTURE — the navigation SHAPE a host draws its chrome in. The
138
+ // vocabulary is closed by `CONTRACT.themeMenuTypes`; these are the resolvers
139
+ // both hosts switch on.
140
+
141
+ export type ThemeMenuType = "sidebar" | "top-bar" | "bottom-tabs";
142
+
143
+ export interface ResolvedNavigation {
144
+ menuType: ThemeMenuType;
145
+ }
146
+
147
+ /**
148
+ * Resolves a stored `theme_config.navigation` block. Always returns a usable
149
+ * shape — a junk, partial, or absent input yields the default sidebar, so a
150
+ * host never has to guard the value it switches on.
151
+ */
152
+ export function normaliseNavigation(navigation: unknown): ResolvedNavigation;
153
+
154
+ /**
155
+ * How many menu pages this shape may draw at once, or `null` for no cap. The
156
+ * cap's meaning differs per type: the sidebar's quick bar may drop a page past
157
+ * it, a bottom-tabs strip must move its surplus behind a More sheet.
158
+ */
159
+ export function menuItemCap(menuType: string): number | null;
160
+
161
+ /**
162
+ * How many pages the SECONDARY mobile quick bar draws, or `null` where the shape
163
+ * draws none (only `sidebar` has one). Unlike `menuItemCap` this cap MAY drop a
164
+ * page: the rail and drawer still list every menu page.
165
+ */
166
+ export function quickBarCap(menuType: string): number | null;
167
+
168
+ /** The footer strip's resolved tokens. A `null` colour means the host keeps its
169
+ * own default; a `null` `borderColor` means no divider is drawn at all. */
170
+ export interface FooterTokens {
171
+ backgroundColor: string | null;
172
+ textColor: string | null;
173
+ activeColor: string | null;
174
+ borderColor: string | null;
175
+ borderWidth: number | null;
176
+ activeStyle: "filled" | "accent";
177
+ }
178
+
179
+ /**
180
+ * Resolves the footer strip's tokens from a whole `theme_config`. Every field
181
+ * falls back to the sidebar's, so a workspace that never opens the Footer panel
182
+ * renders exactly as it did before the block existed.
183
+ */
184
+ export function resolveFooterTokens(theme: unknown): FooterTokens;
package/dist/host.js CHANGED
@@ -39,3 +39,19 @@ export {
39
39
  normalizeToastKind,
40
40
  TOAST_DEFAULTS,
41
41
  } from "./toast-host.js";
42
+
43
+ // REQ-NAV-STRUCTURE: the navigation-shape resolver both hosts draw their chrome
44
+ // from. `normaliseNavigation` turns a stored `theme_config.navigation` block into
45
+ // the resolved `{ menuType }` the chrome switches on; `menuItemCap` states how
46
+ // many menu pages that shape may draw at once. One implementation, so a menu
47
+ // type cannot mean one thing in the Player and another in the Expo export.
48
+ // `resolveFooterTokens` is the footer strip's own token set, falling back to
49
+ // the sidebar's so an untouched workspace keeps the appearance it has. It
50
+ // exists because the strip IS the menu under `bottom-tabs`, where the sidebar
51
+ // panel is hidden and those tokens can no longer be set at all.
52
+ export {
53
+ normaliseNavigation,
54
+ menuItemCap,
55
+ quickBarCap,
56
+ resolveFooterTokens,
57
+ } from "./navigation.js";
package/dist/index.d.ts CHANGED
@@ -385,6 +385,27 @@ export interface DatastoreClient {
385
385
  get(tableIdOrName: string): Promise<unknown>;
386
386
  };
387
387
  schema(tableId: string): Promise<unknown>;
388
+ /**
389
+ * sc-5206 — REQ-ACL-COMPOSER-GATING: the caller's effective table-level
390
+ * (+ optional per-record) permissions. Backs `useCanWrite`.
391
+ */
392
+ myPermissions(
393
+ tableId: string,
394
+ options?: { recordId?: string },
395
+ ): Promise<{
396
+ can_read_schema: boolean;
397
+ can_create: boolean;
398
+ can_read_records: boolean;
399
+ can_read: boolean;
400
+ can_write: boolean;
401
+ can_delete: boolean;
402
+ record?: {
403
+ can_read: boolean;
404
+ can_write: boolean;
405
+ can_delete: boolean;
406
+ can_grant: boolean;
407
+ };
408
+ }>;
388
409
  records(tableId: string): {
389
410
  list(query?: Query): Promise<ListEnvelope>;
390
411
  get(recordId: string): Promise<unknown>;
@@ -971,6 +992,52 @@ export function useDatastoreSchema(
971
992
  tableId: string | null | undefined,
972
993
  ): SchemaResult;
973
994
 
995
+ /** sc-5206 — one prop key `useBoundColumns` resolves to a schema column. */
996
+ export interface BoundColumnSpec {
997
+ dataType?: string | string[];
998
+ /** When true, an unresolved key is omitted from `missing`. */
999
+ optional?: boolean;
1000
+ }
1001
+
1002
+ export type BoundColumnShape = Record<string, BoundColumnSpec>;
1003
+
1004
+ export interface BoundColumnsResult {
1005
+ /** Resolved column NAME per key — drop-in for `record[props.titleField]`. */
1006
+ columns: Record<string, string | undefined>;
1007
+ /** The full Column object per key, as `useDatastoreSchema` returns it. */
1008
+ resolved: Record<string, DatastoreSchemaColumn | undefined>;
1009
+ /** Keys whose spec is not `optional: true` and did not resolve. */
1010
+ missing: string[];
1011
+ loading: boolean;
1012
+ error: DatastoreError | null;
1013
+ }
1014
+
1015
+ /**
1016
+ * sc-5206 — resolve author-bound column NAMES from a widget's own props,
1017
+ * built on `useDatastoreSchema`. Falls back name -> case-insensitive name ->
1018
+ * first unclaimed column matching `dataType`, so a column an author renamed
1019
+ * after install still resolves. Never throws for a missing column — an
1020
+ * unresolved key reads `undefined` and, when not `optional: true`, is named
1021
+ * in `missing`. Falsy `tableId` collapses to `{ columns: {}, resolved: {},
1022
+ * missing: Object.keys(shape), loading: false, error: null }`.
1023
+ */
1024
+ export function useBoundColumns<TProps = Record<string, unknown>>(
1025
+ tableId: string | null | undefined,
1026
+ shape: BoundColumnShape,
1027
+ props: TProps,
1028
+ ): BoundColumnsResult;
1029
+
1030
+ /**
1031
+ * sc-5206 — keep whatever `buildQuery()` returns at a STABLE reference across
1032
+ * renders when its (JSON-serialised) content hasn't changed, so it can be
1033
+ * passed straight into `useDatastoreQuery`'s second argument instead of a
1034
+ * hand-rolled `useMemo` with an easy-to-get-wrong deps array. Pure React
1035
+ * state — no host / WidgetContext required. Never throws: a `buildQuery`
1036
+ * that throws degrades to a stable `undefined`; a result that can't be
1037
+ * diffed (e.g. circular) degrades to "always a new reference".
1038
+ */
1039
+ export function useStableQuery<T = unknown>(buildQuery: () => T): T | undefined;
1040
+
974
1041
  /** sc-4932 — one field a draft may target, with a dropdown's closed option set. */
975
1042
  export interface InterpretDraftField {
976
1043
  column: string;
@@ -1852,6 +1919,34 @@ export function useRecordPermissions(
1852
1919
  recordId: string | null | undefined,
1853
1920
  ): RecordPermissionsResult;
1854
1921
 
1922
+ export interface CanWriteOptions {
1923
+ recordId?: string;
1924
+ }
1925
+
1926
+ export interface CanWriteResult {
1927
+ canWrite: boolean;
1928
+ loading: boolean;
1929
+ error: DatastoreError | null;
1930
+ refetch(): Promise<void>;
1931
+ }
1932
+
1933
+ /**
1934
+ * sc-5206 — is the signed-in caller permitted to write to `tableId` (or, with
1935
+ * `options.recordId`, to that one row)? A FLOOR, not a full replacement for
1936
+ * domain-specific write rules: a widget whose rule is more specific than the
1937
+ * table ACL (e.g. "only the assigned user may edit this row") must still
1938
+ * hand-check that in addition to this hook. Answers "signed in AND
1939
+ * permitted" — pair with `useUser()` to also tell "not signed in" apart from
1940
+ * "signed in but forbidden". Falsy `tableId`, or a host that has not
1941
+ * injected `ctx.datastore.myPermissions`, collapses to `{ canWrite: false,
1942
+ * loading: false, error: null, refetch: async () => undefined }` rather than
1943
+ * throwing.
1944
+ */
1945
+ export function useCanWrite(
1946
+ tableId: string | null | undefined,
1947
+ options?: CanWriteOptions,
1948
+ ): CanWriteResult;
1949
+
1855
1950
  export function WidgetContextProvider(props: {
1856
1951
  value: WidgetContext;
1857
1952
  children?: ReactNode;
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ export {
15
15
  useDatastoreQuery,
16
16
  useDatastoreRecord,
17
17
  useDatastoreSchema,
18
+ useBoundColumns,
19
+ useStableQuery,
20
+ useCanWrite,
18
21
  useInterpretDraft,
19
22
  useAsset,
20
23
  useAssetsByTag,
@@ -15,6 +15,9 @@ export {
15
15
  useDatastoreQuery,
16
16
  useDatastoreRecord,
17
17
  useDatastoreSchema,
18
+ useBoundColumns,
19
+ useStableQuery,
20
+ useCanWrite,
18
21
  useInterpretDraft,
19
22
  useAsset,
20
23
  useAssetsByTag,
package/dist/linter.cjs CHANGED
@@ -944,6 +944,71 @@ function _datastoreErrorHandlingRules(source) {
944
944
  ];
945
945
  }
946
946
 
947
+ // sc-5206 — soft warning: a `useDatastoreQuery` argument built from a raw
948
+ // `const q = useMemo(() => ({...}), [...])` re-fetches in a loop the moment
949
+ // the deps array is wrong (sc-1579's volatile-query gate exists because of
950
+ // exactly this mistake). Flags only when the file has NOT already reached
951
+ // for `useStableQuery`, the hook that removes the deps array entirely.
952
+ function _rawUseMemoIntoDatastoreQueryRules(source) {
953
+ const code = _stripNonCode(source);
954
+ if (/\buseStableQuery\s*\(/.test(code)) return [];
955
+ const callRe = /\buseDatastoreQuery\s*\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*[,)]/g;
956
+ const findings = [];
957
+ const sourceLines = source.split(/\r?\n/);
958
+ const seen = new Set();
959
+ let m;
960
+ while ((m = callRe.exec(code))) {
961
+ const varName = m[1];
962
+ if (seen.has(varName)) continue;
963
+ const memoRe = new RegExp(`\\bconst\\s+${varName}\\s*=\\s*useMemo\\s*\\(`);
964
+ const memoMatch = memoRe.exec(code);
965
+ if (!memoMatch || memoMatch.index >= m.index) continue;
966
+ seen.add(varName);
967
+ const line = code.slice(0, memoMatch.index).split(/\r?\n/).length;
968
+ findings.push({
969
+ rule: "raw-useMemo-into-datastore-query",
970
+ severity: "warning",
971
+ label:
972
+ `Pass useStableQuery(() => ({...})) as the query argument instead ` +
973
+ `of hand-rolling useMemo — it removes the wrong-deps-array failure ` +
974
+ `mode.`,
975
+ line,
976
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
977
+ });
978
+ }
979
+ return findings;
980
+ }
981
+
982
+ // sc-5206 — soft warning, the write-gate twin of `write-not-gated-on-user`.
983
+ // A widget that hand-derives write permission from `useUser().groupIds` /
984
+ // `.roles` re-implements the table ACL client-side and drifts from it the
985
+ // moment the ACL changes; `useCanWrite(tableId)` reads the same rule the
986
+ // write endpoint enforces. Flags only when the file has NOT already reached
987
+ // for it.
988
+ function _handRolledWriteGateRules(source) {
989
+ const code = _stripNonCode(source);
990
+ if (/\buseCanWrite\s*\(/.test(code)) return [];
991
+ const userCall = /\buseUser\s*\(\s*\)/.exec(code);
992
+ if (!userCall) return [];
993
+ if (!/\.(groupIds|roles)\b/.test(code)) return [];
994
+ const hasWrite =
995
+ /\.(create|update)\s*\(/.test(code) ||
996
+ /\buseDatastoreMutation\s*\(/.test(code);
997
+ if (!hasWrite) return [];
998
+ const line = code.slice(0, userCall.index).split(/\r?\n/).length;
999
+ return [
1000
+ {
1001
+ rule: "hand-rolled-write-gate",
1002
+ severity: "warning",
1003
+ label:
1004
+ `Gate this write on useCanWrite(tableId) as the permission FLOOR — ` +
1005
+ `IN ADDITION TO (never instead of) a narrower roles/groupIds rule.`,
1006
+ line,
1007
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1008
+ },
1009
+ ];
1010
+ }
1011
+
947
1012
  function _imagePercentHeightRules(source) {
948
1013
  const findings = [];
949
1014
  const code = _stripNonCode(source, { keepStrings: true });
@@ -1113,6 +1178,9 @@ function lintSource(source, options) {
1113
1178
  findings.push(..._hardcodedCurrencyLabelRules(source));
1114
1179
  findings.push(..._paymentErrorHandlingRules(source));
1115
1180
  findings.push(..._datastoreErrorHandlingRules(source));
1181
+ // sc-5206 — soft warnings steering a widget toward the two new hooks.
1182
+ findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
1183
+ findings.push(..._handRolledWriteGateRules(source));
1116
1184
  findings.push(
1117
1185
  ..._scopeRules(source, options && options.manifest).map((f) => ({
1118
1186
  ...f,
package/dist/linter.js CHANGED
@@ -1077,6 +1077,71 @@ function _datastoreErrorHandlingRules(source) {
1077
1077
  ];
1078
1078
  }
1079
1079
 
1080
+ // sc-5206 — soft warning: a `useDatastoreQuery` argument built from a raw
1081
+ // `const q = useMemo(() => ({...}), [...])` re-fetches in a loop the moment
1082
+ // the deps array is wrong (sc-1579's volatile-query gate exists because of
1083
+ // exactly this mistake). Flags only when the file has NOT already reached
1084
+ // for `useStableQuery`, the hook that removes the deps array entirely.
1085
+ function _rawUseMemoIntoDatastoreQueryRules(source) {
1086
+ const code = _stripNonCode(source);
1087
+ if (/\buseStableQuery\s*\(/.test(code)) return [];
1088
+ const callRe = /\buseDatastoreQuery\s*\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*[,)]/g;
1089
+ const findings = [];
1090
+ const sourceLines = source.split(/\r?\n/);
1091
+ const seen = new Set();
1092
+ let m;
1093
+ while ((m = callRe.exec(code))) {
1094
+ const varName = m[1];
1095
+ if (seen.has(varName)) continue;
1096
+ const memoRe = new RegExp(`\\bconst\\s+${varName}\\s*=\\s*useMemo\\s*\\(`);
1097
+ const memoMatch = memoRe.exec(code);
1098
+ if (!memoMatch || memoMatch.index >= m.index) continue;
1099
+ seen.add(varName);
1100
+ const line = code.slice(0, memoMatch.index).split(/\r?\n/).length;
1101
+ findings.push({
1102
+ rule: "raw-useMemo-into-datastore-query",
1103
+ severity: "warning",
1104
+ label:
1105
+ `Pass useStableQuery(() => ({...})) as the query argument instead ` +
1106
+ `of hand-rolling useMemo — it removes the wrong-deps-array failure ` +
1107
+ `mode.`,
1108
+ line,
1109
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
1110
+ });
1111
+ }
1112
+ return findings;
1113
+ }
1114
+
1115
+ // sc-5206 — soft warning, the write-gate twin of `write-not-gated-on-user`.
1116
+ // A widget that hand-derives write permission from `useUser().groupIds` /
1117
+ // `.roles` re-implements the table ACL client-side and drifts from it the
1118
+ // moment the ACL changes; `useCanWrite(tableId)` reads the same rule the
1119
+ // write endpoint enforces. Flags only when the file has NOT already reached
1120
+ // for it.
1121
+ function _handRolledWriteGateRules(source) {
1122
+ const code = _stripNonCode(source);
1123
+ if (/\buseCanWrite\s*\(/.test(code)) return [];
1124
+ const userCall = /\buseUser\s*\(\s*\)/.exec(code);
1125
+ if (!userCall) return [];
1126
+ if (!/\.(groupIds|roles)\b/.test(code)) return [];
1127
+ const hasWrite =
1128
+ /\.(create|update)\s*\(/.test(code) ||
1129
+ /\buseDatastoreMutation\s*\(/.test(code);
1130
+ if (!hasWrite) return [];
1131
+ const line = code.slice(0, userCall.index).split(/\r?\n/).length;
1132
+ return [
1133
+ {
1134
+ rule: "hand-rolled-write-gate",
1135
+ severity: "warning",
1136
+ label:
1137
+ `Gate this write on useCanWrite(tableId) as the permission FLOOR — ` +
1138
+ `IN ADDITION TO (never instead of) a narrower roles/groupIds rule.`,
1139
+ line,
1140
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1141
+ },
1142
+ ];
1143
+ }
1144
+
1080
1145
  function _imagePercentHeightRules(source) {
1081
1146
  const findings = [];
1082
1147
  // Comments are blanked (string contents kept) so a commented-out example —
@@ -1276,6 +1341,9 @@ export function lintSource(source, options) {
1276
1341
  findings.push(..._hardcodedCurrencyLabelRules(source));
1277
1342
  findings.push(..._paymentErrorHandlingRules(source));
1278
1343
  findings.push(..._datastoreErrorHandlingRules(source));
1344
+ // sc-5206 — soft warnings steering a widget toward the two new hooks.
1345
+ findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
1346
+ findings.push(..._handRolledWriteGateRules(source));
1279
1347
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
1280
1348
  // line-by-line scan so banned-identifier findings stay first in the
1281
1349
  // output.
@@ -0,0 +1,124 @@
1
+ // CommonJS mirror of navigation.js — the CJS compiler and the Mason build runner
2
+ // resolve a `theme_config.navigation` block with the SAME rules the web host
3
+ // applies, so an app's menu type cannot mean one thing on web and another in the
4
+ // exported Expo app.
5
+ //
6
+ // The BODY below is copied VERBATIM from navigation.js; only the module syntax
7
+ // differs. navigation-parity.test.js pins both facts — identical bodies AND
8
+ // identical behaviour over a shared case table — so drift fails CI.
9
+
10
+ // REQ-NAV-STRUCTURE — the host side of an app's navigation SHAPE and the
11
+ // tokens its footer strip is painted with.
12
+ //
13
+ // Host-integration surface, re-exported from `host.js`: consumed by the platform
14
+ // hosts that draw an app's chrome (the web PlayerChrome, the compiler that emits
15
+ // the exported Expo app's navigator) and by the Studio surface that authors it —
16
+ // never by a widget author.
17
+ //
18
+ // Living here — one implementation both hosts import — is what makes a menu type
19
+ // behave identically in the Player and the export (widget-parity skill). The
20
+ // alternative, a resolver copied per host, is the drift this file exists to
21
+ // prevent.
22
+ //
23
+ // `CONTRACT.themeMenuTypes` is the single source of the vocabulary: which shapes
24
+ // exist and each one's item cap. Mason's `set_theme` coercion validates against
25
+ // the SAME literal, so what a planner may persist and what a host draws cannot
26
+ // diverge.
27
+
28
+ const { CONTRACT } = require("./contract.cjs");
29
+
30
+ // Absent or unknown resolves here, so every app authored before menu types
31
+ // existed renders and compiles byte-identically.
32
+ const DEFAULT_MENU_TYPE = "sidebar";
33
+
34
+ function isPlainObject(value) {
35
+ return value !== null && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+
38
+ /**
39
+ * Resolve a `theme_config.navigation` block to the shape both hosts read.
40
+ *
41
+ * Always returns a fully resolved object — a junk, partial, or absent input
42
+ * yields the default sidebar rather than something a host has to guard against.
43
+ *
44
+ * @param {unknown} navigation — the raw `theme_config.navigation` value.
45
+ * @returns {{ menuType: string }} the resolved navigation structure.
46
+ */
47
+ function normaliseNavigation(navigation) {
48
+ const raw = isPlainObject(navigation) ? navigation.menuType : undefined;
49
+ const menuType =
50
+ typeof raw === "string" && Object.hasOwn(CONTRACT.themeMenuTypes, raw)
51
+ ? raw
52
+ : DEFAULT_MENU_TYPE;
53
+ return { menuType };
54
+ }
55
+
56
+ /**
57
+ * How many MENU pages the chrome may draw at once, or `null` for no cap.
58
+ *
59
+ * Where the chrome IS the menu (`bottom-tabs`) this cap must never drop a page:
60
+ * the surplus moves behind a More sheet. Contrast `quickBarCap` below.
61
+ *
62
+ * @param {string} menuType — a resolved menu type.
63
+ * @returns {number|null}
64
+ */
65
+ function menuItemCap(menuType) {
66
+ const entry = CONTRACT.themeMenuTypes[menuType];
67
+ return entry ? entry.maxItems : null;
68
+ }
69
+
70
+ /**
71
+ * How many pages the SECONDARY mobile quick bar draws, or `null` where that
72
+ * shape draws none. Only the `sidebar` shape has one.
73
+ *
74
+ * This cap MAY drop a page, and that is the difference from `menuItemCap`: the
75
+ * rail and the drawer still list every menu page, so the bar is a shortcut
76
+ * rather than the menu. It lived as a constant on one host and a literal on the
77
+ * other before this, which is exactly how the two would have drifted.
78
+ *
79
+ * @param {string} menuType — a resolved menu type.
80
+ * @returns {number|null}
81
+ */
82
+ function quickBarCap(menuType) {
83
+ const entry = CONTRACT.themeMenuTypes[menuType];
84
+ return entry ? entry.quickBarMaxItems : null;
85
+ }
86
+
87
+ // The strip's surface was hard-coded white on BOTH hosts and its items read the
88
+ // sidebar's tokens — which is fine while it is the sidebar's secondary quick bar
89
+ // and fatal once it IS the menu (`bottom-tabs` hides the sidebar panel, so those
90
+ // tokens have nowhere to be set). `footer` gives it tokens of its own.
91
+ //
92
+ // Every field falls back to the sidebar's, so a workspace that never opens the
93
+ // Footer panel keeps exactly the appearance it has today; only an explicit
94
+ // `footer` value moves anything.
95
+ function resolveFooterTokens(theme) {
96
+ const config = isPlainObject(theme) ? theme : {};
97
+ const footer = isPlainObject(config.footer) ? config.footer : {};
98
+ const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
99
+ const pick = (key) => footer[key] || sidebar[key] || null;
100
+ const borderColor = pick("borderColor");
101
+ const activeStyle = footer.activeStyle || sidebar.activeStyle;
102
+ return {
103
+ backgroundColor: pick("backgroundColor"),
104
+ textColor: pick("textColor"),
105
+ // Unlike the others this has no null state on either host: an unset active
106
+ // colour resolves to the brand primary, which the caller supplies.
107
+ activeColor: pick("activeColor"),
108
+ // REQ-THEME-LOOK: the divider's COLOUR is its switch. Unset draws no line at
109
+ // all — which the native quick bar did not honour before this, ruling a
110
+ // hairline across a tinted strip that had asked for none.
111
+ borderColor,
112
+ borderWidth: borderColor ? borderWidthOr(footer.borderWidth, sidebar.borderWidth) : null,
113
+ activeStyle: activeStyle === "accent" ? "accent" : "filled",
114
+ };
115
+ }
116
+
117
+ function borderWidthOr(...values) {
118
+ for (const value of values) {
119
+ if (typeof value === "number" && Number.isFinite(value)) return value;
120
+ }
121
+ return 1;
122
+ }
123
+
124
+ module.exports = { normaliseNavigation, menuItemCap, quickBarCap, resolveFooterTokens };
@@ -0,0 +1,113 @@
1
+ // REQ-NAV-STRUCTURE — the host side of an app's navigation SHAPE and the
2
+ // tokens its footer strip is painted with.
3
+ //
4
+ // Host-integration surface, re-exported from `host.js`: consumed by the platform
5
+ // hosts that draw an app's chrome (the web PlayerChrome, the compiler that emits
6
+ // the exported Expo app's navigator) and by the Studio surface that authors it —
7
+ // never by a widget author.
8
+ //
9
+ // Living here — one implementation both hosts import — is what makes a menu type
10
+ // behave identically in the Player and the export (widget-parity skill). The
11
+ // alternative, a resolver copied per host, is the drift this file exists to
12
+ // prevent.
13
+ //
14
+ // `CONTRACT.themeMenuTypes` is the single source of the vocabulary: which shapes
15
+ // exist and each one's item cap. Mason's `set_theme` coercion validates against
16
+ // the SAME literal, so what a planner may persist and what a host draws cannot
17
+ // diverge.
18
+
19
+ import { CONTRACT } from "./contract.js";
20
+
21
+ // Absent or unknown resolves here, so every app authored before menu types
22
+ // existed renders and compiles byte-identically.
23
+ const DEFAULT_MENU_TYPE = "sidebar";
24
+
25
+ function isPlainObject(value) {
26
+ return value !== null && typeof value === "object" && !Array.isArray(value);
27
+ }
28
+
29
+ /**
30
+ * Resolve a `theme_config.navigation` block to the shape both hosts read.
31
+ *
32
+ * Always returns a fully resolved object — a junk, partial, or absent input
33
+ * yields the default sidebar rather than something a host has to guard against.
34
+ *
35
+ * @param {unknown} navigation — the raw `theme_config.navigation` value.
36
+ * @returns {{ menuType: string }} the resolved navigation structure.
37
+ */
38
+ export function normaliseNavigation(navigation) {
39
+ const raw = isPlainObject(navigation) ? navigation.menuType : undefined;
40
+ const menuType =
41
+ typeof raw === "string" && Object.hasOwn(CONTRACT.themeMenuTypes, raw)
42
+ ? raw
43
+ : DEFAULT_MENU_TYPE;
44
+ return { menuType };
45
+ }
46
+
47
+ /**
48
+ * How many MENU pages the chrome may draw at once, or `null` for no cap.
49
+ *
50
+ * Where the chrome IS the menu (`bottom-tabs`) this cap must never drop a page:
51
+ * the surplus moves behind a More sheet. Contrast `quickBarCap` below.
52
+ *
53
+ * @param {string} menuType — a resolved menu type.
54
+ * @returns {number|null}
55
+ */
56
+ export function menuItemCap(menuType) {
57
+ const entry = CONTRACT.themeMenuTypes[menuType];
58
+ return entry ? entry.maxItems : null;
59
+ }
60
+
61
+ /**
62
+ * How many pages the SECONDARY mobile quick bar draws, or `null` where that
63
+ * shape draws none. Only the `sidebar` shape has one.
64
+ *
65
+ * This cap MAY drop a page, and that is the difference from `menuItemCap`: the
66
+ * rail and the drawer still list every menu page, so the bar is a shortcut
67
+ * rather than the menu. It lived as a constant on one host and a literal on the
68
+ * other before this, which is exactly how the two would have drifted.
69
+ *
70
+ * @param {string} menuType — a resolved menu type.
71
+ * @returns {number|null}
72
+ */
73
+ export function quickBarCap(menuType) {
74
+ const entry = CONTRACT.themeMenuTypes[menuType];
75
+ return entry ? entry.quickBarMaxItems : null;
76
+ }
77
+
78
+ // The strip's surface was hard-coded white on BOTH hosts and its items read the
79
+ // sidebar's tokens — which is fine while it is the sidebar's secondary quick bar
80
+ // and fatal once it IS the menu (`bottom-tabs` hides the sidebar panel, so those
81
+ // tokens have nowhere to be set). `footer` gives it tokens of its own.
82
+ //
83
+ // Every field falls back to the sidebar's, so a workspace that never opens the
84
+ // Footer panel keeps exactly the appearance it has today; only an explicit
85
+ // `footer` value moves anything.
86
+ export function resolveFooterTokens(theme) {
87
+ const config = isPlainObject(theme) ? theme : {};
88
+ const footer = isPlainObject(config.footer) ? config.footer : {};
89
+ const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
90
+ const pick = (key) => footer[key] || sidebar[key] || null;
91
+ const borderColor = pick("borderColor");
92
+ const activeStyle = footer.activeStyle || sidebar.activeStyle;
93
+ return {
94
+ backgroundColor: pick("backgroundColor"),
95
+ textColor: pick("textColor"),
96
+ // Unlike the others this has no null state on either host: an unset active
97
+ // colour resolves to the brand primary, which the caller supplies.
98
+ activeColor: pick("activeColor"),
99
+ // REQ-THEME-LOOK: the divider's COLOUR is its switch. Unset draws no line at
100
+ // all — which the native quick bar did not honour before this, ruling a
101
+ // hairline across a tinted strip that had asked for none.
102
+ borderColor,
103
+ borderWidth: borderColor ? borderWidthOr(footer.borderWidth, sidebar.borderWidth) : null,
104
+ activeStyle: activeStyle === "accent" ? "accent" : "filled",
105
+ };
106
+ }
107
+
108
+ function borderWidthOr(...values) {
109
+ for (const value of values) {
110
+ if (typeof value === "number" && Number.isFinite(value)) return value;
111
+ }
112
+ return 1;
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.97.0",
3
+ "version": "0.99.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.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-image-height.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__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.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"
51
+ "test": "node --test src/__tests__/contract.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-image-height.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__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.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-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"