@colixsystems/widget-sdk 0.158.0 → 0.160.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
@@ -43,8 +43,8 @@ The data layer lives in **four separate domain-client packages**, each instantia
43
43
  | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
44
44
  | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
45
45
  | **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
46
- | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
47
- | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
46
+ | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
47
+ | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching` | `records(table).get` — `datastore.read:<table>` |
48
48
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
49
49
  | **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
50
50
  | **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
@@ -73,7 +73,22 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
73
73
 
74
74
  ## Status
75
75
 
76
- `v0.157.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**.
76
+ `v0.160.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**.
77
+
78
+ ### What's new in 0.160.0 (contract 1.123.0)
79
+
80
+ **`useDatastoreQuery` and `useDatastoreRecord` keep their data on screen while they re-fetch (sc-8595).** `loading` used to turn true on every fetch, so a widget that showed a spinner on `loading` flashed its content away whenever its query changed — most visibly when a sort or filter waited on `useBoundColumns`, where the first rows arrived before the schema and were replaced by the spinner a moment later. `loading` is now true only until the first result for the bound table (or record) lands. A changed query on that table, `refetch()` and pull-to-refresh keep the rows already in `data` with `loading: false`; the new **`fetching`** flag is true while any request is in flight. Binding a different table, or a different record id, still resets to `loading`.
81
+
82
+ Show a spinner on `loading` only. A query built from a bound column should pass `null` as the table until `useBoundColumns(...).loading` is false, so its first fetch already carries the column — and treat BOTH as loading, because the query reports `loading: false` while its table is still `null`:
83
+
84
+ ```js
85
+ const { columns: bound, loading: columnsLoading } = useBoundColumns(tableId, shape, props);
86
+ const query = useStableQuery(() => ({ sort: { field: bound.publishedAtField, dir: "desc" }, limit: 5 }));
87
+ const { data, loading } = useDatastoreQuery(columnsLoading ? null : tableId, query);
88
+ if (columnsLoading || loading) return <Spinner />;
89
+ ```
90
+
91
+ Behaviour change: a widget that deliberately showed a spinner on every re-fetch now keeps its previous rows until the new ones arrive. `fetching` is there for an inline "updating" hint.
77
92
 
78
93
  ### What's new in 0.157.0 (contract 1.122.0 — unchanged)
79
94
 
@@ -128,6 +143,12 @@ Additive — nothing in the existing `./host` surface changed, and the contract
128
143
 
129
144
  ### What's new in 0.151.0 (contract 1.118.0 — unchanged)
130
145
 
146
+ **`resolveSidebarTokens` answers `activeInk` — the rail's active colour made readable as TEXT (sc-8655, REQ-NAV-LOCAL / REQ-THEME-14).** `activeColor` is authored as a SURFACE: the filled style paints it and forces a white label, so nothing in the system ever pressured it to be legible as ink. Every place that paints it as text inherited that — the accent style's active label, and now the entry a folded sidebar section hands its marking to — and the brand default reads 3.5:1 on the rail, below the 4.5:1 AA asks of normal text and worse than the unmarked rows around it.
147
+
148
+ - `activeInk` is the same hue stepped toward the rail's own polarity (darker on a light rail, lighter on a dark one) only as far as AA needs, and is `activeColor` unchanged whenever that already reads. Derived, never authored: it moves with whatever colour the workspace sets, so there is no second control to keep in step.
149
+ - **Additive.** `activeColor` still names the surface the filled style paints and the colour a non-text mark takes — a dot or an accent edge answers to 3:1, not 4.5:1, so those keep the author's actual colour. `CONTRACT` is untouched (`version` stays `1.122.0`): a host resolver, not a widget-facing surface.
150
+ - Both hosts read it, so the Player, the exported drawer and the Design page's chrome preview cannot paint three different labels off one theme.
151
+
131
152
  **`resolveSidebarUnfold` joins the host navigation surface: where a page's local menu unfolds inside the app's own sidebar (sc-7696, REQ-NAV-LOCAL).** A page's local navigation could only be a bar, above or below its content. `local_nav` now also takes `SIDEBAR`, which takes no strip of the page: the saved menu unfolds in the rail and the drawer, under the global menu entry it belongs to.
132
153
 
133
154
  - `resolveSidebarUnfold({ entries, active, sections })` answers `{ under, items, standalone }` from host-neutral keys — page slugs on web, screen names in the export — so it is the ONE derivation both chromes read. Web calls it per render; the compiler asks it once per screen at build time, because the only runtime input is which screen is showing.
package/dist/contract.cjs CHANGED
@@ -913,9 +913,14 @@ const HOOKS = [
913
913
  {
914
914
  name: "useDatastoreRecord",
915
915
  signature: "useDatastoreRecord(tableId, recordId)",
916
+ description:
917
+ "Reads one row. Re-reading the same record (refetch, pull-to-refresh) " +
918
+ "keeps it in `data` with loading false while `fetching` is true; a " +
919
+ "different table or record id resets to loading.",
916
920
  returnShape: {
917
921
  data: "Record | null",
918
922
  loading: "boolean",
923
+ fetching: "boolean",
919
924
  error: "DatastoreError | null",
920
925
  refetch: "() => Promise<void>",
921
926
  },
@@ -1205,9 +1210,16 @@ const HOOKS = [
1205
1210
  name: "useDatastoreQuery",
1206
1211
  signature: "useDatastoreQuery(tableId, options)",
1207
1212
  optionalArgs: ["options"],
1213
+ description:
1214
+ "Reads a table's rows. Stale-while-revalidate: `loading` is true only " +
1215
+ "until the first result for the bound table lands; a changed query on " +
1216
+ "that table, refetch() and pull-to-refresh keep the rows already in " +
1217
+ "`data` with loading false, while `fetching` is true. Binding a " +
1218
+ "different table resets to loading. Show a spinner on `loading` only.",
1208
1219
  returnShape: {
1209
1220
  data: "Record[]",
1210
1221
  loading: "boolean",
1222
+ fetching: "boolean",
1211
1223
  error: "DatastoreError | null",
1212
1224
  refetch: "() => Promise<void>",
1213
1225
  },
@@ -4334,7 +4346,10 @@ const CONTRACT = deepFreeze({
4334
4346
  // layout it would use in a half-width cell. Additive: `isNarrowWidth` and
4335
4347
  // `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly
4336
4348
  // when `isNarrowWidth(w)` is true.
4337
- version: "1.122.0",
4349
+ // 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
4350
+ // keep their data while a changed query or refetch runs; `loading` only
4351
+ // until the first result for the table/record, new `fetching` flag.
4352
+ version: "1.123.0",
4338
4353
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4339
4354
  hooks: HOOKS,
4340
4355
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -913,9 +913,14 @@ const HOOKS = [
913
913
  {
914
914
  name: "useDatastoreRecord",
915
915
  signature: "useDatastoreRecord(tableId, recordId)",
916
+ description:
917
+ "Reads one row. Re-reading the same record (refetch, pull-to-refresh) " +
918
+ "keeps it in `data` with loading false while `fetching` is true; a " +
919
+ "different table or record id resets to loading.",
916
920
  returnShape: {
917
921
  data: "Record | null",
918
922
  loading: "boolean",
923
+ fetching: "boolean",
919
924
  error: "DatastoreError | null",
920
925
  refetch: "() => Promise<void>",
921
926
  },
@@ -1205,9 +1210,16 @@ const HOOKS = [
1205
1210
  name: "useDatastoreQuery",
1206
1211
  signature: "useDatastoreQuery(tableId, options)",
1207
1212
  optionalArgs: ["options"],
1213
+ description:
1214
+ "Reads a table's rows. Stale-while-revalidate: `loading` is true only " +
1215
+ "until the first result for the bound table lands; a changed query on " +
1216
+ "that table, refetch() and pull-to-refresh keep the rows already in " +
1217
+ "`data` with loading false, while `fetching` is true. Binding a " +
1218
+ "different table resets to loading. Show a spinner on `loading` only.",
1208
1219
  returnShape: {
1209
1220
  data: "Record[]",
1210
1221
  loading: "boolean",
1222
+ fetching: "boolean",
1211
1223
  error: "DatastoreError | null",
1212
1224
  refetch: "() => Promise<void>",
1213
1225
  },
@@ -4330,7 +4342,10 @@ const CONTRACT = deepFreeze({
4330
4342
  // layout it would use in a half-width cell. Additive: `isNarrowWidth` and
4331
4343
  // `NARROW_WIDTH_PX` are unchanged, and `widthTier(w) === "narrow"` exactly
4332
4344
  // when `isNarrowWidth(w)` is true.
4333
- version: "1.122.0",
4345
+ // 1.123.0: behaviour (sc-8595) — useDatastoreQuery / useDatastoreRecord
4346
+ // keep their data while a changed query or refetch runs; `loading` only
4347
+ // until the first result for the table/record, new `fetching` flag.
4348
+ version: "1.123.0",
4334
4349
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4335
4350
  hooks: HOOKS,
4336
4351
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -2173,8 +2173,23 @@ function toDatastoreError(err) {
2173
2173
  // on a genuine loop.
2174
2174
  const VOLATILE_QUERY_RENDER_LIMIT = 10;
2175
2175
 
2176
+ const NO_QUERY_RESULT = { key: null, table: null, data: null, error: null, loaded: false };
2177
+ const NO_RECORD_RESULT = { key: null, data: null, error: null, loaded: false };
2178
+
2179
+ function serializeQuery(query) {
2180
+ try {
2181
+ return JSON.stringify(query);
2182
+ } catch (_e) {
2183
+ return null;
2184
+ }
2185
+ }
2186
+
2187
+ const queryResultKey = (table, queryKey) => JSON.stringify([table, queryKey]);
2188
+ const recordResultKey = (table, recordId) => JSON.stringify([table, recordId]);
2189
+
2176
2190
  /**
2177
- * Stateful datastore query hook. Returns { data, loading, error, refetch }.
2191
+ * Stateful datastore query hook. Returns { data, loading, fetching, error,
2192
+ * refetch }.
2178
2193
  *
2179
2194
  * The host injects a `@colixsystems/datastore-client` instance at
2180
2195
  * `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
@@ -2187,9 +2202,18 @@ const VOLATILE_QUERY_RENDER_LIMIT = 10;
2187
2202
  * re-fetch when [table, JSON.stringify(query)] changes. `refetch` re-runs the
2188
2203
  * same call on demand.
2189
2204
  *
2205
+ * sc-8595 — stale-while-revalidate. `loading` is true only until the first
2206
+ * result for the bound TABLE lands. A changed query on that table, `refetch()`
2207
+ * and a page pull-to-refresh keep the rows already in `data` on screen with
2208
+ * `loading: false`; `fetching` is true while any request is in flight. Binding
2209
+ * a different table resets to `{ data: [], loading: true }`. So a widget shows
2210
+ * its spinner on `loading` only, and never flashes back to it when its query
2211
+ * settles (e.g. a sort that waits on `useBoundColumns`).
2212
+ *
2190
2213
  * When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
2191
- * yet), the hook resolves to { data: [], loading: false, error: null,
2192
- * refetch } so the widget can render its empty state without throwing.
2214
+ * yet), the hook resolves to { data: [], loading: false, fetching: false,
2215
+ * error: null, refetch } so the widget can render its empty state without
2216
+ * throwing.
2193
2217
  */
2194
2218
  export function useDatastoreQuery(table, query) {
2195
2219
  const ctx = useWidgetContextOrThrow("useDatastoreQuery");
@@ -2198,9 +2222,12 @@ export function useDatastoreQuery(table, query) {
2198
2222
  "useDatastoreQuery: host did not inject a datastore client",
2199
2223
  );
2200
2224
  }
2201
- const [data, setData] = useState([]);
2202
- const [loading, setLoading] = useState(Boolean(table));
2203
- const [error, setError] = useState(null);
2225
+ // sc-8595: the last settled request, stamped with the arguments it
2226
+ // answered. What renders is DERIVED from it (see the return), so no
2227
+ // committed frame shows another table's rows or a stale `loading`.
2228
+ const [settled, setSettled] = useState(NO_QUERY_RESULT);
2229
+ const [inFlight, setInFlight] = useState(false);
2230
+ const emptyRows = useRef([]).current;
2204
2231
 
2205
2232
  // Capture the latest table + query in refs so refetch() — a stable
2206
2233
  // callback identity — always reads the current arguments without having
@@ -2230,13 +2257,12 @@ export function useDatastoreQuery(table, query) {
2230
2257
  if (!t) {
2231
2258
  // No table bound yet — collapse to a stable empty result without
2232
2259
  // a network round-trip.
2233
- setLoading(false);
2234
- setError(null);
2235
- setData([]);
2260
+ setInFlight(false);
2261
+ setSettled(NO_QUERY_RESULT);
2236
2262
  return;
2237
2263
  }
2238
- setLoading(true);
2239
- setError(null);
2264
+ const key = queryResultKey(t, serializeQuery(queryRef.current));
2265
+ setInFlight(true);
2240
2266
  try {
2241
2267
  const ns = recordsRef.current(t);
2242
2268
  const res = await ns.list(queryRef.current);
@@ -2245,23 +2271,23 @@ export function useDatastoreQuery(table, query) {
2245
2271
  const rows = res && Array.isArray(res.data) ? res.data : [];
2246
2272
  // Discard the result if a newer fetch has started since we kicked off.
2247
2273
  if (runRef.current !== myRun) return;
2248
- setData(rows);
2249
- setLoading(false);
2274
+ setSettled({ key, table: t, data: rows, error: null, loaded: true });
2275
+ setInFlight(false);
2250
2276
  } catch (err) {
2251
2277
  if (runRef.current !== myRun) return;
2252
- setError(toDatastoreError(err));
2253
- setLoading(false);
2278
+ const error = toDatastoreError(err);
2279
+ // A failed re-fetch keeps the table's rows; a failed first load has none.
2280
+ setSettled((prev) =>
2281
+ prev.table === t && prev.loaded
2282
+ ? { ...prev, key, error }
2283
+ : { key, table: t, data: emptyRows, error, loaded: false },
2284
+ );
2285
+ setInFlight(false);
2254
2286
  }
2255
2287
  }, []);
2256
2288
 
2257
2289
  // Re-run on mount and whenever [table, JSON.stringify(query)] changes.
2258
- const queryKey = (() => {
2259
- try {
2260
- return JSON.stringify(query);
2261
- } catch (_e) {
2262
- return null;
2263
- }
2264
- })();
2290
+ const queryKey = serializeQuery(query);
2265
2291
 
2266
2292
  // sc-1579 — a query argument built from a volatile value (`Date.now()`,
2267
2293
  // `new Date()`, `Math.random()`) serialises differently every render, so the
@@ -2304,12 +2330,21 @@ export function useDatastoreQuery(table, query) {
2304
2330
  // pull-to-refresh on mobile re-runs the query without per-widget code.
2305
2331
  useRefresh(refetch);
2306
2332
 
2307
- return { data, loading, error, refetch };
2333
+ const currentKey = table ? queryResultKey(table, queryKey) : null;
2334
+ const busy = Boolean(table) && (settled.key !== currentKey || inFlight);
2335
+ const hasRows = Boolean(table) && settled.table === table && settled.loaded;
2336
+ return {
2337
+ data: hasRows ? settled.data : emptyRows,
2338
+ loading: !hasRows && busy,
2339
+ fetching: busy,
2340
+ error: table && !busy && settled.table === table ? settled.error : null,
2341
+ refetch,
2342
+ };
2308
2343
  }
2309
2344
 
2310
2345
  /**
2311
- * Stateful single-record query hook. Returns { data, loading, error, refetch }
2312
- * where `data` is one row (`null` until loaded, never an array).
2346
+ * Stateful single-record query hook. Returns { data, loading, fetching, error,
2347
+ * refetch } where `data` is one row (`null` until loaded, never an array).
2313
2348
  *
2314
2349
  * The host injects a `@colixsystems/datastore-client` instance at
2315
2350
  * `ctx.datastore`. We call `ctx.datastore.records(table).get(id)`, which
@@ -2318,9 +2353,13 @@ export function useDatastoreQuery(table, query) {
2318
2353
  * named them. We hold the result in component state and re-fetch when
2319
2354
  * [table, id] changes. `refetch` re-runs the call on demand.
2320
2355
  *
2356
+ * sc-8595 — same rule as useDatastoreQuery: re-reading the SAME record keeps
2357
+ * it in `data` with `loading: false` while `fetching` is true; a different
2358
+ * table or id resets to `{ data: null, loading: true }`.
2359
+ *
2321
2360
  * When `table` OR `recordId` is falsy (e.g. the author hasn't bound the
2322
2361
  * record id yet), the hook resolves to { data: null, loading: false,
2323
- * error: null, refetch } so the widget can render its empty state
2362
+ * fetching: false, error: null, refetch } so the widget can render its empty state
2324
2363
  * without throwing. A 404 surfaces as a DatastoreError with
2325
2364
  * `code: "NOT_FOUND"`.
2326
2365
  */
@@ -2331,10 +2370,10 @@ export function useDatastoreRecord(table, recordId) {
2331
2370
  "useDatastoreRecord: host did not inject a datastore client",
2332
2371
  );
2333
2372
  }
2334
- const ready = Boolean(table && recordId);
2335
- const [data, setData] = useState(null);
2336
- const [loading, setLoading] = useState(ready);
2337
- const [error, setError] = useState(null);
2373
+ // sc-8595: stamped with the record it answered; rendered values derive
2374
+ // from it, like useDatastoreQuery's.
2375
+ const [settled, setSettled] = useState(NO_RECORD_RESULT);
2376
+ const [inFlight, setInFlight] = useState(false);
2338
2377
 
2339
2378
  // Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
2340
2379
  // identity on every host render, so we hold the live inputs in refs
@@ -2353,23 +2392,27 @@ export function useDatastoreRecord(table, recordId) {
2353
2392
  const t = tableRef.current;
2354
2393
  const id = recordIdRef.current;
2355
2394
  if (!t || !id) {
2356
- setLoading(false);
2357
- setError(null);
2358
- setData(null);
2395
+ setInFlight(false);
2396
+ setSettled(NO_RECORD_RESULT);
2359
2397
  return;
2360
2398
  }
2361
- setLoading(true);
2362
- setError(null);
2399
+ const key = recordResultKey(t, id);
2400
+ setInFlight(true);
2363
2401
  try {
2364
2402
  const ns = recordsRef.current(t);
2365
2403
  const row = await ns.get(id);
2366
2404
  if (runRef.current !== myRun) return;
2367
- setData(row || null);
2368
- setLoading(false);
2405
+ setSettled({ key, data: row || null, error: null, loaded: true });
2406
+ setInFlight(false);
2369
2407
  } catch (err) {
2370
2408
  if (runRef.current !== myRun) return;
2371
- setError(toDatastoreError(err));
2372
- setLoading(false);
2409
+ const error = toDatastoreError(err);
2410
+ setSettled((prev) =>
2411
+ prev.key === key && prev.loaded
2412
+ ? { ...prev, error }
2413
+ : { key, data: null, error, loaded: false },
2414
+ );
2415
+ setInFlight(false);
2373
2416
  }
2374
2417
  }, []);
2375
2418
 
@@ -2385,7 +2428,16 @@ export function useDatastoreRecord(table, recordId) {
2385
2428
  // sc-1179 — auto-subscribe to the page-level refresh tick.
2386
2429
  useRefresh(refetch);
2387
2430
 
2388
- return { data, loading, error, refetch };
2431
+ const currentKey = table && recordId ? recordResultKey(table, recordId) : null;
2432
+ const busy = currentKey !== null && (settled.key !== currentKey || inFlight);
2433
+ const hasRecord = currentKey !== null && settled.key === currentKey && settled.loaded;
2434
+ return {
2435
+ data: hasRecord ? settled.data : null,
2436
+ loading: !hasRecord && busy,
2437
+ fetching: busy,
2438
+ error: currentKey !== null && !busy ? settled.error : null,
2439
+ refetch,
2440
+ };
2389
2441
  }
2390
2442
 
2391
2443
  /**
@@ -2770,8 +2822,8 @@ function toAgentError(err) {
2770
2822
  * who places the widget and never types costs the workspace nothing.
2771
2823
  *
2772
2824
  * `messages` is the running transcript this widget has seen, oldest first,
2773
- * each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
2774
- * An assistant message that used a tool also carries `activity` — what the
2825
+ * each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
2826
+ * An assistant message that used a tool also carries `activity` — what the
2775
2827
  * agent DID with the user's data. Render the writes: "done!" is not evidence.
2776
2828
  * The SERVER owns the real transcript and the system prompt: the widget sends
2777
2829
  * one message and can neither read nor set the prompt.
package/dist/host.d.ts CHANGED
@@ -208,7 +208,14 @@ export function resolveFooterTokens(theme: unknown): FooterTokens;
208
208
  export interface SidebarTokens {
209
209
  backgroundColor: string;
210
210
  textColor: string;
211
+ /** The active colour as a SURFACE — what `filled` paints, what a non-text
212
+ * mark (an accent edge, a folded section's dot) is drawn in. */
211
213
  activeColor: string;
214
+ /** sc-8655: the same colour as INK. `activeColor` is authored to sit under a
215
+ * white label, so it can fall below AA as text on the rail; this is it
216
+ * stepped toward the rail's own polarity until it clears 4.5:1, and equal to
217
+ * `activeColor` whenever that already reads. Derived, never authored. */
218
+ activeInk: string;
212
219
  activeStyle: "filled" | "accent";
213
220
  borderColor: string | null;
214
221
  borderWidth: number | null;
package/dist/index.d.ts CHANGED
@@ -825,7 +825,14 @@ export interface Query {
825
825
 
826
826
  export interface QueryResult<T> {
827
827
  data: T[];
828
+ /**
829
+ * True only until the first result for the bound table lands. A changed
830
+ * query on the same table, `refetch()` and pull-to-refresh keep the rows
831
+ * already in `data` with `loading: false`. Show a spinner on this only.
832
+ */
828
833
  loading: boolean;
834
+ /** True while any request is in flight, including a background re-fetch. */
835
+ fetching: boolean;
829
836
  error: Error | null;
830
837
  refetch(): Promise<void>;
831
838
  }
@@ -1042,8 +1049,10 @@ export function useTheme(): ThemeTokens;
1042
1049
  export function useWidgetStyle(): Record<string, unknown>;
1043
1050
 
1044
1051
  /**
1045
- * Stateful single-record fetch hook. Returns `{ data, loading, error,
1046
- * refetch }`. `data` is one row or `null` (never an array).
1052
+ * Stateful single-record fetch hook. Returns `{ data, loading, fetching,
1053
+ * error, refetch }`. `data` is one row or `null` (never an array). Re-reading
1054
+ * the same record keeps it in `data` with `loading: false`; a different table
1055
+ * or id resets to `loading: true`.
1047
1056
  */
1048
1057
  export function useDatastoreRecord(
1049
1058
  tableId: string | null | undefined,
@@ -1051,6 +1060,7 @@ export function useDatastoreRecord(
1051
1060
  ): {
1052
1061
  data: unknown | null;
1053
1062
  loading: boolean;
1063
+ fetching: boolean;
1054
1064
  error: DatastoreError | null;
1055
1065
  refetch(): Promise<void>;
1056
1066
  };
@@ -25,7 +25,7 @@
25
25
  // the SAME literal, so what a planner may persist and what a host draws cannot
26
26
  // diverge.
27
27
 
28
- const { CONTRACT, isHexColor } = require("./contract.cjs");
28
+ const { CONTRACT, isHexColor, contrastRatio, mixHex, readableTextColor } = require("./contract.cjs");
29
29
 
30
30
  // Absent or unknown resolves here, so every app authored before menu types
31
31
  // existed renders and compiles byte-identically.
@@ -267,14 +267,46 @@ function brandPrimary(config) {
267
267
  * @returns {{ backgroundColor: string, textColor: string, activeColor: string,
268
268
  * activeStyle: string, borderColor: string|null, borderWidth: number|null }}
269
269
  */
270
+ // sc-8655: the sidebar's active colour is authored to sit UNDER a white label
271
+ // (the filled style), so nothing ever pressured it to read as INK — the brand
272
+ // default lands at 3.5:1 on the rail, below the 4.5:1 AA asks of normal text,
273
+ // and worse than the inactive rows beside it. Every surface that paints the
274
+ // active colour as TEXT reads the derived ink instead: the same hue, stepped
275
+ // toward the rail's own polarity only as far as legibility needs.
276
+ const AA_TEXT_CONTRAST = 4.5;
277
+ const INK_STEP = 0.05;
278
+ const INK_MAX_SHIFT = 0.7;
279
+
280
+ function readableInk(color, background) {
281
+ if (!isHexColor(color) || !isHexColor(background)) return color;
282
+ if (contrastRatio(color, background) >= AA_TEXT_CONTRAST) return color;
283
+ // Darken on a light rail, lighten on a dark one — the direction that keeps
284
+ // the hue rather than washing it toward grey.
285
+ const toward = readableTextColor(background, "#000000", "#ffffff");
286
+ let ink = color;
287
+ for (let t = INK_STEP; t <= INK_MAX_SHIFT + 1e-9; t += INK_STEP) {
288
+ ink = mixHex(color, toward, t);
289
+ if (contrastRatio(ink, background) >= AA_TEXT_CONTRAST) break;
290
+ }
291
+ // A colour that cannot reach AA within the shift keeps the closest it got:
292
+ // the most readable answer available beats reverting to the unreadable one.
293
+ return ink;
294
+ }
295
+
270
296
  function resolveSidebarTokens(theme) {
271
297
  const config = isPlainObject(theme) ? theme : {};
272
298
  const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
273
299
  const borderColor = hexOrNull(sidebar.borderColor);
300
+ const backgroundColor = hexOr(sidebar.backgroundColor, chromeSurface(config));
301
+ const activeColor = hexOr(sidebar.activeColor, brandPrimary(config));
274
302
  return {
275
- backgroundColor: hexOr(sidebar.backgroundColor, chromeSurface(config)),
303
+ backgroundColor,
276
304
  textColor: hexOr(sidebar.textColor, DEFAULT_CHROME_TEXT),
277
- activeColor: hexOr(sidebar.activeColor, brandPrimary(config)),
305
+ activeColor,
306
+ // sc-8655: `activeColor` where it is a SURFACE, `activeInk` where it is
307
+ // text on the rail. Both hosts read the second for an accent-marked label
308
+ // and for a folded section's entry.
309
+ activeInk: readableInk(activeColor, backgroundColor),
278
310
  activeStyle: sidebar.activeStyle === "accent" ? "accent" : "filled",
279
311
  // REQ-THEME-LOOK: the colour is the switch — no colour, no line.
280
312
  borderColor,
@@ -16,7 +16,7 @@
16
16
  // the SAME literal, so what a planner may persist and what a host draws cannot
17
17
  // diverge.
18
18
 
19
- import { CONTRACT, isHexColor } from "./contract.js";
19
+ import { CONTRACT, isHexColor, contrastRatio, mixHex, readableTextColor } from "./contract.js";
20
20
 
21
21
  // Absent or unknown resolves here, so every app authored before menu types
22
22
  // existed renders and compiles byte-identically.
@@ -258,14 +258,46 @@ function brandPrimary(config) {
258
258
  * @returns {{ backgroundColor: string, textColor: string, activeColor: string,
259
259
  * activeStyle: string, borderColor: string|null, borderWidth: number|null }}
260
260
  */
261
+ // sc-8655: the sidebar's active colour is authored to sit UNDER a white label
262
+ // (the filled style), so nothing ever pressured it to read as INK — the brand
263
+ // default lands at 3.5:1 on the rail, below the 4.5:1 AA asks of normal text,
264
+ // and worse than the inactive rows beside it. Every surface that paints the
265
+ // active colour as TEXT reads the derived ink instead: the same hue, stepped
266
+ // toward the rail's own polarity only as far as legibility needs.
267
+ const AA_TEXT_CONTRAST = 4.5;
268
+ const INK_STEP = 0.05;
269
+ const INK_MAX_SHIFT = 0.7;
270
+
271
+ function readableInk(color, background) {
272
+ if (!isHexColor(color) || !isHexColor(background)) return color;
273
+ if (contrastRatio(color, background) >= AA_TEXT_CONTRAST) return color;
274
+ // Darken on a light rail, lighten on a dark one — the direction that keeps
275
+ // the hue rather than washing it toward grey.
276
+ const toward = readableTextColor(background, "#000000", "#ffffff");
277
+ let ink = color;
278
+ for (let t = INK_STEP; t <= INK_MAX_SHIFT + 1e-9; t += INK_STEP) {
279
+ ink = mixHex(color, toward, t);
280
+ if (contrastRatio(ink, background) >= AA_TEXT_CONTRAST) break;
281
+ }
282
+ // A colour that cannot reach AA within the shift keeps the closest it got:
283
+ // the most readable answer available beats reverting to the unreadable one.
284
+ return ink;
285
+ }
286
+
261
287
  export function resolveSidebarTokens(theme) {
262
288
  const config = isPlainObject(theme) ? theme : {};
263
289
  const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
264
290
  const borderColor = hexOrNull(sidebar.borderColor);
291
+ const backgroundColor = hexOr(sidebar.backgroundColor, chromeSurface(config));
292
+ const activeColor = hexOr(sidebar.activeColor, brandPrimary(config));
265
293
  return {
266
- backgroundColor: hexOr(sidebar.backgroundColor, chromeSurface(config)),
294
+ backgroundColor,
267
295
  textColor: hexOr(sidebar.textColor, DEFAULT_CHROME_TEXT),
268
- activeColor: hexOr(sidebar.activeColor, brandPrimary(config)),
296
+ activeColor,
297
+ // sc-8655: `activeColor` where it is a SURFACE, `activeInk` where it is
298
+ // text on the rail. Both hosts read the second for an accent-marked label
299
+ // and for a folded section's entry.
300
+ activeInk: readableInk(activeColor, backgroundColor),
269
301
  activeStyle: sidebar.activeStyle === "accent" ? "accent" : "filled",
270
302
  // REQ-THEME-LOOK: the colour is the switch — no colour, no line.
271
303
  borderColor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.158.0",
3
+ "version": "0.160.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",