@colixsystems/widget-sdk 0.251.0 → 0.256.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,46 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
85
 
85
86
  ## Status
86
87
 
87
- `v0.251.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.256.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.256.0 (contract 1.207.0)
91
+
92
+ **A page's top navigation bar can centre its tabs again.** `resolveTopBarTokens().tabAlignment` is `"left"` | `"center"` | `"right"`. A `theme_config.topBar.tabAlignment` of `"center"` places the tabs between two flexible spacers, never by centring the row, so a row with more tabs than fit still scrolls from its first tab. Unset or unknown still resolves to `"left"`, so an app that never chose is unchanged. Host-integration surface only — no author-facing hook, prop, primitive, or manifest field changed.
93
+
94
+ ### What's new in 0.255.0
95
+
96
+ **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.
97
+
98
+ ### What's new in 0.254.0
99
+
100
+ **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.
101
+
102
+ ```jsx
103
+ import { useAppearance, Pressable, Text } from "@colixsystems/widget-sdk";
104
+
105
+ export default function DarkSwitch() {
106
+ const { mode, canSwitch, setPreference } = useAppearance();
107
+ const next = mode === "dark" ? "light" : "dark";
108
+ return (
109
+ <Pressable disabled={!canSwitch} onPress={() => setPreference(next).catch(() => {})}>
110
+ <Text>{mode === "dark" ? "Use light" : "Use dark"}</Text>
111
+ </Pressable>
112
+ );
113
+ }
114
+ ```
115
+
116
+ ### What's new in 0.253.0
117
+
118
+ **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.
119
+
120
+ ### What's new in 0.252.0
121
+
122
+ **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:
123
+
124
+ - `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.
125
+ - `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.
126
+
127
+ `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.
88
128
 
89
129
  ### What's new in 0.251.0 (contract 1.205.0)
90
130
 
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 }).",
@@ -6335,7 +6368,13 @@ const CONTRACT = deepFreeze({
6335
6368
  // 1.205.0: additive (sc-11840) — a string property's `default` shows as the
6336
6369
  // field's editable text and clearing stores ""; `ui.placeholder` and
6337
6370
  // `ui.clearRestoresDefault` are typed.
6338
- version: "1.205.0",
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
+ // 1.207.0: additive (sc-12198) — `resolveTopBarTokens().tabAlignment` is
6376
+ // "left" | "center" | "right" again; "center" centres the tabs.
6377
+ version: "1.207.0",
6339
6378
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6340
6379
  hooks: HOOKS,
6341
6380
  primitives: PRIMITIVES,
@@ -6744,8 +6783,14 @@ function scaleSpacing(value, scale) {
6744
6783
  return Math.round(value * clampSpacingScale(scale));
6745
6784
  }
6746
6785
 
6786
+ // REQ-THEME-APPEARANCE (sc-12335): what an app opens on, and what its users
6787
+ // may choose — a palette or the device's. One list for useAppearance() and the
6788
+ // host resolvers in theme-appearance.js.
6789
+ const APPEARANCE_PREFERENCES = Object.freeze(["light", "dark", "system"]);
6790
+
6747
6791
  module.exports = {
6748
6792
  CONTRACT,
6793
+ APPEARANCE_PREFERENCES,
6749
6794
  clampSpacingScale,
6750
6795
  scaleSpacing,
6751
6796
  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 }).",
@@ -6331,7 +6364,13 @@ const CONTRACT = deepFreeze({
6331
6364
  // 1.205.0: additive (sc-11840) — a string property's `default` shows as the
6332
6365
  // field's editable text and clearing stores ""; `ui.placeholder` and
6333
6366
  // `ui.clearRestoresDefault` are typed.
6334
- version: "1.205.0",
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
+ // 1.207.0: additive (sc-12198) — `resolveTopBarTokens().tabAlignment` is
6372
+ // "left" | "center" | "right" again; "center" centres the tabs.
6373
+ version: "1.207.0",
6335
6374
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6336
6375
  hooks: HOOKS,
6337
6376
  primitives: PRIMITIVES,
@@ -6740,8 +6779,14 @@ function scaleSpacing(value, scale) {
6740
6779
  return Math.round(value * clampSpacingScale(scale));
6741
6780
  }
6742
6781
 
6782
+ // REQ-THEME-APPEARANCE (sc-12335): what an app opens on, and what its users
6783
+ // may choose — a palette or the device's. One list for useAppearance() and the
6784
+ // host resolvers in theme-appearance.js.
6785
+ const APPEARANCE_PREFERENCES = Object.freeze(["light", "dark", "system"]);
6786
+
6743
6787
  export {
6744
6788
  CONTRACT,
6789
+ APPEARANCE_PREFERENCES,
6745
6790
  clampSpacingScale,
6746
6791
  scaleSpacing,
6747
6792
  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
@@ -409,7 +409,7 @@ export interface TopBarTokens {
409
409
  * current tab so the page shows through it. */
410
410
  tabStyle: "underline" | "attached";
411
411
  /** Where the row's tabs sit across its width; `left` unless authored. */
412
- tabAlignment: "left" | "right";
412
+ tabAlignment: "left" | "center" | "right";
413
413
  /** The column the tabs sit in: the whole bar, or the page content's. The
414
414
  * bar's surface spans the full width either way. */
415
415
  tabRowWidth: "full" | "content";
@@ -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
@@ -845,6 +845,15 @@ export interface WidgetContext<TProps = unknown> {
845
845
  removePhoto?(): Promise<{ photoUrl: null }>;
846
846
  anonymous?: AnonymousAccountHost;
847
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
+ };
848
857
  workspace: {
849
858
  id: string;
850
859
  slug: string;
@@ -2018,6 +2027,26 @@ export function useI18n(): {
2018
2027
  setLocale(code: string | null): Promise<void>;
2019
2028
  };
2020
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
+
2021
2050
  /**
2022
2051
  * sc-3783 — translate USER-GENERATED content (record text, file names, API
2023
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,
@@ -566,11 +566,8 @@ function resolveTopStripeLayout(theme) {
566
566
  // reading the bar; so does the SITE header, which is shown instead of this
567
567
  // row rather than beside it.
568
568
  //
569
- // The notification bell is the one member of `slots` this does not reach.
570
- // It keeps its own resting colour on both hosts — web reads the authored
571
- // `topBar.textColor` and the export tints it not at all — and moving it
572
- // here would widen that difference rather than close it. Whoever closes it
573
- // should move both hosts at once.
569
+ // The notification bell is one of the row's icons and takes this tint on
570
+ // both hosts; the bells outside the row keep the bar's.
574
571
  tintColor: hexOr(stripe.textColor, barTokens.tintColor),
575
572
  titleColor: hexOr(stripe.textColor, barTokens.titleColor),
576
573
  order,
@@ -617,7 +614,7 @@ function resolveAppLogo(logo, darkSurfaceLogo, onDark) {
617
614
  return onDark && darkSurfaceLogo ? darkSurfaceLogo : logo;
618
615
  }
619
616
 
620
- const TAB_ALIGNMENTS = ["left", "right"];
617
+ const TAB_ALIGNMENTS = ["left", "center", "right"];
621
618
  const TAB_ROW_WIDTHS = ["full", "content"];
622
619
 
623
620
  function resolveTopBarTokens(theme) {
@@ -557,11 +557,8 @@ export function resolveTopStripeLayout(theme) {
557
557
  // reading the bar; so does the SITE header, which is shown instead of this
558
558
  // row rather than beside it.
559
559
  //
560
- // The notification bell is the one member of `slots` this does not reach.
561
- // It keeps its own resting colour on both hosts — web reads the authored
562
- // `topBar.textColor` and the export tints it not at all — and moving it
563
- // here would widen that difference rather than close it. Whoever closes it
564
- // should move both hosts at once.
560
+ // The notification bell is one of the row's icons and takes this tint on
561
+ // both hosts; the bells outside the row keep the bar's.
565
562
  tintColor: hexOr(stripe.textColor, barTokens.tintColor),
566
563
  titleColor: hexOr(stripe.textColor, barTokens.titleColor),
567
564
  order,
@@ -608,7 +605,7 @@ export function resolveAppLogo(logo, darkSurfaceLogo, onDark) {
608
605
  return onDark && darkSurfaceLogo ? darkSurfaceLogo : logo;
609
606
  }
610
607
 
611
- const TAB_ALIGNMENTS = ["left", "right"];
608
+ const TAB_ALIGNMENTS = ["left", "center", "right"];
612
609
  const TAB_ROW_WIDTHS = ["full", "content"];
613
610
 
614
611
  export function resolveTopBarTokens(theme) {
@@ -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.251.0",
3
+ "version": "0.256.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 }).",
@@ -6331,7 +6364,13 @@ const CONTRACT = deepFreeze({
6331
6364
  // 1.205.0: additive (sc-11840) — a string property's `default` shows as the
6332
6365
  // field's editable text and clearing stores ""; `ui.placeholder` and
6333
6366
  // `ui.clearRestoresDefault` are typed.
6334
- version: "1.205.0",
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
+ // 1.207.0: additive (sc-12198) — `resolveTopBarTokens().tabAlignment` is
6372
+ // "left" | "center" | "right" again; "center" centres the tabs.
6373
+ version: "1.207.0",
6335
6374
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6336
6375
  hooks: HOOKS,
6337
6376
  primitives: PRIMITIVES,
@@ -6740,8 +6779,14 @@ function scaleSpacing(value, scale) {
6740
6779
  return Math.round(value * clampSpacingScale(scale));
6741
6780
  }
6742
6781
 
6782
+ // REQ-THEME-APPEARANCE (sc-12335): what an app opens on, and what its users
6783
+ // may choose — a palette or the device's. One list for useAppearance() and the
6784
+ // host resolvers in theme-appearance.js.
6785
+ const APPEARANCE_PREFERENCES = Object.freeze(["light", "dark", "system"]);
6786
+
6743
6787
  export {
6744
6788
  CONTRACT,
6789
+ APPEARANCE_PREFERENCES,
6745
6790
  clampSpacingScale,
6746
6791
  scaleSpacing,
6747
6792
  normaliseActionTriggerTypes,