@colixsystems/widget-sdk 0.113.0 → 0.115.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
@@ -17,7 +17,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
17
17
 
18
18
  | Group | Hook (signature) | Returns | Reads / scope |
19
19
  | ----- | ---------------- | ------- | ------------- |
20
- | **CORE** | `useTheme()` | `{ colors, elevation, spacing, spacingScale, radii, typography, components, widgetStyles }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`) and `loader`, the spinner colour for your own loading state. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
20
+ | **CORE** | `useTheme()` | `{ colors, elevation, interaction, spacing, spacingScale, radii, typography, components, widgetStyles }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`) and `loader`, the spinner colour for your own loading state. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
21
21
  | **CORE** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
22
22
  | **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
23
23
  | **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
@@ -49,7 +49,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
49
49
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*` |
50
50
  | **DATASTORE** | `useDatastoreSubscription(table, handlers, options?)` | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
51
51
  | **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
52
- | **DATASTORE** | `useCanWrite(tableId, options?)` | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "is this caller signed in AND permitted", reading the same table-ACL answer the write endpoint enforces. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
52
+ | **DATASTORE** | `useCanWrite(tableId, options?)` | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
53
53
  | **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
54
54
  | **FILES** | `useAssetsByTag(tag, { type? })` | `{ assets, loading, error, refetch }` | `ctx.assets.list` (unwraps `{ data, meta }` to `assets`) — no scope. `type` defaults to `"image"`; pass `"all"` / `"audio"` / `"video"` / `"document"` to widen. Falsy `tag` collapses to `assets: []` without a round-trip. |
55
55
  | **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
@@ -72,6 +72,16 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
72
72
 
73
73
  `v0.112.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
74
74
 
75
+ ### What's new in 0.115.0 (contract unchanged)
76
+
77
+ **`write-not-gated-on-user` now accepts a `useCanWrite()` gate — a widget may be opened to logged-out visitors (sc-6593).** The rule (added in 0.89.0, below) flagged any `useDatastoreMutation` write that carried no identity guard, and only a `.id` / `groupIds` / `roles` check counted as one. That encoded "a write needs a signed-in app user" as a platform fact, which it is not: a table whose permissions grant **Create** to *Everyone (anonymous + signed-in)* accepts a write from a logged-out visitor, and `useCanWrite(tableId)` answers `true` for them.
78
+
79
+ So a widget gated on `useCanWrite` alone — the correct shape for a public tally, a guest sign-up sheet, or an open feedback form — used to trip the warning that steers the AI widget agent's repair loop back to identity gating, making the sign-in requirement impossible for an author to remove. It is now recognised as a gate, and its finding label names it first.
80
+
81
+ Nothing else changes: a widget with **no** gate at all is still flagged, `useUser().id` read purely as a VALUE still does not satisfy the rule, and the severity is still `warning` (never publish-blocking). Identity gating remains the right default for almost every write — this only stops the linter from arguing against the one case where it isn't.
82
+
83
+ When you take the `useCanWrite`-only route, omit the USER column for a guest (`if (user.id) payload[byField] = user.id;`) — an anonymous row records no author, so per-person limits and "my entries" views cannot work for one.
84
+
75
85
  ### What's new in 0.113.0 (contract 1.86.0)
76
86
 
77
87
  **New `useCamera()` hook — take a photo or pick one from the device library.** A new CORE hook reading a new `camera` capability on the existing `ctx.device` slice. Returns `{ asset, loading, error, supported, capture, pick, reset }`. Capture is **imperative** — call `capture()` or `pick()` from a user gesture (a `Pressable.onPress`); the browser and the mobile OS gate the permission prompt on a gesture, so it NEVER opens on mount. `options` (`{ allowsEditing, quality }`) pass through to the host. It needs **no manifest scope** and **no `requestedScopes` entry**.
@@ -1094,15 +1104,16 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
1094
1104
  - **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.lg` + `...theme.elevation.sm`. Give it the elevation **or** a `colors.border` hairline, not both — and prefer the elevation, because a hairline-only card reads as a wireframe. `theme.elevation` is a token table you spread into a style (`...theme.elevation.md`), covering `none / sm / md / lg / xl`; never hand-write `shadowOpacity` / `shadowRadius` / `boxShadow`. Use the status roles (`danger / success / warning / info`) for state.
1095
1105
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
1096
1106
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
1107
+ - **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
1097
1108
  - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget.
1098
1109
  - **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them.
1099
- - **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
1110
+ - **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
1100
1111
  - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`.
1101
1112
  - **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
1102
1113
  - **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead. The frame is your decision, never the picture's: size it for the role (a 40–56 square avatar, a 72–96 square row thumbnail, a `16 / 9` card cover, a 160–240 tall band) and let `resizeMode="cover"` crop the photo into it — photos arrive at every size and ratio, so one left to its own proportions breaks the layout. Keep `contain` for art whose whole subject must stay visible (a logo, a diagram), inside a fixed frame.
1103
1114
  - **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
1104
1115
 
1105
- **Honest ceilings:** the styling surface is React Native style objects, not full CSS. Gradients come from the `<Gradient>` primitive (not a CSS `linear-gradient` string), depth comes from `theme.elevation` (not arbitrary `box-shadow` stacks), and there are no custom CSS keyframe animations or `transition` strings, no `filter` / `backdrop-filter` / `clip-path` / `mask` / blend modes, and no opacity-faked tints (that's what `primarySoft` is for). Aim for clean, confident, professional polish within those bounds — lifted surfaces, generous corners, one accent moment.
1116
+ **Honest ceilings:** the styling surface is React Native style objects, not full CSS. Gradients come from the `<Gradient>` primitive (not a CSS `linear-gradient` string), depth comes from `theme.elevation` (not arbitrary `box-shadow` stacks), and there are no custom CSS keyframe animations or `transition` strings (the hover/press lift comes built into `pressableLift` — never write your own), no `filter` / `backdrop-filter` / `clip-path` / `mask` / blend modes, and no opacity-faked tints (that's what `primarySoft` is for). Aim for clean, confident, professional polish within those bounds — lifted surfaces, generous corners, one accent moment.
1106
1117
 
1107
1118
  ## Managing app users from a widget
1108
1119
 
package/dist/contract.cjs CHANGED
@@ -45,6 +45,24 @@ const ELEVATION = Object.freeze({
45
45
  }),
46
46
  });
47
47
 
48
+ // sc-6531 (REQ-AI-AGENT-DESIGN-LIFT): the ONE cross-platform interaction
49
+ // vocabulary. A tappable surface answers the pointer over it (web) and the
50
+ // press on it (touch) by LIFTING. `raised` is one elevation step up plus a
51
+ // nudge — the scale above stays the only shadow vocabulary — and is what a
52
+ // widget's own surface wears via `pressableLift`; `translateY` alone is the
53
+ // surface-agnostic nudge a tappable layout REGION gets on both hosts.
54
+ const INTERACTION = Object.freeze({
55
+ lift: Object.freeze({
56
+ translateY: -2,
57
+ raised: Object.freeze({
58
+ transform: Object.freeze([Object.freeze({ translateY: -2 })]),
59
+ ...ELEVATION.lg,
60
+ }),
61
+ // Web-only smoothing; a touch state change is instant.
62
+ transitionMs: 150,
63
+ }),
64
+ });
65
+
48
66
  const DEFAULT_THEME_TOKENS = Object.freeze({
49
67
  colors: Object.freeze({
50
68
  primary: "#3b82f6",
@@ -76,6 +94,7 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
76
94
  info: "#0284c7",
77
95
  }),
78
96
  elevation: ELEVATION,
97
+ interaction: INTERACTION,
79
98
  spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
80
99
  // REQ-THEME-LOOK: multiplies every layout spacing value at render. 1 is
81
100
  // unchanged, so a theme that never sets it renders exactly as before.
@@ -1740,6 +1759,17 @@ const PRIMITIVES = [
1740
1759
  rnComponent: "expo-linear-gradient",
1741
1760
  docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
1742
1761
  },
1762
+ // sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the hover/press lift affordance. A
1763
+ // FUNCTION primitive, not a component: it resolves the interaction tokens per
1764
+ // input, so one declaration answers the pointer on web and the press on
1765
+ // native.
1766
+ {
1767
+ name: "pressableLift",
1768
+ description:
1769
+ "Interaction feedback for a tappable surface. Call it inside a Pressable's style FUNCTION and spread the result: `<Pressable style={(state) => [styles.card, ...pressableLift(state)]}>`. While the pointer hovers the surface (web) or it is pressed (touch), the surface lifts: a -2px nudge plus one elevation step, from `themeTokens.interaction`, with the web transition built in. Give it to EVERY tappable card, row and list entry; never hand-write hover logic, `transition` strings, or your own pressed shadows.",
1770
+ rnComponent: null,
1771
+ docsUrl: null,
1772
+ },
1743
1773
  ];
1744
1774
 
1745
1775
  const CATEGORIES = [
@@ -3433,7 +3463,12 @@ const CONTRACT = deepFreeze({
3433
3463
  // docs/design/req-widget-sdk-cross-platform-primitives.md, which was
3434
3464
  // about vetting a full camera surface (permissions, multi-step UX, frame
3435
3465
  // processing) as a widget import — none of which this adds.
3436
- version: "1.86.0",
3466
+ // 1.87.0: additive (sc-6531) — `themeTokens.interaction`: the interaction-
3467
+ // state vocabulary (`lift.raised`, `lift.translateY`, `lift.transitionMs`),
3468
+ // plus the `pressableLift` primitive that resolves it per input — hover on
3469
+ // web, press on native. Tappable layout containers and widget surfaces lift
3470
+ // from this ONE table.
3471
+ version: "1.87.0",
3437
3472
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3438
3473
  hooks: HOOKS,
3439
3474
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -45,6 +45,24 @@ const ELEVATION = Object.freeze({
45
45
  }),
46
46
  });
47
47
 
48
+ // sc-6531 (REQ-AI-AGENT-DESIGN-LIFT): the ONE cross-platform interaction
49
+ // vocabulary. A tappable surface answers the pointer over it (web) and the
50
+ // press on it (touch) by LIFTING. `raised` is one elevation step up plus a
51
+ // nudge — the scale above stays the only shadow vocabulary — and is what a
52
+ // widget's own surface wears via `pressableLift`; `translateY` alone is the
53
+ // surface-agnostic nudge a tappable layout REGION gets on both hosts.
54
+ const INTERACTION = Object.freeze({
55
+ lift: Object.freeze({
56
+ translateY: -2,
57
+ raised: Object.freeze({
58
+ transform: Object.freeze([Object.freeze({ translateY: -2 })]),
59
+ ...ELEVATION.lg,
60
+ }),
61
+ // Web-only smoothing; a touch state change is instant.
62
+ transitionMs: 150,
63
+ }),
64
+ });
65
+
48
66
  const DEFAULT_THEME_TOKENS = Object.freeze({
49
67
  colors: Object.freeze({
50
68
  primary: "#3b82f6",
@@ -76,6 +94,7 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
76
94
  info: "#0284c7",
77
95
  }),
78
96
  elevation: ELEVATION,
97
+ interaction: INTERACTION,
79
98
  spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
80
99
  // REQ-THEME-LOOK: multiplies every layout spacing value at render. 1 is
81
100
  // unchanged, so a theme that never sets it renders exactly as before.
@@ -1740,6 +1759,17 @@ const PRIMITIVES = [
1740
1759
  rnComponent: "expo-linear-gradient",
1741
1760
  docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
1742
1761
  },
1762
+ // sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the hover/press lift affordance. A
1763
+ // FUNCTION primitive, not a component: it resolves the interaction tokens per
1764
+ // input, so one declaration answers the pointer on web and the press on
1765
+ // native.
1766
+ {
1767
+ name: "pressableLift",
1768
+ description:
1769
+ "Interaction feedback for a tappable surface. Call it inside a Pressable's style FUNCTION and spread the result: `<Pressable style={(state) => [styles.card, ...pressableLift(state)]}>`. While the pointer hovers the surface (web) or it is pressed (touch), the surface lifts: a -2px nudge plus one elevation step, from `themeTokens.interaction`, with the web transition built in. Give it to EVERY tappable card, row and list entry; never hand-write hover logic, `transition` strings, or your own pressed shadows.",
1770
+ rnComponent: null,
1771
+ docsUrl: null,
1772
+ },
1743
1773
  ];
1744
1774
 
1745
1775
  const CATEGORIES = [
@@ -3433,7 +3463,12 @@ const CONTRACT = deepFreeze({
3433
3463
  // docs/design/req-widget-sdk-cross-platform-primitives.md, which was
3434
3464
  // about vetting a full camera surface (permissions, multi-step UX, frame
3435
3465
  // processing) as a widget import — none of which this adds.
3436
- version: "1.86.0",
3466
+ // 1.87.0: additive (sc-6531) — `themeTokens.interaction`: the interaction-
3467
+ // state vocabulary (`lift.raised`, `lift.translateY`, `lift.transitionMs`),
3468
+ // plus the `pressableLift` primitive that resolves it per input — hover on
3469
+ // web, press on native. Tappable layout containers and widget surfaces lift
3470
+ // from this ONE table.
3471
+ version: "1.87.0",
3437
3472
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3438
3473
  hooks: HOOKS,
3439
3474
  primitives: PRIMITIVES,
package/dist/index.d.ts CHANGED
@@ -2312,3 +2312,15 @@ export const CONTRACT: AiWidgetContract;
2312
2312
 
2313
2313
  export function isHookAllowed(name: string): boolean;
2314
2314
  export function requiredContextKeys(): string[];
2315
+
2316
+ /**
2317
+ * sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — interaction feedback for a tappable
2318
+ * surface. Call inside a Pressable's style function and spread the result:
2319
+ * `<Pressable style={(state) => [styles.card, ...pressableLift(state)]}>`.
2320
+ * Web raises the surface while hovered or pressed (with the transition built
2321
+ * in); native raises it while pressed.
2322
+ */
2323
+ export function pressableLift(state?: {
2324
+ hovered?: boolean;
2325
+ pressed?: boolean;
2326
+ }): Array<Record<string, unknown> | null>;
package/dist/index.js CHANGED
@@ -129,3 +129,4 @@ export {
129
129
  scaleSpacing,
130
130
  } from "./contract.js";
131
131
  export { normalizeLucideIconName } from "./lucideIconName.js";
132
+ export { pressableLift } from "./interaction.js";
@@ -127,3 +127,4 @@ export {
127
127
  scaleSpacing,
128
128
  } from "./contract.js";
129
129
  export { normalizeLucideIconName } from "./lucideIconName.js";
130
+ export { pressableLift } from "./interaction.native.js";
@@ -0,0 +1,16 @@
1
+ // sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the web half of the lift affordance.
2
+ // react-native-web's Pressable hands its style function `{ hovered, pressed }`;
3
+ // either one raises the surface, and the smoothing rides the RESTING style so
4
+ // the lift animates in both directions. The touch half is interaction.native.js
5
+ // — no hover exists there, and a pressed state change is instant.
6
+ import { CONTRACT } from "./contract.js";
7
+
8
+ const LIFT = CONTRACT.themeTokens.interaction.lift;
9
+ const SMOOTHING = Object.freeze({
10
+ transitionProperty: "transform, box-shadow",
11
+ transitionDuration: `${LIFT.transitionMs}ms`,
12
+ });
13
+
14
+ export function pressableLift(state = {}) {
15
+ return [SMOOTHING, state.hovered || state.pressed ? LIFT.raised : null];
16
+ }
@@ -0,0 +1,10 @@
1
+ // sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the touch half of the lift affordance:
2
+ // there is no hover, the press state raises the surface, and the change is
3
+ // instant (RN styles carry no transitions). The pointer half is interaction.js.
4
+ import { CONTRACT } from "./contract.js";
5
+
6
+ const LIFT = CONTRACT.themeTokens.interaction.lift;
7
+
8
+ export function pressableLift(state = {}) {
9
+ return [state.pressed ? LIFT.raised : null];
10
+ }
package/dist/linter.cjs CHANGED
@@ -928,12 +928,17 @@ const CURRENCY_LABEL_RES = [
928
928
  ];
929
929
 
930
930
  // sc-4985 — soft warning: a widget that writes must decide what a signed-OUT
931
- // visitor sees. A write needs a signed-in app user, so an anonymous visitor
932
- // handed a live "Save" / "Book" / "Delete" button can only tap it and fail —
933
- // the failure the gate exists to spare them. Satisfied by any identity guard:
934
- // a negated or compared `.id`, or a `groupIds` / `roles` check. Reading
935
- // `useUser().id` purely as a VALUE (the USER-column write pattern) is NOT a
936
- // guard, which is why an operator has to follow it.
931
+ // visitor sees. A visitor handed a live "Save" / "Book" / "Delete" button the
932
+ // table will refuse can only tap it and fail — the failure the gate exists to
933
+ // spare them. Satisfied by any identity guard: a negated or compared `.id`, or
934
+ // a `groupIds` / `roles` check. Reading `useUser().id` purely as a VALUE (the
935
+ // USER-column write pattern) is NOT a guard, which is why an operator follows.
936
+ //
937
+ // sc-6593 — `useCanWrite(tableId)` satisfies it too, and answers better: it
938
+ // reads the ACL the write endpoint enforces, so it goes live for whoever may
939
+ // write. Since a table granting Create to EVERYONE accepts an anonymous write
940
+ // (sc-5229), demanding identity here would flag the only correct way to build
941
+ // the logged-out-friendly widget an author explicitly asked for.
937
942
  //
938
943
  // Conservative on purpose: an unrelated `.id` comparison elsewhere in the
939
944
  // source silences the rule. A warning that occasionally stays quiet is far
@@ -952,6 +957,7 @@ function _writeGatedOnUserRules(source) {
952
957
  const code = _stripNonCode(source);
953
958
  const call = /\buseDatastoreMutation\s*\(/.exec(code);
954
959
  if (!call) return [];
960
+ if (/\buseCanWrite\s*\(/.test(code)) return [];
955
961
  if (_IDENTITY_GUARD_RES.some((re) => re.test(code))) return [];
956
962
  const line = code.slice(0, call.index).split(/\r?\n/).length;
957
963
  return [
@@ -961,9 +967,9 @@ function _writeGatedOnUserRules(source) {
961
967
  // Kept under ~210 chars: a finding is truncated at 300 downstream, and
962
968
  // the fix instruction is the half worth keeping.
963
969
  label:
964
- `writes with useDatastoreMutation() but never checks who is signed ` +
965
- `in - read useUser(), and when !user.id render the action inactive ` +
966
- `with a "sign in" line instead of a live button that can only fail.`,
970
+ `writes with useDatastoreMutation() but never checks who may write ` +
971
+ `- gate on useCanWrite(tableId), or useUser() when the action needs ` +
972
+ `a signed-in user, and render the control inactive, never live-but-doomed.`,
967
973
  line,
968
974
  snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
969
975
  },
package/dist/linter.js CHANGED
@@ -1077,12 +1077,17 @@ const CURRENCY_LABEL_RES = [
1077
1077
  ];
1078
1078
 
1079
1079
  // sc-4985 — soft warning: a widget that writes must decide what a signed-OUT
1080
- // visitor sees. A write needs a signed-in app user, so an anonymous visitor
1081
- // handed a live "Save" / "Book" / "Delete" button can only tap it and fail —
1082
- // the failure the gate exists to spare them. Satisfied by any identity guard:
1083
- // a negated or compared `.id`, or a `groupIds` / `roles` check. Reading
1084
- // `useUser().id` purely as a VALUE (the USER-column write pattern) is NOT a
1085
- // guard, which is why an operator has to follow it.
1080
+ // visitor sees. A visitor handed a live "Save" / "Book" / "Delete" button the
1081
+ // table will refuse can only tap it and fail — the failure the gate exists to
1082
+ // spare them. Satisfied by any identity guard: a negated or compared `.id`, or
1083
+ // a `groupIds` / `roles` check. Reading `useUser().id` purely as a VALUE (the
1084
+ // USER-column write pattern) is NOT a guard, which is why an operator follows.
1085
+ //
1086
+ // sc-6593 — `useCanWrite(tableId)` satisfies it too, and answers better: it
1087
+ // reads the ACL the write endpoint enforces, so it goes live for whoever may
1088
+ // write. Since a table granting Create to EVERYONE accepts an anonymous write
1089
+ // (sc-5229), demanding identity here would flag the only correct way to build
1090
+ // the logged-out-friendly widget an author explicitly asked for.
1086
1091
  //
1087
1092
  // Conservative on purpose: an unrelated `.id` comparison elsewhere in the
1088
1093
  // source silences the rule. A warning that occasionally stays quiet is far
@@ -1101,6 +1106,7 @@ function _writeGatedOnUserRules(source) {
1101
1106
  const code = _stripNonCode(source);
1102
1107
  const call = /\buseDatastoreMutation\s*\(/.exec(code);
1103
1108
  if (!call) return [];
1109
+ if (/\buseCanWrite\s*\(/.test(code)) return [];
1104
1110
  if (_IDENTITY_GUARD_RES.some((re) => re.test(code))) return [];
1105
1111
  const line = code.slice(0, call.index).split(/\r?\n/).length;
1106
1112
  return [
@@ -1110,9 +1116,9 @@ function _writeGatedOnUserRules(source) {
1110
1116
  // Kept under ~210 chars: a finding is truncated at 300 downstream, and
1111
1117
  // the fix instruction is the half worth keeping.
1112
1118
  label:
1113
- `writes with useDatastoreMutation() but never checks who is signed ` +
1114
- `in - read useUser(), and when !user.id render the action inactive ` +
1115
- `with a "sign in" line instead of a live button that can only fail.`,
1119
+ `writes with useDatastoreMutation() but never checks who may write ` +
1120
+ `- gate on useCanWrite(tableId), or useUser() when the action needs ` +
1121
+ `a signed-in user, and render the control inactive, never live-but-doomed.`,
1116
1122
  line,
1117
1123
  snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1118
1124
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.113.0",
3
+ "version": "0.115.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__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/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-camera.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-camera.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"