@colixsystems/widget-sdk 0.165.0 → 0.167.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
@@ -73,7 +73,31 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
73
73
 
74
74
  ## Status
75
75
 
76
- `v0.165.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**.
76
+ `v0.167.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**.
77
+
78
+ ### What's new in 0.167.0 (contract 1.129.0)
79
+
80
+ **The markdown grammar stops eating characters, and `useAgent` says to render a reply with `<RichText>` (sc-8891).** A model answers in markdown by default, so a chat surface that renders `message.content` in a plain `<Text>` shows an app user the raw markers. Pointing model text at `<RichText>` surfaced two grammar bugs that were corrupting **author** content too:
81
+
82
+ - **Emphasis now follows CommonMark's flanking rules.** `_` no longer opens or closes inside a word and neither delimiter opens before whitespace, so `created_at` keeps its underscore and `2 * 4` keeps its asterisk. Previously both were silently deleted — in a newsfeed post as well as an AI reply.
83
+ - **`<RichText>` gains `stripLegacyHtml`** (default `true`, unchanged for stored rows). The legacy-HTML strip removes every `<…>` as soon as one tag-shaped token appears, which ate `Promise<void>` and `<name@example.com>`. Pass **`false`** for text that was never an HTML row.
84
+ - **Fenced ``` blocks render verbatim** in a monospace block, newlines and indentation intact. They used to collapse into one inline span with stray backticks.
85
+ - A line that cannot hold a link skips the link pass, so a bracket-heavy string no longer costs seconds of main-thread time.
86
+
87
+ Render an **assistant** message with `<RichText value={m.content} stripLegacyHtml={false} />`. Keep the **user's own** message a `<Text>` — someone who types `*maybe*` means asterisks.
88
+
89
+ ### What's new in 0.166.0 (contract 1.128.0)
90
+
91
+ **A bound colour can be translucent (sc-8893).** Binding and transparency were mutually exclusive: the Studio hid the opacity control the moment a colour field bound to a brand token, so an author who wanted "our brand colour at 20%" had to abandon the binding and freeze today's hex — which then stopped following a rebrand, the very thing binding exists to prevent.
92
+
93
+ A binding may now carry an opacity: `"theme:colors.primary/20"` is the primary brand colour at 20%.
94
+
95
+ - **`parseThemeColorTokenRef(value)` / `formatThemeColorTokenRef(path, percent)`** publish the grammar beside the existing `isThemeColorTokenRef` predicate. The BARE form inherits whatever opacity the token itself carries — a workspace may author a translucent brand colour — so `/100` is a real value, not a redundant spelling: it is how "opaque, whatever the brand does" is said. One opacity has one spelling otherwise (no leading zeros, nothing above 100).
96
+ - **`withHexAlpha(hex, percent)` / `hexAlphaPercent(hex)`** are the percent ↔ alpha-byte conversion the Studio's opacity control and both hosts' resolver share, so the 20% a panel reads back is the 20% a render paints.
97
+ - **An authored opacity replaces the token's.** With none authored the token is handed back verbatim, including a token that is itself translucent — reading the bare form as 100 would quietly paint a see-through brand colour solid.
98
+ - **A token resolving to a non-hex colour stays opaque** — alpha cannot be applied without rewriting the author's notation — and the Studio disables the opacity control for that case rather than lying about it.
99
+ - **Nothing changes for a widget author.** The host still hands your code an ordinary colour string; it is simply `#RRGGBBAA` when an opacity was authored.
100
+ - **Fully additive.** Every stored binding is unsuffixed, parses as before, and renders byte-identically.
77
101
 
78
102
  ### What's new in 0.165.0 (contract 1.127.0 — unchanged)
79
103
 
package/dist/contract.cjs CHANGED
@@ -192,20 +192,64 @@ const THEME_COLOR_TOKENS = Object.freeze([
192
192
  Object.freeze({ path: "colors.secondary", label: "Secondary" }),
193
193
  ]);
194
194
 
195
- // sc-8326 — the `theme:<path>` value grammar, as a PREDICATE over a STORED
196
- // value and declared beside the allowlist it reads. Three readers have to agree
197
- // on what a binding IS: the Studio's colour picker writes one, both hosts
198
- // resolve one, and Mason's plan validator must accept one as a gradient stop.
195
+ // sc-8326 — the `theme:<path>` value grammar, as a PARSE over a STORED value
196
+ // and declared beside the allowlist it reads. Three readers have to agree on
197
+ // what a binding IS: the Studio's colour picker writes one, both hosts resolve
198
+ // one, and Mason's plan validator must accept one as a gradient stop.
199
199
  // One definition, so they cannot drift (CLAUDE.md §3).
200
200
  const THEME_TOKEN_REF_PREFIX = "theme:";
201
201
 
202
+ // sc-8893 — a binding may carry an OPACITY, `theme:<path>/<percent>`. Without
203
+ // it an author had to choose between a colour that follows the workspace brand
204
+ // and one they could make translucent; "our brand colour at 20%" was only
205
+ // expressible by freezing today's hex, which then stopped following a rebrand.
206
+ // `/` cannot occur in a token path, so an unsuffixed binding parses exactly as
207
+ // it always did and no stored value changes meaning.
208
+ const THEME_TOKEN_ALPHA_SEPARATOR = "/";
209
+ const ALPHA_OPAQUE = 100;
210
+
211
+ /**
212
+ * Parse a stored binding into `{ path, alpha }`, or `null` when the value is
213
+ * not one.
214
+ *
215
+ * `alpha` is `null` for an UNSUFFIXED binding, which inherits whatever opacity
216
+ * the token itself carries — a brand colour may be authored translucent, and
217
+ * reading the bare form as 100 would silently paint it solid. A suffix is the
218
+ * author's own opacity and REPLACES the token's, so `/100` is a real value: it
219
+ * is how "opaque, whatever the brand does" is expressed.
220
+ */
221
+ function parseThemeColorTokenRef(value) {
222
+ if (typeof value !== "string") return null;
223
+ if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return null;
224
+ const body = value.slice(THEME_TOKEN_REF_PREFIX.length);
225
+ const cut = body.indexOf(THEME_TOKEN_ALPHA_SEPARATOR);
226
+ const path = cut === -1 ? body : body.slice(0, cut);
227
+ if (!THEME_COLOR_TOKENS.some((token) => token.path === path)) return null;
228
+ if (cut === -1) return { path, alpha: null };
229
+ const percent = body.slice(cut + 1);
230
+ // One spelling per opacity: no leading zeros, nothing above 100. A malformed
231
+ // suffix reads as "not a binding", so the field falls back to its default
232
+ // rather than painting a half-parsed colour.
233
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(percent)) return null;
234
+ const alpha = Number(percent);
235
+ return alpha > ALPHA_OPAQUE ? null : { path, alpha };
236
+ }
237
+
202
238
  function isThemeColorTokenRef(value) {
203
- if (typeof value !== "string") return false;
204
- if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return false;
205
- const path = value.slice(THEME_TOKEN_REF_PREFIX.length);
206
- return THEME_COLOR_TOKENS.some((token) => token.path === path);
239
+ return parseThemeColorTokenRef(value) !== null;
207
240
  }
208
241
 
242
+ /**
243
+ * Spell one binding. `percent` of `null`/`undefined` writes the bare form — the
244
+ * binding that defers to the token's own opacity. The path is the caller's to
245
+ * take from `CONTRACT.themeColorTokens`; an unlisted one spells a string this
246
+ * module's own parse rejects.
247
+ */
248
+ function formatThemeColorTokenRef(path, percent) {
249
+ const ref = `${THEME_TOKEN_REF_PREFIX}${path}`;
250
+ if (percent === null || percent === undefined) return ref;
251
+ return `${ref}${THEME_TOKEN_ALPHA_SEPARATOR}${clampAlphaPercent(percent)}`;
252
+ }
209
253
 
210
254
  // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
211
255
  // single lever: remapping `primaryColor` to recolour "the buttons" recolours
@@ -1414,6 +1458,12 @@ const HOOKS = [
1414
1458
  "no error at all. Call cancel() to stop waiting -- it ends the WAIT, " +
1415
1459
  "not the turn. NEVER surface a " +
1416
1460
  "raw error or the workspace's billing state to an app user. " +
1461
+ "RENDER AN ASSISTANT REPLY WITH <RichText value={msg.content} />, " +
1462
+ "not <Text>: a model answers in MARKDOWN by default — **bold**, " +
1463
+ "`code`, # headings, - bullets, 1. numbered steps, tables — and a " +
1464
+ "plain <Text> shows the raw markers to the app user, so a numbered " +
1465
+ "how-to arrives as one unreadable line. The USER's own message " +
1466
+ "stays <Text>: someone who types *maybe* means asterisks. " +
1417
1467
  "Reads ctx.agents.",
1418
1468
  returnShape: {
1419
1469
  send: "(text) => Promise<{ id, role, content, created_at } | null>",
@@ -2221,7 +2271,7 @@ const PRIMITIVES = [
2221
2271
  {
2222
2272
  name: "RichText",
2223
2273
  description:
2224
- 'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, `[label](target)` links, `| a | b |` pipe tables (a header row, a `| --- | --- |` divider, then body rows; `:--` / `--:` / `:-:` in a divider cell sets that column\'s alignment), and one-line `![alt](src "large")` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>`/`<table>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. A link is pressed through the host\'s `navigation.openLink`, which is what decides page / external / refuse — so a link target is just a string here: an absolute URL leaves the app, a bare page id or slug navigates inside it, and anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself and never route one through `Linking.openURL`, which performs anything. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `followLinks` (default true; pass false to render links styled but inert, which is what an editing preview wants so a tap cannot discard the draft), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them. Table cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.',
2274
+ 'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, `[label](target)` links, `| a | b |` pipe tables (a header row, a `| --- | --- |` divider, then body rows; `:--` / `--:` / `:-:` in a divider cell sets that column\'s alignment), and one-line `![alt](src "large")` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>`/`<table>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. A link is pressed through the host\'s `navigation.openLink`, which is what decides page / external / refuse — so a link target is just a string here: an absolute URL leaves the app, a bare page id or slug navigates inside it, and anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself and never route one through `Linking.openURL`, which performs anything. Fenced ```` ``` ```` blocks render verbatim in a monospace block, so a code sample keeps its newlines and indentation. Props: `value` (markdown string), `stripLegacyHtml` (default true — normalises a STORED row that still holds HTML; pass **false** for text that was never HTML, such as an AI reply, because the strip removes every `<…>` once any tag-shaped token appears and would eat `Promise<void>` or `<name@example.com>`), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `followLinks` (default true; pass false to render links styled but inert, which is what an editing preview wants so a tap cannot discard the draft), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them. Table cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.',
2225
2275
  rnComponent: null,
2226
2276
  docsUrl: null,
2227
2277
  },
@@ -4483,7 +4533,27 @@ const CONTRACT = deepFreeze({
4483
4533
  // and form field so every widget draws them from one implementation, plus
4484
4534
  // a `height` token on the `muted` scope. Additive: the built-ins render
4485
4535
  // exactly as before.
4486
- version: "1.127.0",
4536
+ // 1.128.0: additive (sc-8893) — a `theme:` colour binding may carry an
4537
+ // OPACITY, `theme:<path>/<percent>`. Binding and transparency used to be
4538
+ // mutually exclusive: the Studio hid the opacity control the moment a
4539
+ // field bound, so "our brand colour at 20%" forced the author back to a
4540
+ // frozen hex, which then stopped following a rebrand — the exact thing
4541
+ // binding exists to prevent. `parseThemeColorTokenRef` /
4542
+ // `formatThemeColorTokenRef` publish the grammar and
4543
+ // `withHexAlpha` / `hexAlphaPercent` the percent-to-byte conversion the
4544
+ // Studio's control and both hosts' resolver share, so 20% authored is 20%
4545
+ // painted. The BARE form inherits the token's own opacity (a brand colour
4546
+ // may be authored translucent), which makes `/100` a real value rather
4547
+ // than a redundant spelling: it is how "opaque, whatever the brand does"
4548
+ // is said. Additive: an unsuffixed binding parses and resolves exactly as
4549
+ // before, so every stored value is untouched.
4550
+ // 1.129.0: additive (sc-8891) — `<RichText>` gains `stripLegacyHtml`
4551
+ // (default true) and renders fenced ``` blocks verbatim. The legacy-HTML
4552
+ // strip exists for stored author rows; applied to text that was never HTML
4553
+ // it removed every `<...>`, so an AI reply lost `Promise<void>`. The
4554
+ // emphasis grammar now follows CommonMark flanking rules, so `created_at`
4555
+ // keeps its underscore and `2 * 4` keeps its asterisk.
4556
+ version: "1.129.0",
4487
4557
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4488
4558
  hooks: HOOKS,
4489
4559
  primitives: PRIMITIVES,
@@ -4560,6 +4630,59 @@ function isHexColor(value) {
4560
4630
  return typeof value === "string" && HEX_RE.test(value);
4561
4631
  }
4562
4632
 
4633
+ // sc-8893 — opacity as an authored PERCENTAGE, converted here and nowhere else.
4634
+ // The Studio's opacity control and the host's binding resolver both spell a
4635
+ // colour's alpha through these, so the 20% a panel reads back is the 20% a
4636
+ // render paints. 255/100 > 1, so percent → byte is injective and the inverse
4637
+ // below is exact.
4638
+ function clampAlphaPercent(value) {
4639
+ const n = Math.round(Number(value));
4640
+ // Unparseable reads as fully opaque: "cannot tell" must never turn a surface
4641
+ // see-through on its own.
4642
+ if (!Number.isFinite(n)) return ALPHA_OPAQUE;
4643
+ return Math.min(ALPHA_OPAQUE, Math.max(0, n));
4644
+ }
4645
+
4646
+ function expandShortHex(hex) {
4647
+ const body = hex.slice(1);
4648
+ if (body.length > 4) return hex;
4649
+ return `#${[...body].map((c) => c + c).join("")}`;
4650
+ }
4651
+
4652
+ /**
4653
+ * Apply `percent` opacity to a hex colour, returning `#RRGGBBAA` — or a plain
4654
+ * `#RRGGBB` at full opacity, so an opaque colour is never rewritten with a
4655
+ * redundant `ff`. A non-hex colour cannot carry alpha without being rewritten
4656
+ * into another notation, so it is returned untouched rather than invented.
4657
+ *
4658
+ * "Hex" is `isHexColor`'s grammar — 3, 6 or 8 digits. The 4-digit `#RGBA` form
4659
+ * is deliberately outside it (a gradient stop may not smuggle one past the
4660
+ * injection guard), so it is returned untouched here too.
4661
+ */
4662
+ function withHexAlpha(hex, percent) {
4663
+ if (!isHexColor(hex)) return hex;
4664
+ const base = expandShortHex(hex).slice(0, 7);
4665
+ const alpha = clampAlphaPercent(percent);
4666
+ if (alpha === ALPHA_OPAQUE) return base;
4667
+ return `${base}${Math.round((alpha * 255) / 100)
4668
+ .toString(16)
4669
+ .padStart(2, "0")}`;
4670
+ }
4671
+
4672
+ /**
4673
+ * Read a hex colour's opacity back as a percentage. No alpha pair is 100.
4674
+ *
4675
+ * The percentage is lossy in this direction — 255 alpha values map onto 101
4676
+ * percentages — so a near-opaque `fe` reads back as 100. Only a value this
4677
+ * function produced round-trips exactly.
4678
+ */
4679
+ function hexAlphaPercent(hex) {
4680
+ if (!isHexColor(hex)) return ALPHA_OPAQUE;
4681
+ const full = expandShortHex(hex);
4682
+ if (full.length !== 9) return ALPHA_OPAQUE;
4683
+ return Math.round((parseInt(full.slice(7), 16) * 100) / 255);
4684
+ }
4685
+
4563
4686
  /**
4564
4687
  * The R/G/B bytes, with any alpha pair IGNORED.
4565
4688
  *
@@ -4788,7 +4911,11 @@ module.exports = {
4788
4911
  gradientAngleToVector,
4789
4912
  normaliseComponentGradient,
4790
4913
  isThemeColorTokenRef,
4914
+ parseThemeColorTokenRef,
4915
+ formatThemeColorTokenRef,
4791
4916
  THEME_TOKEN_REF_PREFIX,
4917
+ withHexAlpha,
4918
+ hexAlphaPercent,
4792
4919
  formatMoneyIn,
4793
4920
  normaliseCurrencyCode,
4794
4921
  sameWidgetRouteValue,
package/dist/contract.js CHANGED
@@ -192,20 +192,64 @@ const THEME_COLOR_TOKENS = Object.freeze([
192
192
  Object.freeze({ path: "colors.secondary", label: "Secondary" }),
193
193
  ]);
194
194
 
195
- // sc-8326 — the `theme:<path>` value grammar, as a PREDICATE over a STORED
196
- // value and declared beside the allowlist it reads. Three readers have to agree
197
- // on what a binding IS: the Studio's colour picker writes one, both hosts
198
- // resolve one, and Mason's plan validator must accept one as a gradient stop.
195
+ // sc-8326 — the `theme:<path>` value grammar, as a PARSE over a STORED value
196
+ // and declared beside the allowlist it reads. Three readers have to agree on
197
+ // what a binding IS: the Studio's colour picker writes one, both hosts resolve
198
+ // one, and Mason's plan validator must accept one as a gradient stop.
199
199
  // One definition, so they cannot drift (CLAUDE.md §3).
200
200
  const THEME_TOKEN_REF_PREFIX = "theme:";
201
201
 
202
+ // sc-8893 — a binding may carry an OPACITY, `theme:<path>/<percent>`. Without
203
+ // it an author had to choose between a colour that follows the workspace brand
204
+ // and one they could make translucent; "our brand colour at 20%" was only
205
+ // expressible by freezing today's hex, which then stopped following a rebrand.
206
+ // `/` cannot occur in a token path, so an unsuffixed binding parses exactly as
207
+ // it always did and no stored value changes meaning.
208
+ const THEME_TOKEN_ALPHA_SEPARATOR = "/";
209
+ const ALPHA_OPAQUE = 100;
210
+
211
+ /**
212
+ * Parse a stored binding into `{ path, alpha }`, or `null` when the value is
213
+ * not one.
214
+ *
215
+ * `alpha` is `null` for an UNSUFFIXED binding, which inherits whatever opacity
216
+ * the token itself carries — a brand colour may be authored translucent, and
217
+ * reading the bare form as 100 would silently paint it solid. A suffix is the
218
+ * author's own opacity and REPLACES the token's, so `/100` is a real value: it
219
+ * is how "opaque, whatever the brand does" is expressed.
220
+ */
221
+ function parseThemeColorTokenRef(value) {
222
+ if (typeof value !== "string") return null;
223
+ if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return null;
224
+ const body = value.slice(THEME_TOKEN_REF_PREFIX.length);
225
+ const cut = body.indexOf(THEME_TOKEN_ALPHA_SEPARATOR);
226
+ const path = cut === -1 ? body : body.slice(0, cut);
227
+ if (!THEME_COLOR_TOKENS.some((token) => token.path === path)) return null;
228
+ if (cut === -1) return { path, alpha: null };
229
+ const percent = body.slice(cut + 1);
230
+ // One spelling per opacity: no leading zeros, nothing above 100. A malformed
231
+ // suffix reads as "not a binding", so the field falls back to its default
232
+ // rather than painting a half-parsed colour.
233
+ if (!/^(?:0|[1-9]\d{0,2})$/.test(percent)) return null;
234
+ const alpha = Number(percent);
235
+ return alpha > ALPHA_OPAQUE ? null : { path, alpha };
236
+ }
237
+
202
238
  function isThemeColorTokenRef(value) {
203
- if (typeof value !== "string") return false;
204
- if (!value.startsWith(THEME_TOKEN_REF_PREFIX)) return false;
205
- const path = value.slice(THEME_TOKEN_REF_PREFIX.length);
206
- return THEME_COLOR_TOKENS.some((token) => token.path === path);
239
+ return parseThemeColorTokenRef(value) !== null;
207
240
  }
208
241
 
242
+ /**
243
+ * Spell one binding. `percent` of `null`/`undefined` writes the bare form — the
244
+ * binding that defers to the token's own opacity. The path is the caller's to
245
+ * take from `CONTRACT.themeColorTokens`; an unlisted one spells a string this
246
+ * module's own parse rejects.
247
+ */
248
+ function formatThemeColorTokenRef(path, percent) {
249
+ const ref = `${THEME_TOKEN_REF_PREFIX}${path}`;
250
+ if (percent === null || percent === undefined) return ref;
251
+ return `${ref}${THEME_TOKEN_ALPHA_SEPARATOR}${clampAlphaPercent(percent)}`;
252
+ }
209
253
 
210
254
  // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
211
255
  // single lever: remapping `primaryColor` to recolour "the buttons" recolours
@@ -1414,6 +1458,12 @@ const HOOKS = [
1414
1458
  "no error at all. Call cancel() to stop waiting -- it ends the WAIT, " +
1415
1459
  "not the turn. NEVER surface a " +
1416
1460
  "raw error or the workspace's billing state to an app user. " +
1461
+ "RENDER AN ASSISTANT REPLY WITH <RichText value={msg.content} />, " +
1462
+ "not <Text>: a model answers in MARKDOWN by default — **bold**, " +
1463
+ "`code`, # headings, - bullets, 1. numbered steps, tables — and a " +
1464
+ "plain <Text> shows the raw markers to the app user, so a numbered " +
1465
+ "how-to arrives as one unreadable line. The USER's own message " +
1466
+ "stays <Text>: someone who types *maybe* means asterisks. " +
1417
1467
  "Reads ctx.agents.",
1418
1468
  returnShape: {
1419
1469
  send: "(text) => Promise<{ id, role, content, created_at } | null>",
@@ -2221,7 +2271,7 @@ const PRIMITIVES = [
2221
2271
  {
2222
2272
  name: "RichText",
2223
2273
  description:
2224
- 'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, `[label](target)` links, `| a | b |` pipe tables (a header row, a `| --- | --- |` divider, then body rows; `:--` / `--:` / `:-:` in a divider cell sets that column\'s alignment), and one-line `![alt](src "large")` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>`/`<table>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. A link is pressed through the host\'s `navigation.openLink`, which is what decides page / external / refuse — so a link target is just a string here: an absolute URL leaves the app, a bare page id or slug navigates inside it, and anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself and never route one through `Linking.openURL`, which performs anything. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `followLinks` (default true; pass false to render links styled but inert, which is what an editing preview wants so a tap cannot discard the draft), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them. Table cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.',
2274
+ 'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, `[label](target)` links, `| a | b |` pipe tables (a header row, a `| --- | --- |` divider, then body rows; `:--` / `--:` / `:-:` in a divider cell sets that column\'s alignment), and one-line `![alt](src "large")` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>`/`<table>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. A link is pressed through the host\'s `navigation.openLink`, which is what decides page / external / refuse — so a link target is just a string here: an absolute URL leaves the app, a bare page id or slug navigates inside it, and anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself and never route one through `Linking.openURL`, which performs anything. Fenced ```` ``` ```` blocks render verbatim in a monospace block, so a code sample keeps its newlines and indentation. Props: `value` (markdown string), `stripLegacyHtml` (default true — normalises a STORED row that still holds HTML; pass **false** for text that was never HTML, such as an AI reply, because the strip removes every `<…>` once any tag-shaped token appears and would eat `Promise<void>` or `<name@example.com>`), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `followLinks` (default true; pass false to render links styled but inert, which is what an editing preview wants so a tap cannot discard the draft), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them. Table cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.',
2225
2275
  rnComponent: null,
2226
2276
  docsUrl: null,
2227
2277
  },
@@ -4479,7 +4529,27 @@ const CONTRACT = deepFreeze({
4479
4529
  // and form field so every widget draws them from one implementation, plus
4480
4530
  // a `height` token on the `muted` scope. Additive: the built-ins render
4481
4531
  // exactly as before.
4482
- version: "1.127.0",
4532
+ // 1.128.0: additive (sc-8893) — a `theme:` colour binding may carry an
4533
+ // OPACITY, `theme:<path>/<percent>`. Binding and transparency used to be
4534
+ // mutually exclusive: the Studio hid the opacity control the moment a
4535
+ // field bound, so "our brand colour at 20%" forced the author back to a
4536
+ // frozen hex, which then stopped following a rebrand — the exact thing
4537
+ // binding exists to prevent. `parseThemeColorTokenRef` /
4538
+ // `formatThemeColorTokenRef` publish the grammar and
4539
+ // `withHexAlpha` / `hexAlphaPercent` the percent-to-byte conversion the
4540
+ // Studio's control and both hosts' resolver share, so 20% authored is 20%
4541
+ // painted. The BARE form inherits the token's own opacity (a brand colour
4542
+ // may be authored translucent), which makes `/100` a real value rather
4543
+ // than a redundant spelling: it is how "opaque, whatever the brand does"
4544
+ // is said. Additive: an unsuffixed binding parses and resolves exactly as
4545
+ // before, so every stored value is untouched.
4546
+ // 1.129.0: additive (sc-8891) — `<RichText>` gains `stripLegacyHtml`
4547
+ // (default true) and renders fenced ``` blocks verbatim. The legacy-HTML
4548
+ // strip exists for stored author rows; applied to text that was never HTML
4549
+ // it removed every `<...>`, so an AI reply lost `Promise<void>`. The
4550
+ // emphasis grammar now follows CommonMark flanking rules, so `created_at`
4551
+ // keeps its underscore and `2 * 4` keeps its asterisk.
4552
+ version: "1.129.0",
4483
4553
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4484
4554
  hooks: HOOKS,
4485
4555
  primitives: PRIMITIVES,
@@ -4556,6 +4626,59 @@ function isHexColor(value) {
4556
4626
  return typeof value === "string" && HEX_RE.test(value);
4557
4627
  }
4558
4628
 
4629
+ // sc-8893 — opacity as an authored PERCENTAGE, converted here and nowhere else.
4630
+ // The Studio's opacity control and the host's binding resolver both spell a
4631
+ // colour's alpha through these, so the 20% a panel reads back is the 20% a
4632
+ // render paints. 255/100 > 1, so percent → byte is injective and the inverse
4633
+ // below is exact.
4634
+ function clampAlphaPercent(value) {
4635
+ const n = Math.round(Number(value));
4636
+ // Unparseable reads as fully opaque: "cannot tell" must never turn a surface
4637
+ // see-through on its own.
4638
+ if (!Number.isFinite(n)) return ALPHA_OPAQUE;
4639
+ return Math.min(ALPHA_OPAQUE, Math.max(0, n));
4640
+ }
4641
+
4642
+ function expandShortHex(hex) {
4643
+ const body = hex.slice(1);
4644
+ if (body.length > 4) return hex;
4645
+ return `#${[...body].map((c) => c + c).join("")}`;
4646
+ }
4647
+
4648
+ /**
4649
+ * Apply `percent` opacity to a hex colour, returning `#RRGGBBAA` — or a plain
4650
+ * `#RRGGBB` at full opacity, so an opaque colour is never rewritten with a
4651
+ * redundant `ff`. A non-hex colour cannot carry alpha without being rewritten
4652
+ * into another notation, so it is returned untouched rather than invented.
4653
+ *
4654
+ * "Hex" is `isHexColor`'s grammar — 3, 6 or 8 digits. The 4-digit `#RGBA` form
4655
+ * is deliberately outside it (a gradient stop may not smuggle one past the
4656
+ * injection guard), so it is returned untouched here too.
4657
+ */
4658
+ function withHexAlpha(hex, percent) {
4659
+ if (!isHexColor(hex)) return hex;
4660
+ const base = expandShortHex(hex).slice(0, 7);
4661
+ const alpha = clampAlphaPercent(percent);
4662
+ if (alpha === ALPHA_OPAQUE) return base;
4663
+ return `${base}${Math.round((alpha * 255) / 100)
4664
+ .toString(16)
4665
+ .padStart(2, "0")}`;
4666
+ }
4667
+
4668
+ /**
4669
+ * Read a hex colour's opacity back as a percentage. No alpha pair is 100.
4670
+ *
4671
+ * The percentage is lossy in this direction — 255 alpha values map onto 101
4672
+ * percentages — so a near-opaque `fe` reads back as 100. Only a value this
4673
+ * function produced round-trips exactly.
4674
+ */
4675
+ function hexAlphaPercent(hex) {
4676
+ if (!isHexColor(hex)) return ALPHA_OPAQUE;
4677
+ const full = expandShortHex(hex);
4678
+ if (full.length !== 9) return ALPHA_OPAQUE;
4679
+ return Math.round((parseInt(full.slice(7), 16) * 100) / 255);
4680
+ }
4681
+
4559
4682
  /**
4560
4683
  * The R/G/B bytes, with any alpha pair IGNORED.
4561
4684
  *
@@ -4784,7 +4907,11 @@ export {
4784
4907
  gradientAngleToVector,
4785
4908
  normaliseComponentGradient,
4786
4909
  isThemeColorTokenRef,
4910
+ parseThemeColorTokenRef,
4911
+ formatThemeColorTokenRef,
4787
4912
  THEME_TOKEN_REF_PREFIX,
4913
+ withHexAlpha,
4914
+ hexAlphaPercent,
4788
4915
  formatMoneyIn,
4789
4916
  normaliseCurrencyCode,
4790
4917
  sameWidgetRouteValue,
package/dist/host.d.ts CHANGED
@@ -434,7 +434,8 @@ export function isThemeTokenRef(value: unknown): boolean;
434
434
  /**
435
435
  * Resolve one `theme:<path>` binding against a resolved widget theme. Returns
436
436
  * `undefined` for a non-binding, a token outside `CONTRACT.themeColorTokens`,
437
- * or a path that does not resolve to a colour.
437
+ * or a path that does not resolve to a colour. A binding carrying an opacity
438
+ * (`theme:<path>/<percent>`, sc-8893) resolves to `#RRGGBBAA`.
438
439
  */
439
440
  export function resolveThemeTokenRef(
440
441
  value: unknown,
package/dist/index.d.ts CHANGED
@@ -1221,6 +1221,7 @@ export interface AgentMessage {
1221
1221
  conversation_id: string;
1222
1222
  /** The system prompt is NOT part of the transcript — it lives on the agent. */
1223
1223
  role: "user" | "assistant";
1224
+ /** Markdown — an assistant answers in it. Render with `<RichText>`. */
1224
1225
  content: string;
1225
1226
  /**
1226
1227
  * sc-8332 — what the agent DID on this turn, for the person to see. Present
@@ -2767,17 +2768,35 @@ export type MarkdownBlock =
2767
2768
  | { type: "paragraph"; spans: MarkdownSpan[] }
2768
2769
  | { type: "bullet"; marker: string; spans: MarkdownSpan[] }
2769
2770
  | { type: "ordered"; marker: string; spans: MarkdownSpan[] }
2770
- | { type: "image"; src: string; alt: string; size: MarkdownImageSize };
2771
+ | { type: "image"; src: string; alt: string; size: MarkdownImageSize }
2772
+ /** A fenced ``` block. `text` is verbatim, newlines and indentation intact. */
2773
+ | { type: "code"; text: string; lang: string };
2771
2774
 
2772
2775
  export type MarkdownImageSize = "small" | "medium" | "large" | "full";
2773
2776
 
2774
2777
  export const MARKDOWN_IMAGE_SIZES: readonly MarkdownImageSize[];
2775
2778
 
2776
2779
  /** Parses the markdown subset into blocks. Non-string input yields `[]`. */
2777
- export function parseMarkdown(text: unknown): MarkdownBlock[];
2780
+ export function parseMarkdown(
2781
+ text: unknown,
2782
+ options?: MarkdownParseOptions,
2783
+ ): MarkdownBlock[];
2784
+
2785
+ export interface MarkdownParseOptions {
2786
+ /**
2787
+ * Normalise a STORED row that still holds HTML (default true). Pass false
2788
+ * for text that was never HTML — an AI reply, say — because the strip
2789
+ * removes every `<…>` once any tag-shaped token appears, which would eat
2790
+ * `Promise<void>` and `<name@example.com>`.
2791
+ */
2792
+ stripLegacyHtml?: boolean;
2793
+ }
2778
2794
 
2779
2795
  /** Marker-free projection of `text`, for search, previews and a11y labels. */
2780
- export function markdownToPlainText(text: unknown): string;
2796
+ export function markdownToPlainText(
2797
+ text: unknown,
2798
+ options?: MarkdownParseOptions,
2799
+ ): string;
2781
2800
 
2782
2801
  /** Reads one line as an image block, or `null` when it is not one. */
2783
2802
  export function parseMarkdownImage(
@@ -2824,6 +2843,11 @@ export function normaliseMarkdownImageSize(size: unknown): MarkdownImageSize;
2824
2843
  export const RichText: (props: {
2825
2844
  /** Markdown text. Legacy HTML is stripped to plain text, never rendered. */
2826
2845
  value?: string;
2846
+ /**
2847
+ * See `MarkdownParseOptions.stripLegacyHtml` (default true). Pass false for
2848
+ * text that was never an HTML row, such as an AI agent's reply.
2849
+ */
2850
+ stripLegacyHtml?: boolean;
2827
2851
  /**
2828
2852
  * Resolves an image block to a node. Supply it when images are filestore
2829
2853
  * ids — without it only absolute http(s) URLs render.
@@ -2942,6 +2966,40 @@ export const THEME_TOKEN_REF_PREFIX: string;
2942
2966
  */
2943
2967
  export function isThemeColorTokenRef(value: unknown): boolean;
2944
2968
 
2969
+ export interface ThemeColorTokenRef {
2970
+ path: string;
2971
+ /** null for a bare binding, which inherits the token's own opacity. */
2972
+ alpha: number | null;
2973
+ }
2974
+
2975
+ /**
2976
+ * sc-8893 — parse a binding into its token path and opacity percentage, or null
2977
+ * when the value is not a binding. `theme:<path>` inherits whatever opacity the
2978
+ * token carries; `theme:<path>/<percent>` replaces it with the author's.
2979
+ */
2980
+ export function parseThemeColorTokenRef(
2981
+ value: unknown,
2982
+ ): ThemeColorTokenRef | null;
2983
+
2984
+ /**
2985
+ * sc-8893 — spell one binding. A `percent` of null/undefined writes the bare
2986
+ * form, which defers to the token's own opacity.
2987
+ */
2988
+ export function formatThemeColorTokenRef(
2989
+ path: string,
2990
+ percent?: unknown,
2991
+ ): string;
2992
+
2993
+ /**
2994
+ * sc-8893 — apply an opacity percentage to a hex colour, returning `#RRGGBBAA`
2995
+ * (or plain `#RRGGBB` at 100). A non-hex colour is returned untouched. Shared
2996
+ * so the Studio's opacity control and the hosts' resolver convert identically.
2997
+ */
2998
+ export function withHexAlpha(hex: string, percent: unknown): string;
2999
+
3000
+ /** sc-8893 — read a hex colour's opacity back as a percentage (no alpha = 100). */
3001
+ export function hexAlphaPercent(hex: string): number;
3002
+
2945
3003
  // Linter
2946
3004
  export interface LintFinding {
2947
3005
  rule: string;
package/dist/index.js CHANGED
@@ -164,7 +164,11 @@ export {
164
164
  gradientAngleToVector,
165
165
  normaliseComponentGradient,
166
166
  isThemeColorTokenRef,
167
+ parseThemeColorTokenRef,
168
+ formatThemeColorTokenRef,
167
169
  THEME_TOKEN_REF_PREFIX,
170
+ withHexAlpha,
171
+ hexAlphaPercent,
168
172
  clampSpacingScale,
169
173
  scaleSpacing,
170
174
  } from "./contract.js";
@@ -157,7 +157,11 @@ export {
157
157
  gradientAngleToVector,
158
158
  normaliseComponentGradient,
159
159
  isThemeColorTokenRef,
160
+ parseThemeColorTokenRef,
161
+ formatThemeColorTokenRef,
160
162
  THEME_TOKEN_REF_PREFIX,
163
+ withHexAlpha,
164
+ hexAlphaPercent,
161
165
  clampSpacingScale,
162
166
  scaleSpacing,
163
167
  } from "./contract.js";
package/dist/markdown.js CHANGED
@@ -155,14 +155,53 @@ function linkSpans(rawLabel, href) {
155
155
  return spans.some((span) => span.text) ? spans : [{ text: href, href }];
156
156
  }
157
157
 
158
+ // CommonMark's flanking rules. Without them `created_at` loses its underscore
159
+ // and `2 * 4` loses its asterisk -- silent character deletion in ordinary
160
+ // prose, which an agent's reply has no author around to notice and correct.
161
+ // ASCII punctuation rather than \p{P}: Hermes is the native engine and a
162
+ // word char here is anything that is neither space nor punctuation, so an
163
+ // accented letter still counts as intraword.
164
+ const ASCII_PUNCT_RE = /[!-/:-@[-`{-~]/;
165
+
166
+ function isWordChar(char) {
167
+ return char !== "" && !/\s/.test(char) && !ASCII_PUNCT_RE.test(char);
168
+ }
169
+
170
+ // `delim` may open only when the run is not followed by whitespace, and `_`
171
+ // additionally may not open or close inside a word.
172
+ function emphasisFlanks(delim, content, source, openAt, closeEnd) {
173
+ if (content === "") return false;
174
+ if (/\s/.test(content.charAt(0))) return false;
175
+ if (/\s/.test(content.charAt(content.length - 1))) return false;
176
+ if (delim !== "_") return true;
177
+ const before = openAt > 0 ? source.charAt(openAt - 1) : "";
178
+ const after = source.charAt(closeEnd);
179
+ return !isWordChar(before) && !isWordChar(after);
180
+ }
181
+
182
+ // Alternation order matters: `**` must be tried before `*`, or the bold
183
+ // marker is consumed as two italics. Group numbers are shared by both so the
184
+ // branch handling below reads the same match indices either way.
185
+ const LINK_PATTERN =
186
+ /(!?)\[((?:[^\]\\]|\\.)*)\]\(\s*(\S+?)\s*\)|(\*\*)(.+?)\4|(`)([^`]+?)\6|([*_])(.+?)\8/g;
187
+ // Same groups so the branch handling reads identical match indices; the link
188
+ // alternative is `[^\s\S]`, which can never match, so it is skipped for free.
189
+ const PLAIN_PATTERN =
190
+ /(!?)([^\s\S])([^\s\S])|(\*\*)(.+?)\4|(`)([^`]+?)\6|([*_])(.+?)\8/g;
191
+
158
192
  /** Splits one line into `{ text, bold?, italic?, code?, href? }` spans. */
159
193
  function parseInline(line) {
160
194
  const source = typeof line === "string" ? line : "";
161
195
  const spans = [];
162
196
  // One pass over the four delimiters. Alternation order matters: `**` must
163
197
  // be tried before `*` or the bold marker is consumed as two italics.
164
- const pattern =
165
- /(!?)\[((?:[^\]\\]|\\.)*)\]\(\s*(\S+?)\s*\)|(\*\*)(.+?)\4|(`)([^`]+?)\6|([*_])(.+?)\8/g;
198
+ // The link branch scans forward from EVERY `[`, so a line carrying many
199
+ // unclosed brackets costs O(n x brackets) -- 4s for 50k of them, a frozen
200
+ // JS thread. A link needs `](`, so a line without one skips that branch
201
+ // entirely and stays linear. Same spans either way.
202
+ const mayHoldLink = source.includes("](") && source.includes(")");
203
+ const pattern = mayHoldLink ? LINK_PATTERN : PLAIN_PATTERN;
204
+ pattern.lastIndex = 0;
166
205
  let cursor = 0;
167
206
  let match = pattern.exec(source);
168
207
  while (match) {
@@ -174,14 +213,37 @@ function parseInline(line) {
174
213
  // literal text rather than becoming a link to the image.
175
214
  if (match[1]) spans.push({ text: match[0] });
176
215
  else for (const span of linkSpans(match[2], match[3])) spans.push(span);
177
- } else if (match[4]) spans.push({ text: match[5], bold: true });
178
- else if (match[6]) spans.push({ text: match[7], code: true });
179
- else spans.push({ text: match[9], italic: true });
216
+ } else if (match[4] || match[8]) {
217
+ const delim = match[4] || match[8];
218
+ const content = match[4] ? match[5] : match[9];
219
+ const closeEnd = match.index + match[0].length;
220
+ if (!emphasisFlanks(delim, content, source, match.index, closeEnd)) {
221
+ // Not emphasis after all: keep the delimiter as text and rescan from
222
+ // just after it, so a later legitimate pair can still match.
223
+ spans.push({ text: delim });
224
+ cursor = match.index + delim.length;
225
+ pattern.lastIndex = cursor;
226
+ match = pattern.exec(source);
227
+ continue;
228
+ }
229
+ if (match[4]) spans.push({ text: content, bold: true });
230
+ else spans.push({ text: content, italic: true });
231
+ } else if (match[6]) spans.push({ text: match[7], code: true });
180
232
  cursor = match.index + match[0].length;
181
233
  match = pattern.exec(source);
182
234
  }
183
235
  if (cursor < source.length) spans.push({ text: source.slice(cursor) });
184
- return spans.length > 0 ? spans : [{ text: source }];
236
+ if (spans.length === 0) return [{ text: source }];
237
+ // A rejected delimiter leaves plain fragments either side of it; fold them
238
+ // back so a line of prose stays one span instead of three.
239
+ const merged = [];
240
+ for (const span of spans) {
241
+ const last = merged[merged.length - 1];
242
+ const plain = (s) => !s.bold && !s.italic && !s.code && !s.href;
243
+ if (last && plain(last) && plain(span)) last.text += span.text;
244
+ else merged.push({ ...span });
245
+ }
246
+ return merged;
185
247
  }
186
248
 
187
249
  // `\|` is how a literal pipe survives inside a cell, so the split has to walk
@@ -263,11 +325,22 @@ export function formatMarkdownTable(columns = 3, rows = 2) {
263
325
  /**
264
326
  * Parses markdown text into renderable blocks:
265
327
  * `{ type: "heading"|"paragraph"|"bullet"|"ordered", level?, marker?, spans }`,
266
- * `{ type: "image", src, alt, size }`, or
328
+ * `{ type: "image", src, alt, size }`,
329
+ * `{ type: "code", text, lang }`, or
267
330
  * `{ type: "table", rows: [{ header, cells: spans[] }], align }`.
331
+ *
332
+ * `stripLegacyHtml` (default true) normalises stored rows that still hold HTML.
333
+ * Pass false for text that was never HTML -- a model's reply, say -- because
334
+ * the strip removes EVERY `<...>` once any tag-shaped token appears, which
335
+ * silently eats `Promise<void>` and `<name@example.com>`.
268
336
  */
269
- export function parseMarkdown(text) {
270
- const source = stripHtmlToMarkdown(text);
337
+ export function parseMarkdown(text, options) {
338
+ const stripLegacy = options?.stripLegacyHtml !== false;
339
+ const source = stripLegacy
340
+ ? stripHtmlToMarkdown(text)
341
+ : typeof text === "string"
342
+ ? text
343
+ : "";
271
344
  if (!source.trim()) return [];
272
345
 
273
346
  const blocks = [];
@@ -284,6 +357,27 @@ export function parseMarkdown(text) {
284
357
  const lines = source.split(/\r?\n/);
285
358
  for (let i = 0; i < lines.length; i += 1) {
286
359
  const line = lines[i].trimEnd();
360
+
361
+ // A fenced block is verbatim: its newlines and indentation ARE the content,
362
+ // and joining them into a paragraph made a code sample unreadable.
363
+ const fence = /^\s*```+\s*([^`]*)$/.exec(line);
364
+ if (fence) {
365
+ flushParagraph();
366
+ const body = [];
367
+ let j = i + 1;
368
+ while (j < lines.length && !/^\s*```+\s*$/.test(lines[j])) {
369
+ body.push(lines[j]);
370
+ j += 1;
371
+ }
372
+ blocks.push({
373
+ type: "code",
374
+ text: body.join("\n"),
375
+ lang: fence[1].trim(),
376
+ });
377
+ i = j;
378
+ continue;
379
+ }
380
+
287
381
  if (!line.trim()) {
288
382
  flushParagraph();
289
383
  continue;
@@ -344,10 +438,11 @@ function spansToText(spans) {
344
438
  }
345
439
 
346
440
  /** Plain-text projection — used for search, previews, and a11y labels. */
347
- export function markdownToPlainText(text) {
348
- return parseMarkdown(text)
441
+ export function markdownToPlainText(text, options) {
442
+ return parseMarkdown(text, options)
349
443
  .map((block) => {
350
444
  if (block.type === "image") return block.alt;
445
+ if (block.type === "code") return block.text;
351
446
  // A table has no `spans` of its own; one line per row keeps a search
352
447
  // match and a screen reader reading the cells in their visual order.
353
448
  if (block.type === "table") {
@@ -126,13 +126,23 @@ export function makeRichText(rn) {
126
126
  );
127
127
  }
128
128
 
129
- function RichText({ value, renderImage, followLinks = true, style, testID }) {
129
+ function RichText({
130
+ value,
131
+ renderImage,
132
+ followLinks = true,
133
+ stripLegacyHtml = true,
134
+ style,
135
+ testID,
136
+ }) {
130
137
  const theme = useHostTheme();
131
138
  // The non-throwing reader, so RichText stays renderable outside a widget
132
139
  // host (a Studio preview, a test) the way DateTimePicker does.
133
140
  const navigation = useHostNavigation();
134
141
  const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
135
- const blocks = React.useMemo(() => parseMarkdown(value), [value]);
142
+ const blocks = React.useMemo(
143
+ () => parseMarkdown(value, { stripLegacyHtml }),
144
+ [value, stripLegacyHtml],
145
+ );
136
146
 
137
147
  // sc-7349: `openLink` is the platform's ONE link resolver — it decides page
138
148
  // / external / refuse, so a stored `javascript:` target can never be
@@ -157,6 +167,38 @@ export function makeRichText(rn) {
157
167
  View,
158
168
  { style, testID },
159
169
  blocks.map((block, i) => {
170
+ const gap = i === blocks.length - 1 ? 0 : tokens.blockGap;
171
+
172
+ if (block.type === "code") {
173
+ return React.createElement(
174
+ View,
175
+ {
176
+ key: i,
177
+ style: {
178
+ backgroundColor: tokens.surface,
179
+ borderColor: tokens.border,
180
+ borderWidth: 1,
181
+ borderRadius: 6,
182
+ padding: 8,
183
+ marginBottom: gap,
184
+ },
185
+ },
186
+ React.createElement(
187
+ Text,
188
+ {
189
+ style: {
190
+ // The theme carries no mono token, as the inline span notes.
191
+ fontFamily: "monospace",
192
+ fontSize: tokens.codeSize,
193
+ lineHeight: Math.round(tokens.codeSize * 1.5),
194
+ color: tokens.color,
195
+ },
196
+ },
197
+ block.text,
198
+ ),
199
+ );
200
+ }
201
+
160
202
  if (block.type === "table") {
161
203
  return React.createElement(TableBlock, {
162
204
  key: i,
@@ -185,6 +227,7 @@ export function makeRichText(rn) {
185
227
  fontWeight: "700",
186
228
  fontSize: tokens.headingSizes[block.level] || tokens.headingSizes[3],
187
229
  lineHeight: undefined,
230
+ marginBottom: gap,
188
231
  },
189
232
  ],
190
233
  },
@@ -196,7 +239,7 @@ export function makeRichText(rn) {
196
239
  if (typeof renderImage === "function") {
197
240
  return React.createElement(
198
241
  View,
199
- { key: i, style: { marginBottom: tokens.blockGap } },
242
+ { key: i, style: { marginBottom: gap } },
200
243
  renderImage(block),
201
244
  );
202
245
  }
@@ -207,7 +250,7 @@ export function makeRichText(rn) {
207
250
  {
208
251
  key: i,
209
252
  style: {
210
- marginBottom: tokens.blockGap,
253
+ marginBottom: gap,
211
254
  width: tokens.imageWidth[block.size] || tokens.imageWidth.full,
212
255
  },
213
256
  },
@@ -243,7 +286,7 @@ export function makeRichText(rn) {
243
286
  if (block.type === "bullet" || block.type === "ordered") {
244
287
  return React.createElement(
245
288
  View,
246
- { key: i, style: { flexDirection: "row" } },
289
+ { key: i, style: { flexDirection: "row", marginBottom: gap } },
247
290
  React.createElement(
248
291
  Text,
249
292
  {
@@ -254,11 +297,19 @@ export function makeRichText(rn) {
254
297
  },
255
298
  block.marker,
256
299
  ),
257
- React.createElement(Text, { style: [paragraph, { flex: 1 }] }, spans),
300
+ React.createElement(
301
+ Text,
302
+ { style: [paragraph, { flex: 1, marginBottom: 0 }] },
303
+ spans,
304
+ ),
258
305
  );
259
306
  }
260
307
 
261
- return React.createElement(Text, { key: i, style: paragraph }, spans);
308
+ return React.createElement(
309
+ Text,
310
+ { key: i, style: [paragraph, { marginBottom: gap }] },
311
+ spans,
312
+ );
262
313
  }),
263
314
  );
264
315
  }
@@ -18,7 +18,11 @@
18
18
  // type today's hex, which went stale silently. A bound field stores the TOKEN,
19
19
  // so the intent survives the rebrand.
20
20
 
21
- import { isThemeColorTokenRef, THEME_TOKEN_REF_PREFIX } from "./contract.js";
21
+ import {
22
+ parseThemeColorTokenRef,
23
+ withHexAlpha,
24
+ THEME_TOKEN_REF_PREFIX,
25
+ } from "./contract.js";
22
26
 
23
27
  // A value is a binding, not a colour, when it carries this prefix. No CSS
24
28
  // colour can begin with it, so a stored literal can never be misread as one.
@@ -26,7 +30,15 @@ import { isThemeColorTokenRef, THEME_TOKEN_REF_PREFIX } from "./contract.js";
26
30
  // grammar from there, and two spellings of it would drift.
27
31
  const PREFIX = THEME_TOKEN_REF_PREFIX;
28
32
 
29
- /** True when `value` is a theme-token binding rather than a literal colour. */
33
+ /**
34
+ * True when `value` is a theme-token binding rather than a literal colour.
35
+ *
36
+ * Deliberately the PREFIX alone, not the contract's full parse: the callers
37
+ * below use it to decide what to DROP, and a binding naming a withdrawn token
38
+ * or carrying a malformed opacity must go back to unset — where its
39
+ * `themeDefault` paints — rather than reach a renderer as the literal string
40
+ * `theme:...`.
41
+ */
30
42
  export function isThemeTokenRef(value) {
31
43
  return typeof value === "string" && value.startsWith(PREFIX);
32
44
  }
@@ -43,14 +55,16 @@ export function resolveThemeTokenRef(value, theme) {
43
55
  // The allowlist is the contract's, so what the Studio may WRITE and what a
44
56
  // host will RESOLVE cannot drift apart. sc-8326 moved the test itself there
45
57
  // too, since Mason's plan validator has to ask the same question.
46
- if (!isThemeColorTokenRef(value)) return undefined;
47
- const path = value.slice(PREFIX.length);
48
- const resolved = path
58
+ const ref = parseThemeColorTokenRef(value);
59
+ if (!ref) return undefined;
60
+ const resolved = ref.path
49
61
  .split(".")
50
62
  .reduce((o, k) => (o == null ? undefined : o[k]), theme);
51
- return typeof resolved === "string" && resolved.trim() !== ""
52
- ? resolved
53
- : undefined;
63
+ if (typeof resolved !== "string" || resolved.trim() === "") return undefined;
64
+ // sc-8893: an authored opacity REPLACES the token's. With none authored the
65
+ // token is handed back verbatim — including a token that is itself
66
+ // translucent, which reading the bare form as 100 would quietly make solid.
67
+ return ref.alpha === null ? resolved : withHexAlpha(resolved, ref.alpha);
54
68
  }
55
69
 
56
70
  function isPlainObject(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.165.0",
3
+ "version": "0.167.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",