@colixsystems/widget-sdk 0.86.0 → 0.88.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/hooks.js CHANGED
@@ -941,11 +941,29 @@ export function useGeolocation(options) {
941
941
  * error message, populated when the datastore returned a structured
942
942
  * 400/422 payload with `errors: [{ field, code }, ...]`.
943
943
  */
944
+ // sc-4986 — the datastore refusals retrying the same call can never clear.
945
+ // The default for a hand-constructed error; the mappers pass the status-derived
946
+ // answer explicitly (see isRetryableDomainStatus).
947
+ const NON_RETRYABLE_DATASTORE_CODES = new Set([
948
+ "CONSTRAINT_VIOLATION",
949
+ "FORBIDDEN",
950
+ "NOT_FOUND",
951
+ "VALIDATION",
952
+ ]);
953
+
944
954
  export class DatastoreError extends Error {
945
955
  constructor(code, message, opts) {
946
956
  super(message);
947
957
  this.name = "DatastoreError";
948
958
  this.code = code;
959
+ // sc-4986 — the HTTP status behind the code, when there was one. null for a
960
+ // transport failure or a locally-raised wiring error.
961
+ this.status =
962
+ opts && typeof opts.status === "number" ? opts.status : null;
963
+ this.retryable =
964
+ opts && opts.retryable !== undefined
965
+ ? Boolean(opts.retryable)
966
+ : !NON_RETRYABLE_DATASTORE_CODES.has(code);
949
967
  if (opts && opts.fieldErrors && typeof opts.fieldErrors === "object") {
950
968
  this.fieldErrors = opts.fieldErrors;
951
969
  }
@@ -960,50 +978,113 @@ export class DatastoreError extends Error {
960
978
  * a DatastoreError with a stable `.code`. Reads `error.response.status` if
961
979
  * present (axios shape) and falls back to inspecting the message string.
962
980
  */
981
+ /* ===========================================================================
982
+ * sc-4986 — reading a refusal's real reason off a rejected domain-client call
983
+ * ==========================================================================*/
984
+
985
+ /**
986
+ * The one envelope read the three data-domain mappers share.
987
+ *
988
+ * A rejected call arrives in one of TWO shapes and both have to be read, which
989
+ * is the whole bug this exists to fix:
990
+ *
991
+ * - the `@colixsystems/*-client` packages throw their own typed errors
992
+ * (`ForbiddenError`, `ValidationError`, …) carrying `.code` / `.status` /
993
+ * `.details` — the parsed envelope — and **no `.response`**;
994
+ * - the older host path throws an axios-shaped error with `.response`.
995
+ *
996
+ * Reading only `.response` (what these mappers used to do) meant every typed
997
+ * client rejection fell through every branch and became `INTERNAL`, so a 403
998
+ * was indistinguishable from a dropped socket.
999
+ *
1000
+ * `status` is the signal the code is derived from, never the client's own
1001
+ * `.code`: an axios transport failure carries `code: "ECONNABORTED"`, which is
1002
+ * not a domain code and must never be mistaken for one. The typed client errors
1003
+ * always set a numeric `.status`, and axios keeps its status on `.response`, so
1004
+ * the two reads together cover both shapes.
1005
+ */
1006
+ function readDomainErrorEnvelope(err) {
1007
+ const response = (err && err.response) || null;
1008
+ const body = (response && response.data) || (err && err.details) || null;
1009
+ const status =
1010
+ response && typeof response.status === "number"
1011
+ ? response.status
1012
+ : err && typeof err.status === "number" && err.status
1013
+ ? err.status
1014
+ : null;
1015
+ // `message` is the canonical envelope's field (REQ-GEN-05, errorResponse.ts
1016
+ // emits `{ statusCode, message, ...extras }`); `error` is the ad-hoc key a
1017
+ // few older surfaces still emit. The envelope has never carried BOTH.
1018
+ const envelopeMessage = body && (body.message || body.error);
1019
+ return {
1020
+ status,
1021
+ code: body && typeof body.code === "string" && body.code ? body.code : null,
1022
+ message: typeof envelopeMessage === "string" ? envelopeMessage : null,
1023
+ fieldErrors: readEnvelopeFieldErrors(body),
1024
+ };
1025
+ }
1026
+
1027
+ /**
1028
+ * A flat `field -> message` map from an envelope's `errors: [{ field, code,
1029
+ * message }]` array (the shape `errorResponse(res, 4xx, msg, { errors })`
1030
+ * emits). Returns undefined when the body carries none, so the caller can leave
1031
+ * the property off the error entirely.
1032
+ */
1033
+ function readEnvelopeFieldErrors(body) {
1034
+ if (!body || !Array.isArray(body.errors)) return undefined;
1035
+ const map = {};
1036
+ for (const entry of body.errors) {
1037
+ if (entry && typeof entry.field === "string") {
1038
+ map[entry.field] = entry.message || entry.code || "Invalid value";
1039
+ }
1040
+ }
1041
+ return Object.keys(map).length > 0 ? map : undefined;
1042
+ }
1043
+
1044
+ /**
1045
+ * Whether retrying the SAME data call could plausibly succeed.
1046
+ *
1047
+ * A timeout, a rate limit, a 5xx or a dropped socket is worth another attempt;
1048
+ * a refusal (403), a missing row (404), a bad body (400/422) or a conflict
1049
+ * (409) never clears until the caller, the record or the workspace changes.
1050
+ * An absent status is a transport failure, so it counts as transient.
1051
+ *
1052
+ * Deliberately NOT shared with `NON_RETRYABLE_PAYMENT_CODES`: a payment's 402
1053
+ * DECLINED IS worth another attempt with a different card, so the payments
1054
+ * contract genuinely differs here (CLAUDE.md §3 — the divergence is the point,
1055
+ * not an accident).
1056
+ */
1057
+ function isRetryableDomainStatus(status) {
1058
+ if (status === null) return true;
1059
+ if (status === 408 || status === 429) return true;
1060
+ return status >= 500;
1061
+ }
1062
+
963
1063
  function toDatastoreError(err) {
1064
+ // An already-mapped SDK error passes straight through. NOTE: the datastore
1065
+ // CLIENT exports its own class of the same name, so a client rejection does
1066
+ // NOT match here — it is handled by the envelope read below, which is exactly
1067
+ // what used to be missing (sc-4986).
964
1068
  if (err instanceof DatastoreError) return err;
965
- const status =
966
- err && err.response && typeof err.response.status === "number"
967
- ? err.response.status
968
- : null;
969
- const bodyMessage =
970
- err &&
971
- err.response &&
972
- err.response.data &&
973
- typeof err.response.data.error === "string"
974
- ? err.response.data.error
975
- : null;
976
- const fallbackMessage =
977
- bodyMessage ||
978
- (err && typeof err.message === "string"
979
- ? err.message
980
- : "Datastore call failed");
1069
+ const { status, message, fieldErrors } = readDomainErrorEnvelope(err);
981
1070
  let code = "INTERNAL";
982
1071
  if (status === 400 || status === 422) code = "VALIDATION";
983
1072
  else if (status === 409) code = "CONSTRAINT_VIOLATION";
984
1073
  else if (status === 403) code = "FORBIDDEN";
985
1074
  else if (status === 404) code = "NOT_FOUND";
986
- // Surface a structured fieldErrors map when the server emitted one
987
- // (record.controller.js shapes 422 bodies as `{ errors: [{ field, code }] }`).
988
- let fieldErrors;
989
- if (
990
- err &&
991
- err.response &&
992
- err.response.data &&
993
- Array.isArray(err.response.data.errors)
994
- ) {
995
- const map = {};
996
- for (const entry of err.response.data.errors) {
997
- if (entry && typeof entry.field === "string") {
998
- map[entry.field] = entry.message || entry.code || "Invalid value";
999
- }
1000
- }
1001
- if (Object.keys(map).length > 0) fieldErrors = map;
1002
- }
1003
- return new DatastoreError(code, fallbackMessage, {
1004
- cause: err,
1005
- fieldErrors,
1006
- });
1075
+ return new DatastoreError(
1076
+ code,
1077
+ message ||
1078
+ (err && typeof err.message === "string"
1079
+ ? err.message
1080
+ : "Datastore call failed"),
1081
+ {
1082
+ cause: err,
1083
+ status,
1084
+ retryable: isRetryableDomainStatus(status),
1085
+ fieldErrors,
1086
+ },
1087
+ );
1007
1088
  }
1008
1089
 
1009
1090
  // sc-1579 — consecutive renders with a different serialized query before
@@ -1422,43 +1503,54 @@ export function useDatastoreMutation(table) {
1422
1503
  * - "CONFLICT" — 409 (e.g. trying to edit a template-derived row).
1423
1504
  * - "INTERNAL" — anything else (network, 5xx).
1424
1505
  */
1506
+ // sc-4986 — the permission refusals retrying the same call can never clear.
1507
+ // This mapper's code set is OPEN (the server's own code wins), so the status is
1508
+ // the primary signal and this set only backstops a hand-constructed error.
1509
+ const NON_RETRYABLE_PERMISSION_CODES = new Set([
1510
+ "CONFLICT",
1511
+ "FORBIDDEN",
1512
+ "NOT_FOUND",
1513
+ "TEMPLATE_DERIVED",
1514
+ "VALIDATION",
1515
+ ]);
1516
+
1425
1517
  export class PermissionError extends Error {
1426
1518
  constructor(code, message, opts) {
1427
1519
  super(message);
1428
1520
  this.name = "PermissionError";
1429
1521
  this.code = code;
1430
1522
  if (opts && opts.status !== undefined) this.status = opts.status;
1523
+ // sc-4986 — lets a widget tell "this will never work" from "try again".
1524
+ this.retryable =
1525
+ opts && opts.retryable !== undefined
1526
+ ? Boolean(opts.retryable)
1527
+ : !NON_RETRYABLE_PERMISSION_CODES.has(code);
1431
1528
  if (opts && opts.cause) this.cause = opts.cause;
1432
1529
  }
1433
1530
  }
1434
1531
 
1435
1532
  function toPermissionError(err) {
1533
+ // See the note in toDatastoreError — a client rejection is read from the
1534
+ // envelope below rather than matching this instanceof.
1436
1535
  if (err instanceof PermissionError) return err;
1437
- const status =
1438
- err && err.response && typeof err.response.status === "number"
1439
- ? err.response.status
1440
- : null;
1441
- const bodyCode =
1442
- err && err.response && err.response.data && err.response.data.code;
1443
- const bodyMessage =
1444
- err && err.response && err.response.data && err.response.data.error;
1536
+ const { status, code: envelopeCode, message } = readDomainErrorEnvelope(err);
1445
1537
  let code = "INTERNAL";
1446
1538
  if (status === 403) code = "FORBIDDEN";
1447
1539
  else if (status === 404) code = "NOT_FOUND";
1448
1540
  else if (status === 409) code = "CONFLICT";
1449
1541
  else if (status === 400 || status === 422) code = "VALIDATION";
1450
- if (typeof bodyCode === "string" && bodyCode) {
1451
- // Preserve the server's stable code over the status-derived one when
1452
- // the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
1453
- // an inherit/template row).
1454
- code = bodyCode;
1455
- }
1456
- const message =
1457
- (typeof bodyMessage === "string" && bodyMessage) ||
1458
- (err && typeof err.message === "string"
1459
- ? err.message
1460
- : "Record permission call failed");
1461
- return new PermissionError(code, message, { status, cause: err });
1542
+ // Preserve the server's stable code over the status-derived one when the
1543
+ // server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of an
1544
+ // inherit/template row) this mapper's documented code set is open.
1545
+ if (envelopeCode) code = envelopeCode;
1546
+ return new PermissionError(
1547
+ code,
1548
+ message ||
1549
+ (err && typeof err.message === "string"
1550
+ ? err.message
1551
+ : "Record permission call failed"),
1552
+ { cause: err, status, retryable: isRetryableDomainStatus(status) },
1553
+ );
1462
1554
  }
1463
1555
 
1464
1556
  const _NOOP_PERMISSIONS_RESULT = Object.freeze({
@@ -2727,41 +2819,53 @@ export function useFolderPermissions(folderId, options = {}) {
2727
2819
  * is invite-only and the email is not on the list.
2728
2820
  * - "INTERNAL" — anything else (network, 5xx).
2729
2821
  */
2822
+ // sc-4986 — the directory refusals retrying the same call can never clear.
2823
+ const NON_RETRYABLE_DIRECTORY_CODES = new Set([
2824
+ "FORBIDDEN",
2825
+ "INVITE_ONLY",
2826
+ "NOT_FOUND",
2827
+ "VALIDATION",
2828
+ ]);
2829
+
2730
2830
  export class DirectoryError extends Error {
2731
2831
  constructor(code, message, opts) {
2732
2832
  super(message);
2733
2833
  this.name = "DirectoryError";
2734
2834
  this.code = code;
2835
+ // sc-4986 — the HTTP status behind the code, when there was one.
2836
+ this.status =
2837
+ opts && typeof opts.status === "number" ? opts.status : null;
2838
+ this.retryable =
2839
+ opts && opts.retryable !== undefined
2840
+ ? Boolean(opts.retryable)
2841
+ : !NON_RETRYABLE_DIRECTORY_CODES.has(code);
2735
2842
  if (opts && opts.cause) this.cause = opts.cause;
2736
2843
  }
2737
2844
  }
2738
2845
 
2739
2846
  function toDirectoryError(err) {
2847
+ // See the note in toDatastoreError: the directory CLIENT has a same-named
2848
+ // class, so a client rejection is read from the envelope below, not here.
2740
2849
  if (err instanceof DirectoryError) return err;
2741
- const status =
2742
- err && err.response && typeof err.response.status === "number"
2743
- ? err.response.status
2744
- : null;
2745
- const bodyCode =
2746
- err && err.response && err.response.data && err.response.data.code;
2747
- const bodyMessage =
2748
- err && err.response && err.response.data && err.response.data.error;
2850
+ const { status, code: envelopeCode, message } = readDomainErrorEnvelope(err);
2749
2851
  let code = "INTERNAL";
2750
- if (bodyCode === "INVITE_ONLY") code = "INVITE_ONLY";
2852
+ if (envelopeCode === "INVITE_ONLY") code = "INVITE_ONLY";
2751
2853
  else if (status === 403) code = "FORBIDDEN";
2752
2854
  else if (status === 404) code = "NOT_FOUND";
2753
2855
  else if (status === 400 || status === 422) code = "VALIDATION";
2754
2856
  else if (status === 409) {
2755
2857
  // 409 is invite-only on the invite endpoints; treat the rest as
2756
2858
  // validation conflicts (duplicate email, etc.).
2757
- code = bodyCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
2859
+ code = envelopeCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
2758
2860
  }
2759
- const message =
2760
- (typeof bodyMessage === "string" && bodyMessage) ||
2761
- (err && typeof err.message === "string"
2762
- ? err.message
2763
- : "Directory call failed");
2764
- return new DirectoryError(code, message, { cause: err });
2861
+ return new DirectoryError(
2862
+ code,
2863
+ message ||
2864
+ (err && typeof err.message === "string"
2865
+ ? err.message
2866
+ : "Directory call failed"),
2867
+ { cause: err, status, retryable: isRetryableDomainStatus(status) },
2868
+ );
2765
2869
  }
2766
2870
 
2767
2871
  /**
package/dist/host.d.ts CHANGED
@@ -25,6 +25,12 @@ export type ThemeComponentStyle = Record<
25
25
  >;
26
26
  export type ThemeComponents = Record<string, ThemeComponentStyle>;
27
27
 
28
+ // REQ-THEME-ELEMENT: app-wide style values keyed by widget MANIFEST ID, then by
29
+ // that widget's own styleSchema field name. Values are structural (the
30
+ // authoritative type is the widget's own schema), so this mirrors
31
+ // ThemeComponentStyle rather than naming a closed vocabulary.
32
+ export type ThemeWidgetStyles = Record<string, ThemeComponentStyle>;
33
+
28
34
  /**
29
35
  * REQ-THEME-15 host helper: validates a raw `themeConfig.components` blob down
30
36
  * to `CONTRACT.themeComponents` — unknown scopes/tokens and malformed values are
@@ -33,6 +39,14 @@ export type ThemeComponents = Record<string, ThemeComponentStyle>;
33
39
  */
34
40
  export function normaliseThemeComponents(raw: unknown): ThemeComponents;
35
41
 
42
+ /**
43
+ * REQ-THEME-ELEMENT host helper: validates a raw `themeConfig.widgetStyles` blob.
44
+ * Structural only — the key space is the workspace's widget catalog, not the
45
+ * contract, so the authoritative field type is the widget's own styleSchema.
46
+ * Bounded by `CONTRACT.themeWidgetStyles`.
47
+ */
48
+ export function normaliseWidgetStyles(raw: unknown): ThemeWidgetStyles;
49
+
36
50
  /**
37
51
  * REQ-THEME-15 host render-boundary helper: folds the theme's per-component
38
52
  * tokens into a widget's props as `style` DEFAULTS, with the author's
@@ -41,6 +55,81 @@ export function normaliseThemeComponents(raw: unknown): ThemeComponents;
41
55
  */
42
56
  export function applyThemeComponentStyle<T = Record<string, unknown>>(
43
57
  manifestId: string,
44
- theme: { components?: ThemeComponents } | null | undefined,
58
+ theme:
59
+ | { components?: ThemeComponents; widgetStyles?: ThemeWidgetStyles }
60
+ | null
61
+ | undefined,
45
62
  props: T,
63
+ styleSchema?: Record<string, unknown> | null,
46
64
  ): T;
65
+
66
+ // sc-4939 — the host half of `useToast()`. A widget only ever calls the hook;
67
+ // these are what a platform host puts behind `WidgetContext.toast`.
68
+
69
+ export type ToastKind = "success" | "error" | "warning" | "info";
70
+
71
+ export interface ToastPayload {
72
+ kind?: ToastKind | string;
73
+ message?: string;
74
+ }
75
+
76
+ export interface HostToast {
77
+ id: string;
78
+ kind: ToastKind;
79
+ message: string;
80
+ }
81
+
82
+ /** Resolved values a host paints one toast with. `elevation` is the React
83
+ * Native style object; `boxShadow` is the CSS string derived from it. */
84
+ export interface ToastTokens {
85
+ kind: ToastKind;
86
+ accent: string;
87
+ surface: string;
88
+ text: string;
89
+ border: string;
90
+ radius: number;
91
+ padding: number;
92
+ gap: number;
93
+ stackGap: number;
94
+ accentBarWidth: number;
95
+ fontFamily?: string;
96
+ fontSize: number;
97
+ elevation: Record<string, unknown>;
98
+ boxShadow: string;
99
+ }
100
+
101
+ export interface ToastController {
102
+ /** Enqueue a toast. Returns its id, or null when the message is empty. */
103
+ show(payload: ToastPayload): string | null;
104
+ dismiss(id: string): void;
105
+ getToasts(): HostToast[];
106
+ /** Subscribe to the queue; returns an unsubscribe function. */
107
+ subscribe(listener: (toasts: HostToast[]) => void): () => void;
108
+ /** Clear every pending timer and listener. */
109
+ destroy(): void;
110
+ }
111
+
112
+ export interface ToastControllerOptions {
113
+ durationMs?: number;
114
+ maxVisible?: number;
115
+ setTimer?: (fn: () => void, ms: number) => unknown;
116
+ clearTimer?: (handle: unknown) => void;
117
+ }
118
+
119
+ export const TOAST_DEFAULTS: { durationMs: number; maxVisible: number };
120
+
121
+ export function normalizeToastKind(kind: unknown): ToastKind;
122
+
123
+ export function resolveToastTokens(
124
+ theme: unknown,
125
+ kind: ToastKind | string,
126
+ ): ToastTokens;
127
+
128
+ /**
129
+ * The host-side toast queue: newest-first stacking capped at `maxVisible`,
130
+ * auto-dismiss after `durationMs`, injectable timers. Shared by the web Player
131
+ * and the compiler's native WidgetHost so the two cannot drift.
132
+ */
133
+ export function createToastController(
134
+ opts?: ToastControllerOptions,
135
+ ): ToastController;
package/dist/host.js CHANGED
@@ -18,5 +18,24 @@ export { resolveProps } from "./property-schema.js";
18
18
  // hosts, so the Player and the Expo export cannot diverge.
19
19
  export {
20
20
  normaliseThemeComponents,
21
+ normaliseWidgetStyles,
21
22
  applyThemeComponentStyle,
22
23
  } from "./theme-components.js";
24
+
25
+ // REQ-THEME-SURFACE: the surface-token derivation both hosts apply per painted
26
+ // surface — the page background and every container that carries its own fill.
27
+ // Host plumbing, deliberately off the author entry point: a widget reads the
28
+ // already-correct `useTheme().colors`, it never derives them itself.
29
+ export { deriveSurfaceTokens } from "./contract.js";
30
+
31
+ // REQ-WSDK-PLATFORM §6: the host half of `useToast()` — the queue, the
32
+ // auto-dismiss timing, and the themed values a toast is painted with. The
33
+ // widget-facing hook only forwards to `ctx.toast.showToast`; the host owns the
34
+ // surface, and sharing everything but the JSX is what keeps the web Player and
35
+ // the Expo export rendering the same notification (CLAUDE.md §8).
36
+ export {
37
+ createToastController,
38
+ resolveToastTokens,
39
+ normalizeToastKind,
40
+ TOAST_DEFAULTS,
41
+ } from "./toast-host.js";
package/dist/index.d.ts CHANGED
@@ -1275,11 +1275,22 @@ export class DatastoreError extends Error {
1275
1275
  | "NOT_FOUND"
1276
1276
  | "INTERNAL";
1277
1277
  fieldErrors?: Record<string, string>;
1278
+ /** sc-4986 — the HTTP status behind the code; null for a transport failure. */
1279
+ status: number | null;
1280
+ /**
1281
+ * sc-4986 — whether retrying the SAME call could plausibly succeed. False
1282
+ * for a refusal only the caller, the record or the workspace can clear
1283
+ * (403/404/400/422/409); true for a timeout, a rate limit, a 5xx or a
1284
+ * dropped socket. Branch on this instead of offering a blanket retry.
1285
+ */
1286
+ retryable: boolean;
1278
1287
  constructor(
1279
1288
  code: DatastoreError["code"],
1280
1289
  message: string,
1281
1290
  opts?: {
1282
1291
  fieldErrors?: Record<string, string>;
1292
+ status?: number | null;
1293
+ retryable?: boolean;
1283
1294
  cause?: unknown;
1284
1295
  },
1285
1296
  );
@@ -1297,10 +1308,18 @@ export class DirectoryError extends Error {
1297
1308
  | "NOT_FOUND"
1298
1309
  | "INVITE_ONLY"
1299
1310
  | "INTERNAL";
1311
+ /** sc-4986 — the HTTP status behind the code; null for a transport failure. */
1312
+ status: number | null;
1313
+ /** sc-4986 — whether retrying the SAME call could plausibly succeed. */
1314
+ retryable: boolean;
1300
1315
  constructor(
1301
1316
  code: DirectoryError["code"],
1302
1317
  message: string,
1303
- opts?: { cause?: unknown },
1318
+ opts?: {
1319
+ status?: number | null;
1320
+ retryable?: boolean;
1321
+ cause?: unknown;
1322
+ },
1304
1323
  );
1305
1324
  }
1306
1325
 
@@ -1620,10 +1639,16 @@ export class PermissionError extends Error {
1620
1639
  | "INTERNAL"
1621
1640
  | string;
1622
1641
  status?: number;
1642
+ /** sc-4986 — whether retrying the SAME call could plausibly succeed. */
1643
+ retryable: boolean;
1623
1644
  constructor(
1624
1645
  code: PermissionError["code"],
1625
1646
  message: string,
1626
- opts?: { status?: number; cause?: unknown },
1647
+ opts?: {
1648
+ status?: number | null;
1649
+ retryable?: boolean;
1650
+ cause?: unknown;
1651
+ },
1627
1652
  );
1628
1653
  }
1629
1654
 
package/dist/index.js CHANGED
@@ -99,5 +99,7 @@ export {
99
99
  deriveAccentTints,
100
100
  gradientAngleToVector,
101
101
  normaliseComponentGradient,
102
+ clampSpacingScale,
103
+ scaleSpacing,
102
104
  } from "./contract.js";
103
105
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -97,5 +97,7 @@ export {
97
97
  deriveAccentTints,
98
98
  gradientAngleToVector,
99
99
  normaliseComponentGradient,
100
+ clampSpacingScale,
101
+ scaleSpacing,
100
102
  } from "./contract.js";
101
103
  export { normalizeLucideIconName } from "./lucideIconName.js";
package/dist/linter.cjs CHANGED
@@ -845,6 +845,37 @@ function _paymentErrorHandlingRules(source) {
845
845
  ];
846
846
  }
847
847
 
848
+ // sc-4986 — soft warning, the datastore twin of `payment-error-not-branched`.
849
+ // A write can be refused for reasons a retry never clears (the table's grants,
850
+ // a validation failure, a row that is gone), and the server sends a user-safe
851
+ // sentence saying which. A widget that collapses every rejection into one
852
+ // generic "something went wrong" throws that sentence away and leaves the user
853
+ // pressing the button again. Reading `DatastoreError.retryable`, branching on
854
+ // `code ===`, or rendering the error's own `.message` all satisfy it; strings
855
+ // and comments are blanked so prose about errors never does.
856
+ function _datastoreErrorHandlingRules(source) {
857
+ const code = _stripNonCode(source);
858
+ const call = /\buseDatastoreMutation\s*\(/.exec(code);
859
+ if (!call) return [];
860
+ if (/\bretryable\b/.test(code)) return [];
861
+ if (/\bcode\s*===/.test(code)) return [];
862
+ if (/\.message\b/.test(code)) return [];
863
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
864
+ return [
865
+ {
866
+ rule: "datastore-error-not-branched",
867
+ severity: "warning",
868
+ label:
869
+ `writes with useDatastoreMutation() but never reports why a write ` +
870
+ `failed — read DatastoreError.retryable (or err.message) and show the ` +
871
+ `server's reason, so a refusal the user cannot clear is not offered ` +
872
+ `as "try again".`,
873
+ line,
874
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
875
+ },
876
+ ];
877
+ }
878
+
848
879
  function _imagePercentHeightRules(source) {
849
880
  const findings = [];
850
881
  const code = _stripNonCode(source, { keepStrings: true });
@@ -1012,6 +1043,7 @@ function lintSource(source, options) {
1012
1043
  findings.push(..._paymentCurrencyRules(source));
1013
1044
  findings.push(..._hardcodedCurrencyLabelRules(source));
1014
1045
  findings.push(..._paymentErrorHandlingRules(source));
1046
+ findings.push(..._datastoreErrorHandlingRules(source));
1015
1047
  findings.push(
1016
1048
  ..._scopeRules(source, options && options.manifest).map((f) => ({
1017
1049
  ...f,
package/dist/linter.js CHANGED
@@ -977,6 +977,37 @@ function _paymentErrorHandlingRules(source) {
977
977
  ];
978
978
  }
979
979
 
980
+ // sc-4986 — soft warning, the datastore twin of `payment-error-not-branched`.
981
+ // A write can be refused for reasons a retry never clears (the table's grants,
982
+ // a validation failure, a row that is gone), and the server sends a user-safe
983
+ // sentence saying which. A widget that collapses every rejection into one
984
+ // generic "something went wrong" throws that sentence away and leaves the user
985
+ // pressing the button again. Reading `DatastoreError.retryable`, branching on
986
+ // `code ===`, or rendering the error's own `.message` all satisfy it; strings
987
+ // and comments are blanked so prose about errors never does.
988
+ function _datastoreErrorHandlingRules(source) {
989
+ const code = _stripNonCode(source);
990
+ const call = /\buseDatastoreMutation\s*\(/.exec(code);
991
+ if (!call) return [];
992
+ if (/\bretryable\b/.test(code)) return [];
993
+ if (/\bcode\s*===/.test(code)) return [];
994
+ if (/\.message\b/.test(code)) return [];
995
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
996
+ return [
997
+ {
998
+ rule: "datastore-error-not-branched",
999
+ severity: "warning",
1000
+ label:
1001
+ `writes with useDatastoreMutation() but never reports why a write ` +
1002
+ `failed — read DatastoreError.retryable (or err.message) and show the ` +
1003
+ `server's reason, so a refusal the user cannot clear is not offered ` +
1004
+ `as "try again".`,
1005
+ line,
1006
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1007
+ },
1008
+ ];
1009
+ }
1010
+
980
1011
  function _imagePercentHeightRules(source) {
981
1012
  const findings = [];
982
1013
  // Comments are blanked (string contents kept) so a commented-out example —
@@ -1174,6 +1205,7 @@ export function lintSource(source, options) {
1174
1205
  findings.push(..._paymentCurrencyRules(source));
1175
1206
  findings.push(..._hardcodedCurrencyLabelRules(source));
1176
1207
  findings.push(..._paymentErrorHandlingRules(source));
1208
+ findings.push(..._datastoreErrorHandlingRules(source));
1177
1209
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
1178
1210
  // line-by-line scan so banned-identifier findings stay first in the
1179
1211
  // output.