@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/README.md +100 -3
- package/dist/contract.cjs +139 -4
- package/dist/contract.js +139 -4
- package/dist/hooks.js +4659 -4344
- package/dist/host.d.ts +49 -0
- package/dist/host.js +16 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/dist/linter.cjs +68 -0
- package/dist/linter.js +68 -0
- package/dist/navigation.cjs +124 -0
- package/dist/navigation.js +113 -0
- package/package.json +2 -2
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
package/dist/index.native.js
CHANGED
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.
|
|
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"
|