@colixsystems/widget-sdk 0.137.0 → 0.139.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 +20 -3
- package/dist/contract.cjs +56 -5
- package/dist/contract.js +56 -5
- package/dist/filepicker.native.js +30 -14
- package/dist/hooks.js +29 -8
- package/dist/host.d.ts +25 -0
- package/dist/host.js +11 -0
- package/dist/index.d.ts +5 -2
- package/dist/theme-token-refs.js +198 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
46
46
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
47
47
|
| **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 }`. |
|
|
48
48
|
| **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. |
|
|
49
|
-
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write
|
|
49
|
+
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
|
|
50
50
|
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: 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
52
|
| **DATASTORE** | `useCanWrite(tableId, options)` (optional: 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. |
|
|
@@ -72,6 +72,23 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
72
72
|
|
|
73
73
|
`v0.137.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.139.0 (contract 1.108.0)
|
|
76
|
+
|
|
77
|
+
**An author can BIND a colour field to the workspace brand instead of freezing a hex (sc-7800).** A `type: "color"` field tracked the theme only while it was *unset*, through its `themeDefault` placeholder. The moment the author picked a colour it became a literal that outranked the theme forever, so "make this our brand colour" was inexpressible — the author had to type today's hex, and it went stale silently at the next rebrand.
|
|
78
|
+
|
|
79
|
+
A colour field's value may now be a **binding** — `"theme:colors.primary"` or `"theme:colors.secondary"` — which both hosts resolve to the workspace's current colour at render.
|
|
80
|
+
|
|
81
|
+
- **Nothing changes for a widget author.** You keep declaring `{ type: "color", … }` and reading the value off `props.style` / `props`. The host hands your code an ordinary colour string either way, so never branch on a binding, never parse the `theme:` prefix, and never add a "use theme colour" toggle beside a colour field — the Studio's picker already offers the choice, and a second control for it is a dead knob.
|
|
82
|
+
- **Only the two brand tokens are bindable**, listed on `CONTRACT.themeColorTokens`. The derived and contrast-paired tokens (`onPrimary`, `primarySoft`, `primaryStrong`, `loader`) are deliberately withheld: they are the host's to compute, and binding one half of a contrast pair produces unreadable text.
|
|
83
|
+
- **An unresolvable binding falls back to the field's `themeDefault`**, exactly as an unset field does — it never paints blank.
|
|
84
|
+
- **Fully additive.** Every colour already stored is a literal, is untouched, and renders byte-identically.
|
|
85
|
+
|
|
86
|
+
### What's new in 0.138.0 (contract 1.107.0)
|
|
87
|
+
|
|
88
|
+
**Every file upload from the Expo export works again.** Expo's `fetch` implements the WEB FormData spec, so a part must be a string or a Blob — but `useCamera`, `useImageEditor` and `<FilePicker>` all handed back React Native's legacy `{ uri, name, type }` triple, which it rejects with `Unsupported FormDataPart implementation` before a byte leaves the device. Web was unaffected because a browser `File` **is** a Blob, so it only ever reproduced on device. All three now hand back expo-file-system's `File`, carrying the same `uri` / `name` / `type` / `size` — so no widget needs a change.
|
|
89
|
+
|
|
90
|
+
**`useFilestoreUpload` keeps the filename, and `fileName` works on both hosts.** A Blob appended with no filename is stored as `blob`. The name travels as `append()`'s third argument for a real Blob, and is set ON the part otherwise — expo-file-system's `File` is **not** `instanceof Blob` at runtime (`implements Blob` is TypeScript-only), and Expo reads the name off the part. `upload(file, { fileName })` overrides it. The native producers set `type` the same way, because the `File` derives it from the cache URI and leaves it empty when that URI has no extension.
|
|
91
|
+
|
|
75
92
|
### What's new in 0.137.0 (contract 1.106.0)
|
|
76
93
|
|
|
77
94
|
**A `datastoreTemplate` column marked `encrypted` is now actually encrypted in the workspace that installs you (sc-7557).** The field was documented nowhere and honoured nowhere: install built its column rows one way, the republish/upgrade migration built them another way, and neither carried the flag — so a column you declared confidential was created as an ordinary plaintext column and nothing told you or your installer. Both paths now share one projection, so the flag lands on install and on upgrade alike.
|
|
@@ -470,7 +487,7 @@ When you take the `useCanWrite`-only route, omit the USER column for a guest (`i
|
|
|
470
487
|
|
|
471
488
|
**Dismissing the picker resolves `null`, not an error.** Backing out is the most common outcome, so it is deliberately not a rejection — your happy path needs no `try/catch`. A genuine failure (permission refused, no host broker) rejects with a structured `CameraError` (new named export) carrying a stable `.code` (`PERMISSION_DENIED` / `UNSUPPORTED` / `INTERNAL`).
|
|
472
489
|
|
|
473
|
-
**One upload path on both hosts.** The resolved asset is normalised to `{ uri, name, mimeType, width, height, size, file }`, where `uri` is directly displayable (`<Image source={{ uri }} />`) and `file` is already the right upload part for the platform — a `File` on web, a `{ uri, name, type }`
|
|
490
|
+
**One upload path on both hosts.** The resolved asset is normalised to `{ uri, name, mimeType, width, height, size, file }`, where `uri` is directly displayable (`<Image source={{ uri }} />`) and `file` is already the right upload part for the platform — a Blob on both hosts: a browser `File` on web, expo-file-system's `File` on native. (It must be a Blob: Expo's fetch implements the web FormData spec and rejects React Native's legacy `{ uri, name, type }` part.) So the same three lines work everywhere:
|
|
474
491
|
|
|
475
492
|
```js
|
|
476
493
|
const fd = new FormData();
|
|
@@ -1495,7 +1512,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
1495
1512
|
- **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.
|
|
1496
1513
|
- **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 with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — 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. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
|
|
1497
1514
|
- **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.
|
|
1498
|
-
- **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`.
|
|
1515
|
+
- **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`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` below `isNarrowWidth`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
|
|
1499
1516
|
- **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).
|
|
1500
1517
|
- **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.
|
|
1501
1518
|
- **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.
|
package/dist/contract.cjs
CHANGED
|
@@ -121,6 +121,26 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
121
121
|
widgetStyles: Object.freeze({}),
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
// sc-7800 -- the colour tokens an AUTHOR may BIND a `type: "color"` field to.
|
|
125
|
+
//
|
|
126
|
+
// A colour field already tracked the theme while unset (its `themeDefault`
|
|
127
|
+
// placeholder); the moment it was set it froze to a hex and stopped following a
|
|
128
|
+
// rebrand. A bound field stores `theme:<path>` instead, and the hosts resolve it
|
|
129
|
+
// per render, so the author's INTENT ("our brand colour") survives the rebrand.
|
|
130
|
+
//
|
|
131
|
+
// Deliberately only the two BRAND tokens. The derived and contrast-paired
|
|
132
|
+
// tokens -- `onPrimary`, `primarySoft`, `primaryStrong`, `loader` -- are the
|
|
133
|
+
// host's to compute: offering them would let an author pick one half of a
|
|
134
|
+
// contrast pair and produce unreadable text with no way to see it was their
|
|
135
|
+
// choice. `label` is the author-facing name; the Studio picker, the host
|
|
136
|
+
// resolver and the AI widget agent all read this ONE list, so what may be
|
|
137
|
+
// written and what will resolve cannot drift.
|
|
138
|
+
const THEME_COLOR_TOKENS = Object.freeze([
|
|
139
|
+
Object.freeze({ path: "colors.primary", label: "Primary" }),
|
|
140
|
+
Object.freeze({ path: "colors.secondary", label: "Secondary" }),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
|
|
124
144
|
// REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
|
|
125
145
|
// single lever: remapping `primaryColor` to recolour "the buttons" recolours
|
|
126
146
|
// every element that shares it. A theme may therefore also carry
|
|
@@ -886,8 +906,11 @@ const HOOKS = [
|
|
|
886
906
|
"from the host context, builds a multipart FormData with the " +
|
|
887
907
|
"snake_case fields the backend reads verbatim (`space_type`, " +
|
|
888
908
|
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
889
|
-
"ctx.filestore.files.upload. `upload(file, { folderId?, compress? })` " +
|
|
890
|
-
"resolves to the created file row or throws the wire error.
|
|
909
|
+
"ctx.filestore.files.upload. `upload(file, { folderId?, compress?, fileName? })` " +
|
|
910
|
+
"resolves to the created file row or throws the wire error. `file` must be a BLOB — a browser File on web, " +
|
|
911
|
+
"expo-file-system's File on native (what FilePicker / useCamera / useImageEditor hand back); Expo's fetch rejects " +
|
|
912
|
+
"React Native's legacy { uri, name, type } part with \"Unsupported FormDataPart implementation\". The stored name " +
|
|
913
|
+
"comes from the Blob's `.name` unless `fileName` overrides it. Branch " +
|
|
891
914
|
"on `err.status`: 403 means the destination denied the add and says " +
|
|
892
915
|
"so, because the caller can see that position — for a public space " +
|
|
893
916
|
"that means an operator has not switched upload access on yet; 404 " +
|
|
@@ -907,7 +930,7 @@ const HOOKS = [
|
|
|
907
930
|
"'project' for content restricted to signed-in workspace users, " +
|
|
908
931
|
"'personal' for a file private to the uploading app user.",
|
|
909
932
|
returnShape: {
|
|
910
|
-
upload: "(file, { folderId?, compress? }) => Promise<FilestoreFile>",
|
|
933
|
+
upload: "(file, { folderId?, compress?, fileName? }) => Promise<FilestoreFile>",
|
|
911
934
|
uploading: "boolean",
|
|
912
935
|
error: "Error | null",
|
|
913
936
|
lastUploaded: "FilestoreFile | null",
|
|
@@ -1668,7 +1691,8 @@ const HOOKS = [
|
|
|
1668
1691
|
"{ uri, name, mimeType, width, height, size, file }, or NULL when the user dismisses the picker — dismissal is the " +
|
|
1669
1692
|
"common case and is deliberately NOT an error, so no try/catch is needed on the happy path. They reject with a " +
|
|
1670
1693
|
"CameraError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `asset.file` is already the right " +
|
|
1671
|
-
"upload part for the host (a File on web,
|
|
1694
|
+
"upload part for the host — a BLOB on both (a File on web, expo-file-system's File on native), because Expo's fetch " +
|
|
1695
|
+
"implements the web FormData spec and rejects React Native's legacy { uri, name, type } part outright: append it to a FormData as `file` and pass " +
|
|
1672
1696
|
"that to ctx.assets.upload(fd) — one code path on both platforms. reset() clears the asset and releases it. Check " +
|
|
1673
1697
|
"`supported` before rendering a camera button. options: { allowsEditing, quality } are HINTS the host honours where it " +
|
|
1674
1698
|
"can — the Expo export applies both, the web camera applies quality only, so never depend on a cropped result. " +
|
|
@@ -3926,7 +3950,33 @@ const CONTRACT = deepFreeze({
|
|
|
3926
3950
|
// template declaring neither behaves exactly as before, and a role nobody
|
|
3927
3951
|
// binds writes no grant rather than failing the install. Minor bump on the
|
|
3928
3952
|
// pre-1.0 channel.
|
|
3929
|
-
|
|
3953
|
+
//
|
|
3954
|
+
// 1.107.0: fix + additive — every FILE UPLOAD from the Expo export failed
|
|
3955
|
+
// with "Unsupported FormDataPart implementation". Expo's fetch implements
|
|
3956
|
+
// the WEB FormData spec, so a part must be a string or a Blob, but all
|
|
3957
|
+
// three native producers (`useCamera`, `useImageEditor`, `<FilePicker>`)
|
|
3958
|
+
// handed back React Native's legacy `{ uri, name, type }` triple, which it
|
|
3959
|
+
// rejects before a byte leaves the device. Web was unaffected because a
|
|
3960
|
+
// browser `File` IS a Blob. All three now hand back expo-file-system's
|
|
3961
|
+
// `File`, the shape Expo's fetch accepts, exposing the same
|
|
3962
|
+
// `uri` / `name` / `type` / `size`, so a widget reading those is
|
|
3963
|
+
// unaffected — each producer shadows the name + type the cache URI
|
|
3964
|
+
// would otherwise impose. `useFilestoreUpload` additionally carries the
|
|
3965
|
+
// multipart FILENAME (a bare Blob would be stored as "blob") and accepts a
|
|
3966
|
+
// `fileName` override: as append()'s third argument for a real Blob, and
|
|
3967
|
+
// ON the part otherwise, since expo-file-system's File is NOT
|
|
3968
|
+
// `instanceof Blob` at runtime and Expo reads the name off the part.
|
|
3969
|
+
// One upload path on both hosts, as before.
|
|
3970
|
+
// 1.108.0: additive (sc-7800) -- `themeColorTokens`: the brand colour tokens
|
|
3971
|
+
// an author may BIND a `type: "color"` field to, plus the `theme:<path>`
|
|
3972
|
+
// value grammar and the `resolveThemeTokenRefs` host resolver behind it.
|
|
3973
|
+
// A set colour field used to freeze to a hex literal and stop following
|
|
3974
|
+
// the workspace theme, so "make this our brand colour" was inexpressible
|
|
3975
|
+
// and every picked colour went stale on the next rebrand. Purely additive:
|
|
3976
|
+
// a stored hex is untouched and still renders byte-identically, and an
|
|
3977
|
+
// unresolvable binding falls back to the field's `themeDefault` rather
|
|
3978
|
+
// than painting blank.
|
|
3979
|
+
version: "1.108.0",
|
|
3930
3980
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3931
3981
|
hooks: HOOKS,
|
|
3932
3982
|
primitives: PRIMITIVES,
|
|
@@ -3937,6 +3987,7 @@ const CONTRACT = deepFreeze({
|
|
|
3937
3987
|
actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
|
|
3938
3988
|
actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
|
|
3939
3989
|
themeTokens: DEFAULT_THEME_TOKENS,
|
|
3990
|
+
themeColorTokens: THEME_COLOR_TOKENS,
|
|
3940
3991
|
themeComponents: THEME_COMPONENTS,
|
|
3941
3992
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
3942
3993
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
package/dist/contract.js
CHANGED
|
@@ -121,6 +121,26 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
121
121
|
widgetStyles: Object.freeze({}),
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
// sc-7800 -- the colour tokens an AUTHOR may BIND a `type: "color"` field to.
|
|
125
|
+
//
|
|
126
|
+
// A colour field already tracked the theme while unset (its `themeDefault`
|
|
127
|
+
// placeholder); the moment it was set it froze to a hex and stopped following a
|
|
128
|
+
// rebrand. A bound field stores `theme:<path>` instead, and the hosts resolve it
|
|
129
|
+
// per render, so the author's INTENT ("our brand colour") survives the rebrand.
|
|
130
|
+
//
|
|
131
|
+
// Deliberately only the two BRAND tokens. The derived and contrast-paired
|
|
132
|
+
// tokens -- `onPrimary`, `primarySoft`, `primaryStrong`, `loader` -- are the
|
|
133
|
+
// host's to compute: offering them would let an author pick one half of a
|
|
134
|
+
// contrast pair and produce unreadable text with no way to see it was their
|
|
135
|
+
// choice. `label` is the author-facing name; the Studio picker, the host
|
|
136
|
+
// resolver and the AI widget agent all read this ONE list, so what may be
|
|
137
|
+
// written and what will resolve cannot drift.
|
|
138
|
+
const THEME_COLOR_TOKENS = Object.freeze([
|
|
139
|
+
Object.freeze({ path: "colors.primary", label: "Primary" }),
|
|
140
|
+
Object.freeze({ path: "colors.secondary", label: "Secondary" }),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
|
|
124
144
|
// REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
|
|
125
145
|
// single lever: remapping `primaryColor` to recolour "the buttons" recolours
|
|
126
146
|
// every element that shares it. A theme may therefore also carry
|
|
@@ -886,8 +906,11 @@ const HOOKS = [
|
|
|
886
906
|
"from the host context, builds a multipart FormData with the " +
|
|
887
907
|
"snake_case fields the backend reads verbatim (`space_type`, " +
|
|
888
908
|
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
889
|
-
"ctx.filestore.files.upload. `upload(file, { folderId?, compress? })` " +
|
|
890
|
-
"resolves to the created file row or throws the wire error.
|
|
909
|
+
"ctx.filestore.files.upload. `upload(file, { folderId?, compress?, fileName? })` " +
|
|
910
|
+
"resolves to the created file row or throws the wire error. `file` must be a BLOB — a browser File on web, " +
|
|
911
|
+
"expo-file-system's File on native (what FilePicker / useCamera / useImageEditor hand back); Expo's fetch rejects " +
|
|
912
|
+
"React Native's legacy { uri, name, type } part with \"Unsupported FormDataPart implementation\". The stored name " +
|
|
913
|
+
"comes from the Blob's `.name` unless `fileName` overrides it. Branch " +
|
|
891
914
|
"on `err.status`: 403 means the destination denied the add and says " +
|
|
892
915
|
"so, because the caller can see that position — for a public space " +
|
|
893
916
|
"that means an operator has not switched upload access on yet; 404 " +
|
|
@@ -907,7 +930,7 @@ const HOOKS = [
|
|
|
907
930
|
"'project' for content restricted to signed-in workspace users, " +
|
|
908
931
|
"'personal' for a file private to the uploading app user.",
|
|
909
932
|
returnShape: {
|
|
910
|
-
upload: "(file, { folderId?, compress? }) => Promise<FilestoreFile>",
|
|
933
|
+
upload: "(file, { folderId?, compress?, fileName? }) => Promise<FilestoreFile>",
|
|
911
934
|
uploading: "boolean",
|
|
912
935
|
error: "Error | null",
|
|
913
936
|
lastUploaded: "FilestoreFile | null",
|
|
@@ -1668,7 +1691,8 @@ const HOOKS = [
|
|
|
1668
1691
|
"{ uri, name, mimeType, width, height, size, file }, or NULL when the user dismisses the picker — dismissal is the " +
|
|
1669
1692
|
"common case and is deliberately NOT an error, so no try/catch is needed on the happy path. They reject with a " +
|
|
1670
1693
|
"CameraError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `asset.file` is already the right " +
|
|
1671
|
-
"upload part for the host (a File on web,
|
|
1694
|
+
"upload part for the host — a BLOB on both (a File on web, expo-file-system's File on native), because Expo's fetch " +
|
|
1695
|
+
"implements the web FormData spec and rejects React Native's legacy { uri, name, type } part outright: append it to a FormData as `file` and pass " +
|
|
1672
1696
|
"that to ctx.assets.upload(fd) — one code path on both platforms. reset() clears the asset and releases it. Check " +
|
|
1673
1697
|
"`supported` before rendering a camera button. options: { allowsEditing, quality } are HINTS the host honours where it " +
|
|
1674
1698
|
"can — the Expo export applies both, the web camera applies quality only, so never depend on a cropped result. " +
|
|
@@ -3926,7 +3950,33 @@ const CONTRACT = deepFreeze({
|
|
|
3926
3950
|
// template declaring neither behaves exactly as before, and a role nobody
|
|
3927
3951
|
// binds writes no grant rather than failing the install. Minor bump on the
|
|
3928
3952
|
// pre-1.0 channel.
|
|
3929
|
-
|
|
3953
|
+
//
|
|
3954
|
+
// 1.107.0: fix + additive — every FILE UPLOAD from the Expo export failed
|
|
3955
|
+
// with "Unsupported FormDataPart implementation". Expo's fetch implements
|
|
3956
|
+
// the WEB FormData spec, so a part must be a string or a Blob, but all
|
|
3957
|
+
// three native producers (`useCamera`, `useImageEditor`, `<FilePicker>`)
|
|
3958
|
+
// handed back React Native's legacy `{ uri, name, type }` triple, which it
|
|
3959
|
+
// rejects before a byte leaves the device. Web was unaffected because a
|
|
3960
|
+
// browser `File` IS a Blob. All three now hand back expo-file-system's
|
|
3961
|
+
// `File`, the shape Expo's fetch accepts, exposing the same
|
|
3962
|
+
// `uri` / `name` / `type` / `size`, so a widget reading those is
|
|
3963
|
+
// unaffected — each producer shadows the name + type the cache URI
|
|
3964
|
+
// would otherwise impose. `useFilestoreUpload` additionally carries the
|
|
3965
|
+
// multipart FILENAME (a bare Blob would be stored as "blob") and accepts a
|
|
3966
|
+
// `fileName` override: as append()'s third argument for a real Blob, and
|
|
3967
|
+
// ON the part otherwise, since expo-file-system's File is NOT
|
|
3968
|
+
// `instanceof Blob` at runtime and Expo reads the name off the part.
|
|
3969
|
+
// One upload path on both hosts, as before.
|
|
3970
|
+
// 1.108.0: additive (sc-7800) -- `themeColorTokens`: the brand colour tokens
|
|
3971
|
+
// an author may BIND a `type: "color"` field to, plus the `theme:<path>`
|
|
3972
|
+
// value grammar and the `resolveThemeTokenRefs` host resolver behind it.
|
|
3973
|
+
// A set colour field used to freeze to a hex literal and stop following
|
|
3974
|
+
// the workspace theme, so "make this our brand colour" was inexpressible
|
|
3975
|
+
// and every picked colour went stale on the next rebrand. Purely additive:
|
|
3976
|
+
// a stored hex is untouched and still renders byte-identically, and an
|
|
3977
|
+
// unresolvable binding falls back to the field's `themeDefault` rather
|
|
3978
|
+
// than painting blank.
|
|
3979
|
+
version: "1.108.0",
|
|
3930
3980
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3931
3981
|
hooks: HOOKS,
|
|
3932
3982
|
primitives: PRIMITIVES,
|
|
@@ -3937,6 +3987,7 @@ const CONTRACT = deepFreeze({
|
|
|
3937
3987
|
actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
|
|
3938
3988
|
actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
|
|
3939
3989
|
themeTokens: DEFAULT_THEME_TOKENS,
|
|
3990
|
+
themeColorTokens: THEME_COLOR_TOKENS,
|
|
3940
3991
|
themeComponents: THEME_COMPONENTS,
|
|
3941
3992
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
3942
3993
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
|
@@ -10,15 +10,20 @@
|
|
|
10
10
|
//
|
|
11
11
|
// `onPick` receives the same shape on both platforms:
|
|
12
12
|
// - web → the browser's `File` object (a `Blob` with name + type).
|
|
13
|
-
// - native → `
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// - native → expo-file-system's `File` — ALSO a `Blob`, and carrying the
|
|
14
|
+
// same `uri` / `name` / `type` / `size` the old React Native
|
|
15
|
+
// `{ uri, name, type, size }` part did, so a widget reading those is
|
|
16
|
+
// unaffected. It must be a Blob: Expo's fetch implements the WEB
|
|
17
|
+
// FormData spec and rejects the RN triple outright ("Unsupported
|
|
18
|
+
// FormDataPart implementation"), which failed every native upload.
|
|
19
|
+
// `useFilestoreUpload` appends whichever verbatim into the multipart body.
|
|
17
20
|
|
|
18
21
|
import React, { useCallback, useMemo } from "react";
|
|
19
22
|
import { Pressable, View } from "react-native";
|
|
20
23
|
// eslint-disable-next-line no-restricted-syntax
|
|
21
24
|
import { getDocumentAsync } from "expo-document-picker";
|
|
25
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
26
|
+
import { File as FsFile } from "expo-file-system";
|
|
22
27
|
|
|
23
28
|
// Map an HTML `accept` string (the web contract — "image/*", "audio/*",
|
|
24
29
|
// "image/png,image/jpeg", …) to expo-document-picker's `type` arg, which
|
|
@@ -38,17 +43,28 @@ function _mapAccept(accept) {
|
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
// expo-document-picker returns `{ canceled, assets: [{ uri, name,
|
|
41
|
-
// mimeType, size }] }`.
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
46
|
+
// mimeType, size }] }`. The part handed to onPick is expo-file-system's
|
|
47
|
+
// File, exposing the same uri / name / type / size the old plain object
|
|
48
|
+
// did. It must NOT be the old object: Expo's fetch refuses that shape, so
|
|
49
|
+
// keeping it as a fallback would only defer the failure.
|
|
45
50
|
function _asset(picked) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
const file = new FsFile(picked.uri);
|
|
52
|
+
// Both are read off the PART when the multipart is serialised (the third
|
|
53
|
+
// `append` argument is ignored for a non-Blob), and both are derived from
|
|
54
|
+
// the cache URI — which may be renamed, percent-encoded, or extensionless.
|
|
55
|
+
// The picker's own values are the ones the user and the backend expect.
|
|
56
|
+
_shadow(file, "name", picked.name || "upload");
|
|
57
|
+
_shadow(file, "type", picked.mimeType || "application/octet-stream");
|
|
58
|
+
return file;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _shadow(file, key, value) {
|
|
62
|
+
if (!value || file[key] === value) return;
|
|
63
|
+
try {
|
|
64
|
+
Object.defineProperty(file, key, { value, configurable: true });
|
|
65
|
+
} catch {
|
|
66
|
+
/* non-configurable: whatever the platform derived stands */
|
|
67
|
+
}
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
export function FilePicker({
|
package/dist/hooks.js
CHANGED
|
@@ -3571,13 +3571,14 @@ export function useFilestoreFile(fileId) {
|
|
|
3571
3571
|
* same way the read hooks do (tenant for PROJECT, app user for PERSONAL)
|
|
3572
3572
|
* and posts a multipart FormData to `ctx.filestore.files.upload`.
|
|
3573
3573
|
*
|
|
3574
|
-
* `upload(file, { folderId? })`
|
|
3575
|
-
*
|
|
3576
|
-
*
|
|
3577
|
-
*
|
|
3578
|
-
*
|
|
3579
|
-
*
|
|
3580
|
-
*
|
|
3574
|
+
* `upload(file, { folderId?, fileName? })` takes a BLOB on both hosts — a
|
|
3575
|
+
* browser `File` on the web Player, expo-file-system's `File` (also a Blob)
|
|
3576
|
+
* on the Expo export, as `<FilePicker>`, `useCamera()` and `useImageEditor()`
|
|
3577
|
+
* all hand back. It must be a Blob: Expo's fetch implements the web FormData
|
|
3578
|
+
* spec and rejects React Native's legacy `{ uri, name, type }` part with
|
|
3579
|
+
* "Unsupported FormDataPart implementation", which failed every native
|
|
3580
|
+
* upload. `fileName` overrides the stored name, which otherwise comes from
|
|
3581
|
+
* the Blob's own `.name`.
|
|
3581
3582
|
*
|
|
3582
3583
|
* The destination can refuse the add two ways (sc-6977). BRANCH ON
|
|
3583
3584
|
* `err.status`: 403 means the caller may know why — most often a public space
|
|
@@ -3636,7 +3637,27 @@ export function useFilestoreUpload(options) {
|
|
|
3636
3637
|
// sc-5402: only the opt-out is sent, so the backend stays the single
|
|
3637
3638
|
// source of the default (it compresses images to WebP).
|
|
3638
3639
|
if (compress === false) form.append("compress", "false");
|
|
3639
|
-
|
|
3640
|
+
// The multipart FILENAME is what the backend stores as the file's name
|
|
3641
|
+
// (multer's `originalname`), and a Blob with no third argument lands as
|
|
3642
|
+
// "blob". Both hosts' parts carry `.name` — a browser File, and
|
|
3643
|
+
// expo-file-system's File on native — so pass it through explicitly.
|
|
3644
|
+
const partName =
|
|
3645
|
+
(overrides && typeof overrides.fileName === "string" && overrides.fileName) ||
|
|
3646
|
+
(typeof file.name === "string" && file.name ? file.name : null);
|
|
3647
|
+
// The three-argument append is SPEC'd for a Blob only — a compliant
|
|
3648
|
+
// FormData throws on anything else, and Expo's ignores the name. So a
|
|
3649
|
+
// non-Blob part (expo-file-system's File: `implements Blob` is erased at
|
|
3650
|
+
// runtime) carries its name ON itself, which is where Expo reads it.
|
|
3651
|
+
const isBlob = typeof Blob !== "undefined" && file instanceof Blob;
|
|
3652
|
+
if (partName && !isBlob && file.name !== partName) {
|
|
3653
|
+
try {
|
|
3654
|
+
Object.defineProperty(file, "name", { value: partName, configurable: true });
|
|
3655
|
+
} catch {
|
|
3656
|
+
/* non-configurable: the part's own name stands */
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
if (partName && isBlob) form.append("file", file, partName);
|
|
3660
|
+
else form.append("file", file);
|
|
3640
3661
|
setUploading(true);
|
|
3641
3662
|
setError(null);
|
|
3642
3663
|
try {
|
package/dist/host.d.ts
CHANGED
|
@@ -334,3 +334,28 @@ export interface LoginDesign {
|
|
|
334
334
|
|
|
335
335
|
/** The sign-in screen's authored design, or null when nothing is configured. */
|
|
336
336
|
export function normaliseLoginDesign(raw: unknown): LoginDesign | null;
|
|
337
|
+
|
|
338
|
+
/** sc-7800 — true when a stored value is a `theme:<path>` colour binding. */
|
|
339
|
+
export function isThemeTokenRef(value: unknown): boolean;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Resolve one `theme:<path>` binding against a resolved widget theme. Returns
|
|
343
|
+
* `undefined` for a non-binding, a token outside `CONTRACT.themeColorTokens`,
|
|
344
|
+
* or a path that does not resolve to a colour.
|
|
345
|
+
*/
|
|
346
|
+
export function resolveThemeTokenRef(
|
|
347
|
+
value: unknown,
|
|
348
|
+
theme: unknown,
|
|
349
|
+
): string | undefined;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Replace every bound `type: "color"` field on a widget's props — both the
|
|
353
|
+
* top-level `propertySchema` fields and the `styleSchema` fields under
|
|
354
|
+
* `props.style` — with the colour its token currently resolves to. Returns the
|
|
355
|
+
* same reference when nothing is bound.
|
|
356
|
+
*/
|
|
357
|
+
export function resolveThemeTokenRefs<T>(
|
|
358
|
+
props: T,
|
|
359
|
+
theme: unknown,
|
|
360
|
+
schemas?: { propertySchema?: unknown; styleSchema?: unknown },
|
|
361
|
+
): T;
|
package/dist/host.js
CHANGED
|
@@ -23,6 +23,17 @@ export {
|
|
|
23
23
|
applyThemeComponentStyle,
|
|
24
24
|
} from "./theme-components.js";
|
|
25
25
|
|
|
26
|
+
// sc-7800: the theme-token colour bindings. `resolveThemeTokenRefs` replaces a
|
|
27
|
+
// bound `type: "color"` field with the colour its token currently resolves to,
|
|
28
|
+
// at the SAME render boundary on both hosts — so a rebrand repaints the Player,
|
|
29
|
+
// the Canvas and the exported app alike, and the widget still receives a plain
|
|
30
|
+
// colour string.
|
|
31
|
+
export {
|
|
32
|
+
isThemeTokenRef,
|
|
33
|
+
resolveThemeTokenRef,
|
|
34
|
+
resolveThemeTokenRefs,
|
|
35
|
+
} from "./theme-token-refs.js";
|
|
36
|
+
|
|
26
37
|
// REQ-THEME-SURFACE: the surface-token derivation both hosts apply per painted
|
|
27
38
|
// surface — the page background and every container that carries its own fill.
|
|
28
39
|
// Host plumbing, deliberately off the author entry point: a widget reads the
|
package/dist/index.d.ts
CHANGED
|
@@ -1674,7 +1674,8 @@ export interface CameraAsset {
|
|
|
1674
1674
|
/** Bytes, when the host reports it. */
|
|
1675
1675
|
size: number | null;
|
|
1676
1676
|
/**
|
|
1677
|
-
* Ready-to-upload part — a
|
|
1677
|
+
* Ready-to-upload part — a Blob on both hosts: a `File` on web,
|
|
1678
|
+
* expo-file-system's `File` on native. Expo's fetch rejects any other shape.
|
|
1678
1679
|
* Append it to a FormData and hand that to `ctx.assets.upload(...)`.
|
|
1679
1680
|
*/
|
|
1680
1681
|
file: unknown;
|
|
@@ -1749,7 +1750,7 @@ export interface EditedImage {
|
|
|
1749
1750
|
size: number | null;
|
|
1750
1751
|
/** Present only when `base64` was requested. */
|
|
1751
1752
|
base64?: string;
|
|
1752
|
-
/** Ready-to-upload part — a `File` on web,
|
|
1753
|
+
/** Ready-to-upload part — a Blob on both: a `File` on web, expo-file-system's `File` on native. */
|
|
1753
1754
|
file: unknown;
|
|
1754
1755
|
}
|
|
1755
1756
|
|
|
@@ -2699,6 +2700,8 @@ export interface AiWidgetContract {
|
|
|
2699
2700
|
readonly manifestCategories: ReadonlyArray<string>;
|
|
2700
2701
|
readonly manifestPlatforms: ReadonlyArray<string>;
|
|
2701
2702
|
readonly themeTokens: ThemeTokens;
|
|
2703
|
+
/** sc-7800 — the brand colour tokens a `type: "color"` field may bind to. */
|
|
2704
|
+
readonly themeColorTokens: ReadonlyArray<{ path: string; label: string }>;
|
|
2702
2705
|
readonly widgetContextShape: Readonly<
|
|
2703
2706
|
Record<
|
|
2704
2707
|
string,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// sc-7800 — the host side of THEME-TOKEN COLOUR BINDINGS.
|
|
2
|
+
//
|
|
3
|
+
// Host-integration surface, re-exported from `host.js`: consumed ONLY by the
|
|
4
|
+
// platform hosts that render widgets (the web Player / Studio Canvas via
|
|
5
|
+
// buildHostWidgetContext.js, and the exported Expo app's generated WidgetHost),
|
|
6
|
+
// never by a widget author. A widget keeps reading a plain colour string off
|
|
7
|
+
// `props` / `props.style` and never learns a token was involved.
|
|
8
|
+
//
|
|
9
|
+
// Living here — one implementation both hosts import — is what makes a bound
|
|
10
|
+
// colour resolve identically in the Player and the export (widget-parity
|
|
11
|
+
// skill). The alternative, a copy per host, is the drift this file exists to
|
|
12
|
+
// prevent.
|
|
13
|
+
//
|
|
14
|
+
// WHY THIS EXISTS: a `type: "color"` field already tracks the workspace theme
|
|
15
|
+
// while it is UNSET, through its `themeDefault` placeholder. The moment an
|
|
16
|
+
// author picks a colour it became a frozen hex that never followed a rebrand
|
|
17
|
+
// again, and "make this our brand colour" was inexpressible — the author had to
|
|
18
|
+
// type today's hex, which went stale silently. A bound field stores the TOKEN,
|
|
19
|
+
// so the intent survives the rebrand.
|
|
20
|
+
|
|
21
|
+
import { CONTRACT } from "./contract.js";
|
|
22
|
+
|
|
23
|
+
// A value is a binding, not a colour, when it carries this prefix. No CSS
|
|
24
|
+
// colour can begin with it, so a stored literal can never be misread as one.
|
|
25
|
+
const PREFIX = "theme:";
|
|
26
|
+
|
|
27
|
+
/** True when `value` is a theme-token binding rather than a literal colour. */
|
|
28
|
+
export function isThemeTokenRef(value) {
|
|
29
|
+
return typeof value === "string" && value.startsWith(PREFIX);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve one binding against a resolved widget theme.
|
|
34
|
+
*
|
|
35
|
+
* Returns `undefined` for anything that is not a binding, names a token outside
|
|
36
|
+
* the bindable allowlist, or resolves to a non-string — the caller then leaves
|
|
37
|
+
* the field unset so it falls back to its `themeDefault` exactly as an
|
|
38
|
+
* un-bound field does. A malformed binding must never paint blank.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveThemeTokenRef(value, theme) {
|
|
41
|
+
if (!isThemeTokenRef(value)) return undefined;
|
|
42
|
+
const path = value.slice(PREFIX.length);
|
|
43
|
+
// The allowlist is the contract's, so what the Studio may WRITE and what a
|
|
44
|
+
// host will RESOLVE cannot drift apart.
|
|
45
|
+
if (!CONTRACT.themeColorTokens.some((token) => token.path === path)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const resolved = path
|
|
49
|
+
.split(".")
|
|
50
|
+
.reduce((o, k) => (o == null ? undefined : o[k]), theme);
|
|
51
|
+
return typeof resolved === "string" && resolved.trim() !== ""
|
|
52
|
+
? resolved
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPlainObject(value) {
|
|
57
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The same guard the neighbouring style validators apply: these props are
|
|
61
|
+
// author-stored JSON, and assigning such a key while rebuilding an object would
|
|
62
|
+
// silently reseat its prototype instead of carrying the value.
|
|
63
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk a declared schema alongside a value and replace every bound `color`
|
|
67
|
+
* leaf with the colour its token currently resolves to.
|
|
68
|
+
*
|
|
69
|
+
* Schema-GUIDED rather than a blind string sweep, because top-level props hold
|
|
70
|
+
* arbitrary author content — body copy, rich text, a stored link — where a
|
|
71
|
+
* `theme:`-prefixed string is data and must be left verbatim. Only a field the
|
|
72
|
+
* widget declared as a colour is eligible here. (`props.style` is different and
|
|
73
|
+
* is handled by `resolveStyleBag` below.)
|
|
74
|
+
*
|
|
75
|
+
* Returns the SAME reference when nothing is bound, so an app that uses no
|
|
76
|
+
* bindings takes no extra render work and behaves exactly as it did before.
|
|
77
|
+
*/
|
|
78
|
+
function resolveAgainstSchema(schema, value, theme) {
|
|
79
|
+
if (!isPlainObject(schema) || !isPlainObject(value)) return value;
|
|
80
|
+
let changed = false;
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
83
|
+
if (UNSAFE_KEYS.has(key)) continue;
|
|
84
|
+
const def = schema[key];
|
|
85
|
+
out[key] = raw;
|
|
86
|
+
if (!isPlainObject(def)) continue;
|
|
87
|
+
|
|
88
|
+
if (def.type === "color") {
|
|
89
|
+
if (isThemeTokenRef(raw)) {
|
|
90
|
+
// An unresolvable binding is dropped rather than painted: the field goes
|
|
91
|
+
// back to unset, which is what already renders its `themeDefault`.
|
|
92
|
+
out[key] = resolveThemeTokenRef(raw, theme);
|
|
93
|
+
changed = true;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (def.type === "object" && isPlainObject(def.properties)) {
|
|
98
|
+
const next = resolveAgainstSchema(def.properties, raw, theme);
|
|
99
|
+
if (next !== raw) {
|
|
100
|
+
out[key] = next;
|
|
101
|
+
changed = true;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (def.type === "array" && isPlainObject(def.items) && Array.isArray(raw)) {
|
|
106
|
+
let itemChanged = false;
|
|
107
|
+
const items = raw.map((item) => {
|
|
108
|
+
const next =
|
|
109
|
+
def.items.type === "object" && isPlainObject(def.items.properties)
|
|
110
|
+
? resolveAgainstSchema(def.items.properties, item, theme)
|
|
111
|
+
: item;
|
|
112
|
+
if (next !== item) itemChanged = true;
|
|
113
|
+
return next;
|
|
114
|
+
});
|
|
115
|
+
if (itemChanged) {
|
|
116
|
+
out[key] = items;
|
|
117
|
+
changed = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return changed ? out : value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A style bag is resolved WITHOUT a schema, and deliberately so. Both hosts
|
|
125
|
+
// thread `styleSchema` only for marketplace / AI-agent widgets; a BUILT-IN
|
|
126
|
+
// passes `undefined` on web and `null` on native (it keeps its defaults in
|
|
127
|
+
// code). Requiring the schema would therefore have left every built-in's colour
|
|
128
|
+
// field bindable in the Studio but unresolved at render — the author picks
|
|
129
|
+
// "Primary" and the widget is handed the literal string. Resolving the bag
|
|
130
|
+
// directly is what makes a built-in and a generated widget behave the same, on
|
|
131
|
+
// both hosts, with nothing new baked into the export.
|
|
132
|
+
//
|
|
133
|
+
// Safe without a schema because `props.style` is by construction a bag of style
|
|
134
|
+
// VALUES — colours, sizes, enums — so an allowlisted `theme:` string in it can
|
|
135
|
+
// only ever have been a binding. The depth cap keeps the walk bounded for the
|
|
136
|
+
// structured style values (a gradient's stops, per-corner radii).
|
|
137
|
+
const MAX_STYLE_DEPTH = 3;
|
|
138
|
+
|
|
139
|
+
function resolveStyleBag(value, theme, depth) {
|
|
140
|
+
if (typeof value === "string") {
|
|
141
|
+
if (!isThemeTokenRef(value)) return value;
|
|
142
|
+
// Unresolvable drops to unset, which is what renders `themeDefault`.
|
|
143
|
+
return resolveThemeTokenRef(value, theme);
|
|
144
|
+
}
|
|
145
|
+
if (depth >= MAX_STYLE_DEPTH) return value;
|
|
146
|
+
if (Array.isArray(value)) {
|
|
147
|
+
let changed = false;
|
|
148
|
+
const items = value.map((item) => {
|
|
149
|
+
const next = resolveStyleBag(item, theme, depth + 1);
|
|
150
|
+
if (next !== item) changed = true;
|
|
151
|
+
return next;
|
|
152
|
+
});
|
|
153
|
+
return changed ? items : value;
|
|
154
|
+
}
|
|
155
|
+
if (isPlainObject(value)) {
|
|
156
|
+
let changed = false;
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const [key, item] of Object.entries(value)) {
|
|
159
|
+
if (UNSAFE_KEYS.has(key)) continue;
|
|
160
|
+
const next = resolveStyleBag(item, theme, depth + 1);
|
|
161
|
+
out[key] = next;
|
|
162
|
+
if (next !== item) changed = true;
|
|
163
|
+
}
|
|
164
|
+
return changed ? out : value;
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The host render-boundary call: resolve every theme-token colour binding on a
|
|
171
|
+
* widget's props, both the top-level `propertySchema` fields and the style
|
|
172
|
+
* values under `props.style`.
|
|
173
|
+
*
|
|
174
|
+
* Called by BOTH hosts in the same place, immediately after
|
|
175
|
+
* `applyThemeComponentStyle` — a binding is the author's own per-instance
|
|
176
|
+
* value, so it sits at the top of the style precedence and is resolved once the
|
|
177
|
+
* lower layers have been folded in.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} props — the widget's props (post-`applyThemeComponentStyle`).
|
|
180
|
+
* @param {object} theme — the resolved widget theme (`workspace.theme`).
|
|
181
|
+
* @param {object} [schemas] — `{ propertySchema }` as declared by the widget's
|
|
182
|
+
* manifest, used for the top-level props only. `props.style` needs no schema
|
|
183
|
+
* (see above), so a built-in resolves exactly like a generated widget.
|
|
184
|
+
* @returns {object} props, with every bound colour resolved.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveThemeTokenRefs(props, theme, schemas) {
|
|
187
|
+
if (!isPlainObject(props)) return props;
|
|
188
|
+
const { propertySchema } = schemas || {};
|
|
189
|
+
let out = resolveAgainstSchema(propertySchema, props, theme);
|
|
190
|
+
const style = out.style;
|
|
191
|
+
if (isPlainObject(style)) {
|
|
192
|
+
const nextStyle = resolveStyleBag(style, theme, 1);
|
|
193
|
+
if (nextStyle !== style) {
|
|
194
|
+
out = { ...out, style: nextStyle };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.139.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",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build": "node scripts/build.js",
|
|
52
|
-
"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-flex-basis-percent.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__/flatten-entry.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__/overlay-tokens.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-barcode.test.js src/__tests__/hooks-image-editor.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 src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js src/__tests__/style-group.test.js"
|
|
52
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/theme-token-refs.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-flex-basis-percent.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__/flatten-entry.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__/overlay-tokens.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-barcode.test.js src/__tests__/hooks-image-editor.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 src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js src/__tests__/style-group.test.js"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=18"
|