@colixsystems/widget-sdk 0.250.0 → 0.255.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
@@ -46,6 +46,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
46
46
  | **CORE** | `useSpeechToText(options)` (optional: options) | `{ transcript, partial, listening, supported, error, start, stop, abort, reset }` | `ctx.device.speech` — no scope. Dictation with the device's **on-device** recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call `start()` from a user gesture (a tap), never on mount. `transcript` accumulates finalised speech, `partial` holds the uncommitted guess (needs `options.interimResults`); `stop()` keeps it, `abort()` discards it. Rejects with `SpeechToTextError` (`.code` in `PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL`). **Gate your mic button on `supported`** — Firefox ships no `SpeechRecognition`. Identical on web (`SpeechRecognition`) and the Expo export (`expo-speech-recognition`). |
47
47
  | **CORE** | `useCamera(options)` (optional: options) | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (a live `getUserMedia` preview, phones included; a file input only where getUserMedia is absent) and the Expo export (`expo-image-picker`). |
48
48
  | **CORE** | `useI18n()` | `{ t, locale, languages, canSetLocale, setLocale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. `languages` lists the app's active languages (`[{ code, name, is_base }]`); `setLocale(code)` switches the whole app live — saved to a signed-in user's account, kept on the device for a visitor, `null` for the app default. Rejects with `.code` `INVALID_LOCALE` \| `UNSUPPORTED`; `canSetLocale` is false where the host cannot switch (the Builder canvas). |
49
+ | **CORE** | `useAppearance()` | `{ mode, preference, canSwitch, setPreference }` | `ctx.appearance` — no scope. `mode` is the palette showing (`light` \| `dark`), `preference` what the app follows (`light` \| `dark` \| `system`). `setPreference(p)` switches the whole app live — saved to a signed-in user's account, kept on the device for a visitor, `null` for the app default. Rejects with `.code` `UNSUPPORTED` \| `INVALID_APPEARANCE`; `canSwitch` is false where the app does not let users switch and on the Builder canvas. |
49
50
  | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
50
51
  | **CORE** | `useLinkPreview(url)` | `{ preview, loading, error, available }` — `preview` is `{ url, title, description, site_name, image_url } \| null` | `ctx.linkPreviews.get` — no scope. A link's title, description, site name and image, fetched by the platform (the device never contacts the linked site). A page with nothing to show resolves with null fields, not an error — render the plain link. A non-http(s) `url` makes no request. Memoized for up to an hour across widgets; concurrent calls for one URL share a request. `error` is a `LinkPreviewError` (`VALIDATION` \| `AUTH_REQUIRED` \| `RATE_LIMITED` \| `NETWORK` \| `INTERNAL`). `available` is false on a host with no link-preview client |
51
52
  | **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
@@ -84,7 +85,49 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
85
 
85
86
  ## Status
86
87
 
87
- `v0.250.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**.
88
+ `v0.255.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**.
89
+
90
+ ### What's new in 0.255.0
91
+
92
+ **Storing a look in both modes.** `themeInBothModes(theme, mode, twin)` on `@colixsystems/widget-sdk/host` takes a theme painted in `mode` and the palette of the other mode, and returns it the way an app with a light and a dark palette stores it: the base is the light palette and `dark` the dark one. Painted light, the theme stays and `twin` becomes `dark`. Painted dark, its own colours become `dark` and `twin` is laid over the base. `DARK_PALETTE_KEYS` names the top-level theme keys a dark palette may recolour. Host-integration only; nothing changes for widget authors.
93
+
94
+ ### What's new in 0.254.0
95
+
96
+ **A widget can switch the app between light and dark — `useAppearance()`.** Where an app has a light and a dark palette and lets its users choose, `useAppearance()` returns `{ mode, preference, canSwitch, setPreference }`. `mode` is the palette showing (`"light"` or `"dark"`); `preference` is what the app follows (`"light"`, `"dark"` or `"system"`, the device's setting). `setPreference(preference)` switches the whole app at once, with no reload, through the same switch as the account screen: a signed-in user's choice is saved to their account, a visitor's stays on this device, and `null` returns to the app's default. It rejects with an `Error` whose `.code` is `UNSUPPORTED` (the app does not let users switch, or the Builder canvas) or `INVALID_APPEARANCE`. Render your controls disabled when `canSwitch` is false. You need no hook for colours: `useTheme()` already returns the palette showing. An app without the setting reads `mode: "light"`, `canSwitch: false`. The same on the web Player and the exported app.
97
+
98
+ ```jsx
99
+ import { useAppearance, Pressable, Text } from "@colixsystems/widget-sdk";
100
+
101
+ export default function DarkSwitch() {
102
+ const { mode, canSwitch, setPreference } = useAppearance();
103
+ const next = mode === "dark" ? "light" : "dark";
104
+ return (
105
+ <Pressable disabled={!canSwitch} onPress={() => setPreference(next).catch(() => {})}>
106
+ <Text>{mode === "dark" ? "Use light" : "Use dark"}</Text>
107
+ </Pressable>
108
+ );
109
+ }
110
+ ```
111
+
112
+ ### What's new in 0.253.0
113
+
114
+ **Knowing when to follow the device.** `resolveAppearancePreference({ appearance, userChoice })` on `@colixsystems/widget-sdk/host` returns what an app follows: `"light"`, `"dark"` or `"system"`. It applies the same precedence as `resolveAppearanceMode` but stops before asking the device, so a host can subscribe to the device's colour scheme only while the answer is `"system"`. `resolveAppearanceMode` is now built on it and returns the same results. Host-integration only; nothing changes for widget authors.
115
+
116
+ ### What's new in 0.252.0
117
+
118
+ **Host helpers for an app's light and dark palettes.** A workspace theme can now carry a second, colour-only palette (`themeConfig.dark`) and a setting for which palette an app opens on (`themeConfig.appearance`: `{ default: "light" | "dark" | "system", userCanSwitch }`). Two helpers on `@colixsystems/widget-sdk/host` decide what to render, and both hosts call them, so a dark palette means the same thing in the web Player and in the exported app:
119
+
120
+ - `resolveAppearanceMode({ appearance, userChoice, systemScheme })` returns `"light"` or `"dark"`. A user's choice wins only where the app lets users switch. Otherwise the app's default applies, and `"system"` follows the device, falling back to light when the device reports nothing. No setting means light.
121
+ - `resolveThemeForAppearance(themeConfig, mode)` returns the theme to render, without `dark` or `appearance`. In dark mode it lays the palette's colours over the base, key by key. A theme carrying neither key comes back unchanged.
122
+
123
+ `themePaletteOf(theme)` returns the colour-bearing part of a theme, which is the shape a dark palette may hold. `normaliseAppearance`, `APPEARANCE_MODES` and `APPEARANCE_PREFERENCES` come with them. These are host-integration helpers, not something a widget imports. Nothing changes for widget authors: `useTheme()` keeps returning the resolved theme.
124
+
125
+ ### What's new in 0.251.0 (contract 1.205.0)
126
+
127
+ **A text property's default is real, editable text in the Studio.** The Properties panel used to show a `string` property's `default` as a grey placeholder, so authors took it for a hint and never edited the text the widget actually rendered. It now shows the default as the field's own value. It is still not written until the author edits, so an untouched field keeps following the manifest. Clearing a field that shows a default stores `""`. Your widget gets an empty string and should render it as empty, so read text props with `??`, not `||`. Two `ui` hints are now typed:
128
+
129
+ - `ui.placeholder` — a grey hint shown while the field is empty.
130
+ - `ui.clearRestoresDefault: true` — clearing brings the default back instead of storing `""`. Use it for a caption that must never render blank, such as a button label.
88
131
 
89
132
  ### What's new in 0.250.0 (contract 1.204.0)
90
133
 
@@ -2089,7 +2132,7 @@ const style = useWidgetStyle();
2089
2132
  <View style={[styles.card, style.cardBackground && { backgroundColor: style.cardBackground }]}>
2090
2133
  ```
2091
2134
 
2092
- Keep `themeDefault` for a fallback that IS a theme token: it stays display-only (a greyed placeholder in the Style panel) so the field tracks the workspace theme. Use `default` for a literal constant your code genuinely falls back to — that value now reaches `props.style`, so it must match what your code applies.
2135
+ Keep `themeDefault` for a fallback that IS a theme token: it stays display-only (shown in the Style panel field, never written) so the field tracks the workspace theme. Use `default` for a literal constant your code genuinely falls back to — that value now reaches `props.style`, so it must match what your code applies.
2093
2136
 
2094
2137
  ### What's new in 0.117.0 (contract 1.89.0)
2095
2138
 
@@ -2609,7 +2652,7 @@ Docs-only correction of documented host behaviour: no export, type, runtime, or
2609
2652
 
2610
2653
  **`themeDefault` publishes the value a style field falls back to (sc-4164).** An unset `styleSchema` field rendered as an empty box, so an author adjusting `Title size` could not see the size they were changing. `WidgetPropertyDef` now documents the key the Studio has read since sc-1807:
2611
2654
 
2612
- - **`themeDefault: "<dotted path into the resolved widget theme>"`** — e.g. `"typography.sizes.lg"`, `"radii.md"`, `"colors.onSurface"`. The Studio renders it as greyed placeholder text, resolved against the **workspace's** theme, so the hint stays truthful after a rebrand. Prefer it over a literal `default` whenever your fallback is a theme token — which it usually is, since you style from `useTheme()`.
2655
+ - **`themeDefault: "<dotted path into the resolved widget theme>"`** — e.g. `"typography.sizes.lg"`, `"radii.md"`, `"colors.onSurface"`. The Studio shows it in the field, resolved against the **workspace's** theme, so the hint stays truthful after a rebrand. Prefer it over a literal `default` whenever your fallback is a theme token — which it usually is, since you style from `useTheme()`.
2613
2656
  - **A literal `default`** still works and is the right choice for a hard-coded constant your code carries (`default: 1.4` for a line height you wrote yourself).
2614
2657
  - **Both are DISPLAY-ONLY.** Neither is ever written into `props.style`, so a style field keeps its only-when-set contract and continues to inherit the theme. Declare the value your code actually applies — a baseline that disagrees with the render is worse than none.
2615
2658
  - **Optional.** A field whose fallback has no fixed value (a `background` that inherits whatever the container paints) declares neither and renders blank.
package/dist/contract.cjs CHANGED
@@ -885,6 +885,29 @@ const HOOKS = [
885
885
  requiredContextSlice: ["i18n.t", "i18n.locale"],
886
886
  scopes: null,
887
887
  },
888
+ {
889
+ name: "useAppearance",
890
+ signature: "useAppearance()",
891
+ description:
892
+ "The app's light or dark palette, and the app user's own choice of it. mode is the palette showing (\"light\" | \"dark\"); " +
893
+ "preference is what the app follows (\"light\" | \"dark\" | \"system\", where system follows the device). " +
894
+ "setPreference(preference) switches the WHOLE app live, with no reload, through the same switch as the account screen: " +
895
+ "a signed-in user's choice is saved to their account, a visitor who is not signed in keeps it on this device, and null " +
896
+ "returns to the app's default. It resolves once the choice is saved and rejects with an Error whose .code is UNSUPPORTED " +
897
+ "(canSwitch is false: the app does not let its users switch, or this is the Builder canvas) or INVALID_APPEARANCE — always " +
898
+ "catch it. canSwitch is false on such a host: render the choices disabled then, never a dead button. Colours need no " +
899
+ "hook: useTheme() already returns the palette showing. An app without an appearance setting reads light, canSwitch false. " +
900
+ "Identical on web (Player) and the Expo export.",
901
+ returnShape: {
902
+ mode: "\"light\" | \"dark\"",
903
+ preference: "\"light\" | \"dark\" | \"system\"",
904
+ canSwitch: "boolean",
905
+ setPreference:
906
+ "(preference: \"light\" | \"dark\" | \"system\" | null) => Promise<void> // rejects with Error .code UNSUPPORTED | INVALID_APPEARANCE",
907
+ },
908
+ requiredContextSlice: [],
909
+ scopes: null,
910
+ },
888
911
  {
889
912
  name: "useTranslate",
890
913
  signature: "useTranslate()",
@@ -3998,6 +4021,16 @@ const WIDGET_CONTEXT_SHAPE = {
3998
4021
  required: false,
3999
4022
  fields: { setPhoto: "function", removePhoto: "function", anonymous: "object" },
4000
4023
  },
4024
+ // REQ-THEME-APPEARANCE — backs useAppearance().
4025
+ appearance: {
4026
+ description:
4027
+ "Optional. { mode: \"light\" | \"dark\", preference: \"light\" | \"dark\" | \"system\", setPreference?(preference) -> Promise<void> } " +
4028
+ "backs useAppearance(). setPreference is the host's own appearance switch and is present only where the app user may " +
4029
+ "switch. The whole slice is absent where the app has no appearance setting, and for a badge counter.",
4030
+ required: false,
4031
+ fields: { mode: "string", preference: "string" },
4032
+ optionalFields: { setPreference: "function" },
4033
+ },
4001
4034
  workspace: {
4002
4035
  description:
4003
4036
  "Workspace identity + resolved theme + charge currency ({ id, slug, theme, locale, currency }).",
@@ -6332,7 +6365,14 @@ const CONTRACT = deepFreeze({
6332
6365
  // `field-in-bordered-row`, `field-trailing-press-unnamed`.
6333
6366
  // 1.204.0: additive (sc-12244) — `<DataTable showHeader>` (default true);
6334
6367
  // false drops the wide layout's header row and its sticky index.
6335
- version: "1.204.0",
6368
+ // 1.205.0: additive (sc-11840) — a string property's `default` shows as the
6369
+ // field's editable text and clearing stores ""; `ui.placeholder` and
6370
+ // `ui.clearRestoresDefault` are typed.
6371
+ // 1.206.0: additive (sc-12335) — `useAppearance()` returns the palette
6372
+ // showing, the app's appearance preference, `canSwitch` and
6373
+ // `setPreference`, backed by the optional `appearance` host slice. Hosts
6374
+ // without it read light / false.
6375
+ version: "1.206.0",
6336
6376
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6337
6377
  hooks: HOOKS,
6338
6378
  primitives: PRIMITIVES,
@@ -6741,8 +6781,14 @@ function scaleSpacing(value, scale) {
6741
6781
  return Math.round(value * clampSpacingScale(scale));
6742
6782
  }
6743
6783
 
6784
+ // REQ-THEME-APPEARANCE (sc-12335): what an app opens on, and what its users
6785
+ // may choose — a palette or the device's. One list for useAppearance() and the
6786
+ // host resolvers in theme-appearance.js.
6787
+ const APPEARANCE_PREFERENCES = Object.freeze(["light", "dark", "system"]);
6788
+
6744
6789
  module.exports = {
6745
6790
  CONTRACT,
6791
+ APPEARANCE_PREFERENCES,
6746
6792
  clampSpacingScale,
6747
6793
  scaleSpacing,
6748
6794
  normaliseActionTriggerTypes,
package/dist/contract.js CHANGED
@@ -885,6 +885,29 @@ const HOOKS = [
885
885
  requiredContextSlice: ["i18n.t", "i18n.locale"],
886
886
  scopes: null,
887
887
  },
888
+ {
889
+ name: "useAppearance",
890
+ signature: "useAppearance()",
891
+ description:
892
+ "The app's light or dark palette, and the app user's own choice of it. mode is the palette showing (\"light\" | \"dark\"); " +
893
+ "preference is what the app follows (\"light\" | \"dark\" | \"system\", where system follows the device). " +
894
+ "setPreference(preference) switches the WHOLE app live, with no reload, through the same switch as the account screen: " +
895
+ "a signed-in user's choice is saved to their account, a visitor who is not signed in keeps it on this device, and null " +
896
+ "returns to the app's default. It resolves once the choice is saved and rejects with an Error whose .code is UNSUPPORTED " +
897
+ "(canSwitch is false: the app does not let its users switch, or this is the Builder canvas) or INVALID_APPEARANCE — always " +
898
+ "catch it. canSwitch is false on such a host: render the choices disabled then, never a dead button. Colours need no " +
899
+ "hook: useTheme() already returns the palette showing. An app without an appearance setting reads light, canSwitch false. " +
900
+ "Identical on web (Player) and the Expo export.",
901
+ returnShape: {
902
+ mode: "\"light\" | \"dark\"",
903
+ preference: "\"light\" | \"dark\" | \"system\"",
904
+ canSwitch: "boolean",
905
+ setPreference:
906
+ "(preference: \"light\" | \"dark\" | \"system\" | null) => Promise<void> // rejects with Error .code UNSUPPORTED | INVALID_APPEARANCE",
907
+ },
908
+ requiredContextSlice: [],
909
+ scopes: null,
910
+ },
888
911
  {
889
912
  name: "useTranslate",
890
913
  signature: "useTranslate()",
@@ -3994,6 +4017,16 @@ const WIDGET_CONTEXT_SHAPE = {
3994
4017
  required: false,
3995
4018
  fields: { setPhoto: "function", removePhoto: "function", anonymous: "object" },
3996
4019
  },
4020
+ // REQ-THEME-APPEARANCE — backs useAppearance().
4021
+ appearance: {
4022
+ description:
4023
+ "Optional. { mode: \"light\" | \"dark\", preference: \"light\" | \"dark\" | \"system\", setPreference?(preference) -> Promise<void> } " +
4024
+ "backs useAppearance(). setPreference is the host's own appearance switch and is present only where the app user may " +
4025
+ "switch. The whole slice is absent where the app has no appearance setting, and for a badge counter.",
4026
+ required: false,
4027
+ fields: { mode: "string", preference: "string" },
4028
+ optionalFields: { setPreference: "function" },
4029
+ },
3997
4030
  workspace: {
3998
4031
  description:
3999
4032
  "Workspace identity + resolved theme + charge currency ({ id, slug, theme, locale, currency }).",
@@ -6328,7 +6361,14 @@ const CONTRACT = deepFreeze({
6328
6361
  // `field-in-bordered-row`, `field-trailing-press-unnamed`.
6329
6362
  // 1.204.0: additive (sc-12244) — `<DataTable showHeader>` (default true);
6330
6363
  // false drops the wide layout's header row and its sticky index.
6331
- version: "1.204.0",
6364
+ // 1.205.0: additive (sc-11840) — a string property's `default` shows as the
6365
+ // field's editable text and clearing stores ""; `ui.placeholder` and
6366
+ // `ui.clearRestoresDefault` are typed.
6367
+ // 1.206.0: additive (sc-12335) — `useAppearance()` returns the palette
6368
+ // showing, the app's appearance preference, `canSwitch` and
6369
+ // `setPreference`, backed by the optional `appearance` host slice. Hosts
6370
+ // without it read light / false.
6371
+ version: "1.206.0",
6332
6372
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6333
6373
  hooks: HOOKS,
6334
6374
  primitives: PRIMITIVES,
@@ -6737,8 +6777,14 @@ function scaleSpacing(value, scale) {
6737
6777
  return Math.round(value * clampSpacingScale(scale));
6738
6778
  }
6739
6779
 
6780
+ // REQ-THEME-APPEARANCE (sc-12335): what an app opens on, and what its users
6781
+ // may choose — a palette or the device's. One list for useAppearance() and the
6782
+ // host resolvers in theme-appearance.js.
6783
+ const APPEARANCE_PREFERENCES = Object.freeze(["light", "dark", "system"]);
6784
+
6740
6785
  export {
6741
6786
  CONTRACT,
6787
+ APPEARANCE_PREFERENCES,
6742
6788
  clampSpacingScale,
6743
6789
  scaleSpacing,
6744
6790
  normaliseActionTriggerTypes,
package/dist/hooks.js CHANGED
@@ -48,6 +48,8 @@ import {
48
48
  // host codec so the hook and the encoder can never disagree about it.
49
49
  sameWidgetRouteValue,
50
50
  deriveSurfaceTokens,
51
+ // sc-12335 — the light/dark vocabulary useAppearance() validates against.
52
+ APPEARANCE_PREFERENCES,
51
53
  } from "./contract.js";
52
54
 
53
55
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
@@ -1105,6 +1107,47 @@ export function useI18n() {
1105
1107
  return { t, locale, languages, canSetLocale: hostSetLocale !== null, setLocale };
1106
1108
  }
1107
1109
 
1110
+ function appearanceError(code, message) {
1111
+ const err = new Error(message);
1112
+ err.code = code;
1113
+ return err;
1114
+ }
1115
+
1116
+ /**
1117
+ * REQ-THEME-APPEARANCE — `{ mode, preference, canSwitch, setPreference }`.
1118
+ * `mode` is the palette showing ("light" | "dark"); `preference` is what the
1119
+ * app follows ("light" | "dark" | "system"). `setPreference` is the host's own
1120
+ * switch, the one its account screen uses; it rejects UNSUPPORTED where the
1121
+ * user may not switch (`canSwitch` false) and INVALID_APPEARANCE for any other
1122
+ * value. Without the optional `ctx.appearance` slice: light, and no switch.
1123
+ */
1124
+ export function useAppearance() {
1125
+ const ctx = useWidgetContextOrThrow("useAppearance");
1126
+ const slice = ctx.appearance || null;
1127
+ const mode = slice && slice.mode === "dark" ? "dark" : "light";
1128
+ const preference =
1129
+ slice && APPEARANCE_PREFERENCES.includes(slice.preference) ? slice.preference : mode;
1130
+ const hostSetPreference =
1131
+ slice && typeof slice.setPreference === "function" ? slice.setPreference : null;
1132
+ // Validated here so both hosts refuse a bad value identically.
1133
+ const setPreference = useCallback(
1134
+ async (next) => {
1135
+ if (!hostSetPreference) {
1136
+ throw appearanceError("UNSUPPORTED", "The appearance cannot be changed here.");
1137
+ }
1138
+ if (next !== null && !APPEARANCE_PREFERENCES.includes(next)) {
1139
+ throw appearanceError(
1140
+ "INVALID_APPEARANCE",
1141
+ `"${next}" is not one of: ${APPEARANCE_PREFERENCES.join(", ")}.`,
1142
+ );
1143
+ }
1144
+ await hostSetPreference(next);
1145
+ },
1146
+ [hostSetPreference],
1147
+ );
1148
+ return { mode, preference, canSwitch: hostSetPreference !== null, setPreference };
1149
+ }
1150
+
1108
1151
  /**
1109
1152
  * sc-3783 — structured error thrown by `useTranslate().translate`. Carries a
1110
1153
  * stable `code` so widgets branch without parsing message strings; mirrors the
package/dist/host.d.ts CHANGED
@@ -653,3 +653,62 @@ export function formatDateTimeLabel(
653
653
  mode: "date" | "time" | "datetime",
654
654
  locale?: string,
655
655
  ): string;
656
+
657
+ /** REQ-THEME-APPEARANCE — the two palettes an app can render. */
658
+ export type AppearanceMode = "light" | "dark";
659
+
660
+ /** What an app opens on, and what a user may choose: a mode or the device's. */
661
+ export type AppearancePreference = AppearanceMode | "system";
662
+
663
+ /** A normalised `theme_config.appearance`. */
664
+ export interface AppearanceSettings {
665
+ default: AppearancePreference;
666
+ userCanSwitch: boolean;
667
+ }
668
+
669
+ export const APPEARANCE_MODES: readonly AppearanceMode[];
670
+ export const APPEARANCE_PREFERENCES: readonly AppearancePreference[];
671
+
672
+ /**
673
+ * The colour-bearing part of a theme (every hex leaf and every gradient, at its
674
+ * path): exactly the shape a `theme_config.dark` palette may hold.
675
+ */
676
+ export function themePaletteOf(theme: unknown): Record<string, unknown>;
677
+
678
+ /** A stored `theme_config.appearance`, or null when it is not one. */
679
+ export function normaliseAppearance(raw: unknown): AppearanceSettings | null;
680
+
681
+ /**
682
+ * What an app follows: the user's choice where the app lets users switch,
683
+ * else the app's default. No appearance setting resolves to light.
684
+ */
685
+ export function resolveAppearancePreference(input?: {
686
+ appearance?: unknown;
687
+ userChoice?: unknown;
688
+ }): AppearancePreference;
689
+
690
+ /**
691
+ * Which palette an app shows: its preference, where `system` follows
692
+ * `systemScheme` and reads light when unknown.
693
+ */
694
+ export function resolveAppearanceMode(input?: {
695
+ appearance?: unknown;
696
+ userChoice?: unknown;
697
+ systemScheme?: unknown;
698
+ }): AppearanceMode;
699
+
700
+ /**
701
+ * The theme to render in `mode`, without `dark` or `appearance`. Dark lays the
702
+ * `dark` palette's colours over the base; light is the base. A theme carrying
703
+ * neither key is returned by reference.
704
+ */
705
+ export function resolveThemeForAppearance<T>(themeConfig: T, mode: AppearanceMode): T;
706
+
707
+ /** The top-level theme keys a `dark` palette may recolour. */
708
+ export const DARK_PALETTE_KEYS: readonly string[];
709
+
710
+ /**
711
+ * A theme painted in `mode`, plus the other mode's palette, as an app with an
712
+ * appearance setting stores it: the base light, `dark` the dark palette.
713
+ */
714
+ export function themeInBothModes<T>(theme: T, mode: AppearanceMode, twin: unknown): T;
package/dist/host.js CHANGED
@@ -152,6 +152,21 @@ export {
152
152
  resolveLoginTextVisibility,
153
153
  } from "./theme-background.js";
154
154
 
155
+ // REQ-THEME-APPEARANCE (sc-12335): an app's light and dark palettes. Each host
156
+ // resolves the mode it shows and the theme it renders through these, so a dark
157
+ // palette and the choice of palette mean the same thing on web and native.
158
+ export {
159
+ APPEARANCE_MODES,
160
+ APPEARANCE_PREFERENCES,
161
+ themePaletteOf,
162
+ normaliseAppearance,
163
+ resolveAppearancePreference,
164
+ resolveAppearanceMode,
165
+ resolveThemeForAppearance,
166
+ DARK_PALETTE_KEYS,
167
+ themeInBothModes,
168
+ } from "./theme-appearance.js";
169
+
155
170
  // sc-8448: one DATETIME local-clock conversion for the picker and the Studio.
156
171
  export {
157
172
  isoToDateTimeLocalInput,
package/dist/index.d.ts CHANGED
@@ -87,8 +87,8 @@ export interface WidgetPropertyDef {
87
87
  // sc-1807 / sc-4164 — the value the widget falls back to when this field is
88
88
  // unset, as a dotted path into the resolved widget theme
89
89
  // (`"typography.sizes.lg"`, `"radii.md"`, `"colors.onSurface"`). The Studio
90
- // shows it as greyed placeholder text so the author can see what they are
91
- // adjusting from; it resolves against the WORKSPACE's theme, so the hint stays
90
+ // shows it in the field (a greyed hint for colours) so the author can see
91
+ // what they are adjusting from; it resolves against the WORKSPACE's theme, so the hint stays
92
92
  // truthful after a rebrand. DISPLAY-ONLY — never written into props.style, so
93
93
  // a styleSchema field keeps its only-when-set contract. Prefer this over a
94
94
  // literal `default` whenever the fallback is a theme token.
@@ -106,6 +106,11 @@ export interface WidgetPropertyDef {
106
106
  // a static `default`). Today: `"tabId"` → seed a unique stable id
107
107
  // for newly added Tab Layout tabs.
108
108
  defaultFactory?: "tabId";
109
+ // string: a grey hint shown while the field is empty.
110
+ placeholder?: string;
111
+ // string: clearing restores `default` instead of storing "" — for a
112
+ // caption that must never render blank (a button label).
113
+ clearRestoresDefault?: boolean;
109
114
  };
110
115
  validation?: { min?: number; max?: number; pattern?: string };
111
116
  }
@@ -840,6 +845,15 @@ export interface WidgetContext<TProps = unknown> {
840
845
  removePhoto?(): Promise<{ photoUrl: null }>;
841
846
  anonymous?: AnonymousAccountHost;
842
847
  };
848
+ /**
849
+ * Optional; backs `useAppearance()`. `setPreference` is present only where
850
+ * the app user may switch. Absent where the app has no appearance setting.
851
+ */
852
+ appearance?: {
853
+ mode: "light" | "dark";
854
+ preference: AppearancePreference;
855
+ setPreference?(preference: AppearancePreference | null): Promise<void>;
856
+ };
843
857
  workspace: {
844
858
  id: string;
845
859
  slug: string;
@@ -2013,6 +2027,26 @@ export function useI18n(): {
2013
2027
  setLocale(code: string | null): Promise<void>;
2014
2028
  };
2015
2029
 
2030
+ /** What an app follows: a fixed palette, or the device's setting. */
2031
+ export type AppearancePreference = "light" | "dark" | "system";
2032
+
2033
+ /**
2034
+ * The app's light or dark palette, and the app user's own choice of it.
2035
+ * `mode` is the palette showing; `preference` is what the app follows.
2036
+ * `setPreference` switches the whole app live, through the same switch as the
2037
+ * account screen: a signed-in user's choice is saved to their account, a
2038
+ * visitor's stays on the device, and `null` returns to the app's default.
2039
+ * Rejects with an Error whose `.code` is `UNSUPPORTED` (`canSwitch` is false:
2040
+ * the app does not let users switch, or this is the Builder canvas) or
2041
+ * `INVALID_APPEARANCE`.
2042
+ */
2043
+ export function useAppearance(): {
2044
+ mode: "light" | "dark";
2045
+ preference: AppearancePreference;
2046
+ canSwitch: boolean;
2047
+ setPreference(preference: AppearancePreference | null): Promise<void>;
2048
+ };
2049
+
2016
2050
  /**
2017
2051
  * sc-3783 — translate USER-GENERATED content (record text, file names, API
2018
2052
  * payloads) into the app user's selected language.
package/dist/index.js CHANGED
@@ -72,6 +72,7 @@ export {
72
72
  useTheme,
73
73
  useWidgetStyle,
74
74
  useI18n,
75
+ useAppearance,
75
76
  useTranslate,
76
77
  TranslateError,
77
78
  useLinkPreview,
@@ -72,6 +72,7 @@ export {
72
72
  useTheme,
73
73
  useWidgetStyle,
74
74
  useI18n,
75
+ useAppearance,
75
76
  useTranslate,
76
77
  TranslateError,
77
78
  useLinkPreview,
@@ -0,0 +1,195 @@
1
+ // CommonJS mirror of theme-appearance.js, for the backend's theme coercion and
2
+ // the compiler. The BODY is copied VERBATIM; theme-appearance-parity.test.js
3
+ // pins identical bodies and identical behaviour.
4
+
5
+ const { isHexColor, APPEARANCE_PREFERENCES } = require("./contract.cjs");
6
+
7
+ // REQ-THEME-APPEARANCE (sc-12335) — the ONE implementation of an app's light and
8
+ // dark palettes, and of which one is showing. Host surface, not author API.
9
+
10
+ // Deeper than any stored theme nests, so a hand-edited blob stays bounded.
11
+ const MAX_PALETTE_DEPTH = 8;
12
+
13
+ // Assigned onto object literals, `__proto__` would set a prototype.
14
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
15
+
16
+ const APPEARANCE_THEME_KEYS = ["dark", "appearance"];
17
+
18
+ /** The two palettes an app can render. */
19
+ const APPEARANCE_MODES = Object.freeze(["light", "dark"]);
20
+
21
+ function isPlainObject(value) {
22
+ return value !== null && typeof value === "object" && !Array.isArray(value);
23
+ }
24
+
25
+ // A gradient is one colour value: its angle and type travel with its stops.
26
+ function isGradient(value) {
27
+ return isPlainObject(value) && isHexColor(value.from) && isHexColor(value.to);
28
+ }
29
+
30
+ function isPaletteColour(value) {
31
+ return isHexColor(value) || isGradient(value);
32
+ }
33
+
34
+ function paletteAt(value, depth) {
35
+ const out = {};
36
+ if (!isPlainObject(value) || depth > MAX_PALETTE_DEPTH) return out;
37
+ for (const [key, child] of Object.entries(value)) {
38
+ if (UNSAFE_KEYS.has(key)) continue;
39
+ if (isPaletteColour(child)) {
40
+ out[key] = child;
41
+ continue;
42
+ }
43
+ const nested = paletteAt(child, depth + 1);
44
+ if (Object.keys(nested).length) out[key] = nested;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * The colour-bearing part of a theme: every hex leaf and every gradient, at
51
+ * the path it sits on. Fonts, sizes, radii, spacing, enums, booleans and arrays
52
+ * drop out, and an object left with no colour is omitted, so the result is
53
+ * exactly the shape a `dark` palette may hold.
54
+ *
55
+ * @param {unknown} theme — a `theme_config`, or any part of one.
56
+ * @returns {Record<string, unknown>} never null.
57
+ */
58
+ function themePaletteOf(theme) {
59
+ return paletteAt(theme, 0);
60
+ }
61
+
62
+ /**
63
+ * A stored or untrusted `theme_config.appearance`, or null when it is not one.
64
+ * `default` must be a known preference; `userCanSwitch` is true only when it
65
+ * is literally true.
66
+ *
67
+ * @returns {{ default: "light"|"dark"|"system", userCanSwitch: boolean } | null}
68
+ */
69
+ function normaliseAppearance(raw) {
70
+ if (!isPlainObject(raw)) return null;
71
+ if (!APPEARANCE_PREFERENCES.includes(raw.default)) return null;
72
+ return { default: raw.default, userCanSwitch: raw.userCanSwitch === true };
73
+ }
74
+
75
+ /**
76
+ * What an app follows: a fixed mode, or the device's. A user's choice wins
77
+ * only where the app lets users switch; otherwise the app's default applies.
78
+ * No appearance setting is today's fixed light palette.
79
+ *
80
+ * @param {{ appearance?: unknown, userChoice?: unknown }} [input]
81
+ * @returns {"light"|"dark"|"system"}
82
+ */
83
+ function resolveAppearancePreference({ appearance, userChoice } = {}) {
84
+ const settings = normaliseAppearance(appearance);
85
+ if (!settings) return "light";
86
+ return settings.userCanSwitch && APPEARANCE_PREFERENCES.includes(userChoice)
87
+ ? userChoice
88
+ : settings.default;
89
+ }
90
+
91
+ /**
92
+ * Which palette an app shows: its preference, where `system` follows the
93
+ * device and reads light when the device reports nothing.
94
+ *
95
+ * @param {{ appearance?: unknown, userChoice?: unknown, systemScheme?: unknown }} [input]
96
+ * @returns {"light"|"dark"}
97
+ */
98
+ function resolveAppearanceMode({ appearance, userChoice, systemScheme } = {}) {
99
+ const preference = resolveAppearancePreference({ appearance, userChoice });
100
+ if (preference === "system") return systemScheme === "dark" ? "dark" : "light";
101
+ return preference;
102
+ }
103
+
104
+ function overlayPalette(base, palette) {
105
+ const out = { ...base };
106
+ for (const [key, value] of Object.entries(palette)) {
107
+ out[key] = isPaletteColour(value)
108
+ ? value
109
+ : overlayPalette(isPlainObject(base[key]) ? base[key] : {}, value);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * The theme a host renders in `mode`, with no `dark` or `appearance` key left
116
+ * on it. In dark mode the `dark` palette is laid over the base key by key, and
117
+ * only its colours are read. Light mode, or a theme with no `dark`, is the base.
118
+ * A theme carrying neither key is returned as is, by reference.
119
+ *
120
+ * @param {unknown} themeConfig — a stored `theme_config`.
121
+ * @param {"light"|"dark"} mode — from `resolveAppearanceMode`.
122
+ */
123
+ function resolveThemeForAppearance(themeConfig, mode) {
124
+ if (!isPlainObject(themeConfig)) return themeConfig;
125
+ if (!APPEARANCE_THEME_KEYS.some((key) => Object.hasOwn(themeConfig, key))) {
126
+ return themeConfig;
127
+ }
128
+ // `fromEntries`, not assignment, so a stored `__proto__` stays a plain key.
129
+ const base = Object.fromEntries(
130
+ Object.entries(themeConfig).filter(([key]) => !APPEARANCE_THEME_KEYS.includes(key)),
131
+ );
132
+ if (mode !== "dark") return base;
133
+ return overlayPalette(base, themePaletteOf(themeConfig.dark));
134
+ }
135
+
136
+ /**
137
+ * The top-level keys a `dark` palette may recolour. The backend's theme
138
+ * coercion derives the same set from its own vocabulary; a test pins the two.
139
+ */
140
+ const DARK_PALETTE_KEYS = Object.freeze([
141
+ "primaryColor",
142
+ "secondaryColor",
143
+ "backgroundColor",
144
+ "backgroundGradient",
145
+ "loaderColor",
146
+ "login",
147
+ "sidebar",
148
+ "topBar",
149
+ "footer",
150
+ "siteHeader",
151
+ "typographyRoles",
152
+ "components",
153
+ "widgetStyles",
154
+ ]);
155
+
156
+ function recolourablePalette(palette) {
157
+ return Object.fromEntries(
158
+ Object.entries(themePaletteOf(palette)).filter(([key]) => DARK_PALETTE_KEYS.includes(key)),
159
+ );
160
+ }
161
+
162
+ function withDarkPalette(base, palette) {
163
+ return Object.keys(palette).length ? { ...base, dark: palette } : base;
164
+ }
165
+
166
+ /**
167
+ * `theme`, painted in `mode`, stored the way an app with an appearance setting
168
+ * keeps it: the base is the light palette and `dark` the dark one. `twin` is the
169
+ * palette of the other mode. Painted light, the base stays and `twin` becomes
170
+ * `dark`. Painted dark, the theme's own colours become `dark` and `twin` is laid
171
+ * over the base. Everything that is not a colour stays as `theme` has it.
172
+ *
173
+ * @param {unknown} theme — a `theme_config`; any `dark` it carries is replaced.
174
+ * @param {"light"|"dark"} mode — the mode `theme`'s base is painted in.
175
+ * @param {unknown} twin — the other mode's palette (`themePaletteOf` shape).
176
+ */
177
+ function themeInBothModes(theme, mode, twin) {
178
+ if (!isPlainObject(theme)) return theme;
179
+ const base = Object.fromEntries(Object.entries(theme).filter(([key]) => key !== "dark"));
180
+ const twinPalette = recolourablePalette(twin);
181
+ if (mode !== "dark") return withDarkPalette(base, twinPalette);
182
+ return withDarkPalette(overlayPalette(base, twinPalette), recolourablePalette(base));
183
+ }
184
+
185
+ module.exports = {
186
+ APPEARANCE_MODES,
187
+ APPEARANCE_PREFERENCES,
188
+ themePaletteOf,
189
+ normaliseAppearance,
190
+ resolveAppearancePreference,
191
+ resolveAppearanceMode,
192
+ resolveThemeForAppearance,
193
+ DARK_PALETTE_KEYS,
194
+ themeInBothModes,
195
+ };
@@ -0,0 +1,182 @@
1
+ import { isHexColor, APPEARANCE_PREFERENCES } from "./contract.js";
2
+
3
+ // What an app opens on and a user may choose; one list, in the contract.
4
+ export { APPEARANCE_PREFERENCES };
5
+
6
+ // REQ-THEME-APPEARANCE (sc-12335) — the ONE implementation of an app's light and
7
+ // dark palettes, and of which one is showing. Host surface, not author API.
8
+
9
+ // Deeper than any stored theme nests, so a hand-edited blob stays bounded.
10
+ const MAX_PALETTE_DEPTH = 8;
11
+
12
+ // Assigned onto object literals, `__proto__` would set a prototype.
13
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
14
+
15
+ const APPEARANCE_THEME_KEYS = ["dark", "appearance"];
16
+
17
+ /** The two palettes an app can render. */
18
+ export const APPEARANCE_MODES = Object.freeze(["light", "dark"]);
19
+
20
+ function isPlainObject(value) {
21
+ return value !== null && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+
24
+ // A gradient is one colour value: its angle and type travel with its stops.
25
+ function isGradient(value) {
26
+ return isPlainObject(value) && isHexColor(value.from) && isHexColor(value.to);
27
+ }
28
+
29
+ function isPaletteColour(value) {
30
+ return isHexColor(value) || isGradient(value);
31
+ }
32
+
33
+ function paletteAt(value, depth) {
34
+ const out = {};
35
+ if (!isPlainObject(value) || depth > MAX_PALETTE_DEPTH) return out;
36
+ for (const [key, child] of Object.entries(value)) {
37
+ if (UNSAFE_KEYS.has(key)) continue;
38
+ if (isPaletteColour(child)) {
39
+ out[key] = child;
40
+ continue;
41
+ }
42
+ const nested = paletteAt(child, depth + 1);
43
+ if (Object.keys(nested).length) out[key] = nested;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * The colour-bearing part of a theme: every hex leaf and every gradient, at
50
+ * the path it sits on. Fonts, sizes, radii, spacing, enums, booleans and arrays
51
+ * drop out, and an object left with no colour is omitted, so the result is
52
+ * exactly the shape a `dark` palette may hold.
53
+ *
54
+ * @param {unknown} theme — a `theme_config`, or any part of one.
55
+ * @returns {Record<string, unknown>} never null.
56
+ */
57
+ export function themePaletteOf(theme) {
58
+ return paletteAt(theme, 0);
59
+ }
60
+
61
+ /**
62
+ * A stored or untrusted `theme_config.appearance`, or null when it is not one.
63
+ * `default` must be a known preference; `userCanSwitch` is true only when it
64
+ * is literally true.
65
+ *
66
+ * @returns {{ default: "light"|"dark"|"system", userCanSwitch: boolean } | null}
67
+ */
68
+ export function normaliseAppearance(raw) {
69
+ if (!isPlainObject(raw)) return null;
70
+ if (!APPEARANCE_PREFERENCES.includes(raw.default)) return null;
71
+ return { default: raw.default, userCanSwitch: raw.userCanSwitch === true };
72
+ }
73
+
74
+ /**
75
+ * What an app follows: a fixed mode, or the device's. A user's choice wins
76
+ * only where the app lets users switch; otherwise the app's default applies.
77
+ * No appearance setting is today's fixed light palette.
78
+ *
79
+ * @param {{ appearance?: unknown, userChoice?: unknown }} [input]
80
+ * @returns {"light"|"dark"|"system"}
81
+ */
82
+ export function resolveAppearancePreference({ appearance, userChoice } = {}) {
83
+ const settings = normaliseAppearance(appearance);
84
+ if (!settings) return "light";
85
+ return settings.userCanSwitch && APPEARANCE_PREFERENCES.includes(userChoice)
86
+ ? userChoice
87
+ : settings.default;
88
+ }
89
+
90
+ /**
91
+ * Which palette an app shows: its preference, where `system` follows the
92
+ * device and reads light when the device reports nothing.
93
+ *
94
+ * @param {{ appearance?: unknown, userChoice?: unknown, systemScheme?: unknown }} [input]
95
+ * @returns {"light"|"dark"}
96
+ */
97
+ export function resolveAppearanceMode({ appearance, userChoice, systemScheme } = {}) {
98
+ const preference = resolveAppearancePreference({ appearance, userChoice });
99
+ if (preference === "system") return systemScheme === "dark" ? "dark" : "light";
100
+ return preference;
101
+ }
102
+
103
+ function overlayPalette(base, palette) {
104
+ const out = { ...base };
105
+ for (const [key, value] of Object.entries(palette)) {
106
+ out[key] = isPaletteColour(value)
107
+ ? value
108
+ : overlayPalette(isPlainObject(base[key]) ? base[key] : {}, value);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * The theme a host renders in `mode`, with no `dark` or `appearance` key left
115
+ * on it. In dark mode the `dark` palette is laid over the base key by key, and
116
+ * only its colours are read. Light mode, or a theme with no `dark`, is the base.
117
+ * A theme carrying neither key is returned as is, by reference.
118
+ *
119
+ * @param {unknown} themeConfig — a stored `theme_config`.
120
+ * @param {"light"|"dark"} mode — from `resolveAppearanceMode`.
121
+ */
122
+ export function resolveThemeForAppearance(themeConfig, mode) {
123
+ if (!isPlainObject(themeConfig)) return themeConfig;
124
+ if (!APPEARANCE_THEME_KEYS.some((key) => Object.hasOwn(themeConfig, key))) {
125
+ return themeConfig;
126
+ }
127
+ // `fromEntries`, not assignment, so a stored `__proto__` stays a plain key.
128
+ const base = Object.fromEntries(
129
+ Object.entries(themeConfig).filter(([key]) => !APPEARANCE_THEME_KEYS.includes(key)),
130
+ );
131
+ if (mode !== "dark") return base;
132
+ return overlayPalette(base, themePaletteOf(themeConfig.dark));
133
+ }
134
+
135
+ /**
136
+ * The top-level keys a `dark` palette may recolour. The backend's theme
137
+ * coercion derives the same set from its own vocabulary; a test pins the two.
138
+ */
139
+ export const DARK_PALETTE_KEYS = Object.freeze([
140
+ "primaryColor",
141
+ "secondaryColor",
142
+ "backgroundColor",
143
+ "backgroundGradient",
144
+ "loaderColor",
145
+ "login",
146
+ "sidebar",
147
+ "topBar",
148
+ "footer",
149
+ "siteHeader",
150
+ "typographyRoles",
151
+ "components",
152
+ "widgetStyles",
153
+ ]);
154
+
155
+ function recolourablePalette(palette) {
156
+ return Object.fromEntries(
157
+ Object.entries(themePaletteOf(palette)).filter(([key]) => DARK_PALETTE_KEYS.includes(key)),
158
+ );
159
+ }
160
+
161
+ function withDarkPalette(base, palette) {
162
+ return Object.keys(palette).length ? { ...base, dark: palette } : base;
163
+ }
164
+
165
+ /**
166
+ * `theme`, painted in `mode`, stored the way an app with an appearance setting
167
+ * keeps it: the base is the light palette and `dark` the dark one. `twin` is the
168
+ * palette of the other mode. Painted light, the base stays and `twin` becomes
169
+ * `dark`. Painted dark, the theme's own colours become `dark` and `twin` is laid
170
+ * over the base. Everything that is not a colour stays as `theme` has it.
171
+ *
172
+ * @param {unknown} theme — a `theme_config`; any `dark` it carries is replaced.
173
+ * @param {"light"|"dark"} mode — the mode `theme`'s base is painted in.
174
+ * @param {unknown} twin — the other mode's palette (`themePaletteOf` shape).
175
+ */
176
+ export function themeInBothModes(theme, mode, twin) {
177
+ if (!isPlainObject(theme)) return theme;
178
+ const base = Object.fromEntries(Object.entries(theme).filter(([key]) => key !== "dark"));
179
+ const twinPalette = recolourablePalette(twin);
180
+ if (mode !== "dark") return withDarkPalette(base, twinPalette);
181
+ return withDarkPalette(overlayPalette(base, twinPalette), recolourablePalette(base));
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.250.0",
3
+ "version": "0.255.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",
package/src/contract.js CHANGED
@@ -885,6 +885,29 @@ const HOOKS = [
885
885
  requiredContextSlice: ["i18n.t", "i18n.locale"],
886
886
  scopes: null,
887
887
  },
888
+ {
889
+ name: "useAppearance",
890
+ signature: "useAppearance()",
891
+ description:
892
+ "The app's light or dark palette, and the app user's own choice of it. mode is the palette showing (\"light\" | \"dark\"); " +
893
+ "preference is what the app follows (\"light\" | \"dark\" | \"system\", where system follows the device). " +
894
+ "setPreference(preference) switches the WHOLE app live, with no reload, through the same switch as the account screen: " +
895
+ "a signed-in user's choice is saved to their account, a visitor who is not signed in keeps it on this device, and null " +
896
+ "returns to the app's default. It resolves once the choice is saved and rejects with an Error whose .code is UNSUPPORTED " +
897
+ "(canSwitch is false: the app does not let its users switch, or this is the Builder canvas) or INVALID_APPEARANCE — always " +
898
+ "catch it. canSwitch is false on such a host: render the choices disabled then, never a dead button. Colours need no " +
899
+ "hook: useTheme() already returns the palette showing. An app without an appearance setting reads light, canSwitch false. " +
900
+ "Identical on web (Player) and the Expo export.",
901
+ returnShape: {
902
+ mode: "\"light\" | \"dark\"",
903
+ preference: "\"light\" | \"dark\" | \"system\"",
904
+ canSwitch: "boolean",
905
+ setPreference:
906
+ "(preference: \"light\" | \"dark\" | \"system\" | null) => Promise<void> // rejects with Error .code UNSUPPORTED | INVALID_APPEARANCE",
907
+ },
908
+ requiredContextSlice: [],
909
+ scopes: null,
910
+ },
888
911
  {
889
912
  name: "useTranslate",
890
913
  signature: "useTranslate()",
@@ -3994,6 +4017,16 @@ const WIDGET_CONTEXT_SHAPE = {
3994
4017
  required: false,
3995
4018
  fields: { setPhoto: "function", removePhoto: "function", anonymous: "object" },
3996
4019
  },
4020
+ // REQ-THEME-APPEARANCE — backs useAppearance().
4021
+ appearance: {
4022
+ description:
4023
+ "Optional. { mode: \"light\" | \"dark\", preference: \"light\" | \"dark\" | \"system\", setPreference?(preference) -> Promise<void> } " +
4024
+ "backs useAppearance(). setPreference is the host's own appearance switch and is present only where the app user may " +
4025
+ "switch. The whole slice is absent where the app has no appearance setting, and for a badge counter.",
4026
+ required: false,
4027
+ fields: { mode: "string", preference: "string" },
4028
+ optionalFields: { setPreference: "function" },
4029
+ },
3997
4030
  workspace: {
3998
4031
  description:
3999
4032
  "Workspace identity + resolved theme + charge currency ({ id, slug, theme, locale, currency }).",
@@ -6328,7 +6361,14 @@ const CONTRACT = deepFreeze({
6328
6361
  // `field-in-bordered-row`, `field-trailing-press-unnamed`.
6329
6362
  // 1.204.0: additive (sc-12244) — `<DataTable showHeader>` (default true);
6330
6363
  // false drops the wide layout's header row and its sticky index.
6331
- version: "1.204.0",
6364
+ // 1.205.0: additive (sc-11840) — a string property's `default` shows as the
6365
+ // field's editable text and clearing stores ""; `ui.placeholder` and
6366
+ // `ui.clearRestoresDefault` are typed.
6367
+ // 1.206.0: additive (sc-12335) — `useAppearance()` returns the palette
6368
+ // showing, the app's appearance preference, `canSwitch` and
6369
+ // `setPreference`, backed by the optional `appearance` host slice. Hosts
6370
+ // without it read light / false.
6371
+ version: "1.206.0",
6332
6372
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6333
6373
  hooks: HOOKS,
6334
6374
  primitives: PRIMITIVES,
@@ -6737,8 +6777,14 @@ function scaleSpacing(value, scale) {
6737
6777
  return Math.round(value * clampSpacingScale(scale));
6738
6778
  }
6739
6779
 
6780
+ // REQ-THEME-APPEARANCE (sc-12335): what an app opens on, and what its users
6781
+ // may choose — a palette or the device's. One list for useAppearance() and the
6782
+ // host resolvers in theme-appearance.js.
6783
+ const APPEARANCE_PREFERENCES = Object.freeze(["light", "dark", "system"]);
6784
+
6740
6785
  export {
6741
6786
  CONTRACT,
6787
+ APPEARANCE_PREFERENCES,
6742
6788
  clampSpacingScale,
6743
6789
  scaleSpacing,
6744
6790
  normaliseActionTriggerTypes,