@colixsystems/widget-sdk 0.166.0 → 0.168.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,18 @@ 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.
77
88
 
78
89
  ### What's new in 0.166.0 (contract 1.128.0)
79
90
 
@@ -82,7 +93,7 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
82
93
  A binding may now carry an opacity: `"theme:colors.primary/20"` is the primary brand colour at 20%.
83
94
 
84
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).
85
- - **`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.
96
+ - **`withHexAlpha(hex, percent)` / `hexAlphaPercent(hex)`** are the percent ↔ alpha-byte conversion, so the 20% a panel reads back is the 20% a render paints. The Studio's `frontend/src/utils/color.js` keeps its own copy of this maths rather than importing it — that module is reached by a `node --test` suite, and the SDK is a Vite alias with no node resolution — and the two are pinned equal by the SDK's suite.
86
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.
87
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.
88
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.
package/dist/contract.cjs CHANGED
@@ -1458,6 +1458,12 @@ const HOOKS = [
1458
1458
  "no error at all. Call cancel() to stop waiting -- it ends the WAIT, " +
1459
1459
  "not the turn. NEVER surface a " +
1460
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. " +
1461
1467
  "Reads ctx.agents.",
1462
1468
  returnShape: {
1463
1469
  send: "(text) => Promise<{ id, role, content, created_at } | null>",
@@ -2265,7 +2271,7 @@ const PRIMITIVES = [
2265
2271
  {
2266
2272
  name: "RichText",
2267
2273
  description:
2268
- '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.',
2269
2275
  rnComponent: null,
2270
2276
  docsUrl: null,
2271
2277
  },
@@ -4533,15 +4539,21 @@ const CONTRACT = deepFreeze({
4533
4539
  // field bound, so "our brand colour at 20%" forced the author back to a
4534
4540
  // frozen hex, which then stopped following a rebrand — the exact thing
4535
4541
  // binding exists to prevent. `parseThemeColorTokenRef` /
4536
- // `formatThemeColorTokenRef` publish the grammar and
4542
+ // `formatThemeColorTokenRef` publish the grammar, and
4537
4543
  // `withHexAlpha` / `hexAlphaPercent` the percent-to-byte conversion the
4538
- // Studio's control and both hosts' resolver share, so 20% authored is 20%
4539
- // painted. The BARE form inherits the token's own opacity (a brand colour
4544
+ // Studio's control and both hosts' resolver answer alike, so 20% authored
4545
+ // is 20% painted. The BARE form inherits the token's own opacity (a brand colour
4540
4546
  // may be authored translucent), which makes `/100` a real value rather
4541
4547
  // than a redundant spelling: it is how "opaque, whatever the brand does"
4542
4548
  // is said. Additive: an unsuffixed binding parses and resolves exactly as
4543
4549
  // before, so every stored value is untouched.
4544
- version: "1.128.0",
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",
4545
4557
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4546
4558
  hooks: HOOKS,
4547
4559
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1458,6 +1458,12 @@ const HOOKS = [
1458
1458
  "no error at all. Call cancel() to stop waiting -- it ends the WAIT, " +
1459
1459
  "not the turn. NEVER surface a " +
1460
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. " +
1461
1467
  "Reads ctx.agents.",
1462
1468
  returnShape: {
1463
1469
  send: "(text) => Promise<{ id, role, content, created_at } | null>",
@@ -2265,7 +2271,7 @@ const PRIMITIVES = [
2265
2271
  {
2266
2272
  name: "RichText",
2267
2273
  description:
2268
- '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.',
2269
2275
  rnComponent: null,
2270
2276
  docsUrl: null,
2271
2277
  },
@@ -4529,15 +4535,21 @@ const CONTRACT = deepFreeze({
4529
4535
  // field bound, so "our brand colour at 20%" forced the author back to a
4530
4536
  // frozen hex, which then stopped following a rebrand — the exact thing
4531
4537
  // binding exists to prevent. `parseThemeColorTokenRef` /
4532
- // `formatThemeColorTokenRef` publish the grammar and
4538
+ // `formatThemeColorTokenRef` publish the grammar, and
4533
4539
  // `withHexAlpha` / `hexAlphaPercent` the percent-to-byte conversion the
4534
- // Studio's control and both hosts' resolver share, so 20% authored is 20%
4535
- // painted. The BARE form inherits the token's own opacity (a brand colour
4540
+ // Studio's control and both hosts' resolver answer alike, so 20% authored
4541
+ // is 20% painted. The BARE form inherits the token's own opacity (a brand colour
4536
4542
  // may be authored translucent), which makes `/100` a real value rather
4537
4543
  // than a redundant spelling: it is how "opaque, whatever the brand does"
4538
4544
  // is said. Additive: an unsuffixed binding parses and resolves exactly as
4539
4545
  // before, so every stored value is untouched.
4540
- version: "1.128.0",
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",
4541
4553
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4542
4554
  hooks: HOOKS,
4543
4555
  primitives: PRIMITIVES,
package/dist/host.js CHANGED
@@ -74,6 +74,9 @@ export {
74
74
  // SIDEBAR local menu hangs in the rail and the drawer. The compiler asks it
75
75
  // once per screen at build time and the Player once per render, so the two
76
76
  // chromes derive the same tree from the same code.
77
+ // REQ-NAV-LOCAL (sc-8901): `resolveLocalNavModel` answers WHICH model a page
78
+ // wears in one device view, so the same menu can be a bottom bar on a phone and
79
+ // unfold in the rail on a desktop.
77
80
  export {
78
81
  normaliseNavigation,
79
82
  menuItemCap,
@@ -94,6 +97,8 @@ export {
94
97
  DEFAULT_TOP_STRIPE_HEIGHT_PX,
95
98
  DEFAULT_TOP_STRIPE_PADDING_X_PX,
96
99
  resolveSidebarUnfold,
100
+ resolveLocalNavModel,
101
+ drawsLocalNavAnywhere,
97
102
  } from "./navigation.js";
98
103
 
99
104
  // sc-5717: the codec behind `useWidgetRoute()` — how a widget's own internal
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.
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") {
@@ -675,6 +675,64 @@ function resolveSidebarUnfold(input) {
675
675
  return { under: section.key, items: marked, standalone: true };
676
676
  }
677
677
 
678
+ /**
679
+ * REQ-NAV-LOCAL (sc-8901) — which local-navigation model a page wears in ONE
680
+ * device view, so the same saved menu can be a bottom bar on a phone and unfold
681
+ * in the rail on a desktop.
682
+ *
683
+ * `local_nav` is the MOBILE value and the base of the cascade: a page authored
684
+ * before the wider views existed leaves both of them unset and therefore
685
+ * renders identically at every width. The cascade is REQ-LAY-07's — the one the
686
+ * layout grid and every responsive widget prop already use — because a page's
687
+ * chrome and its content must not disagree about what a tablet is.
688
+ *
689
+ * The vocabulary stays the API's: any non-empty string is a model and the
690
+ * host's own switch decides what it can draw, so a kind a newer backend allows
691
+ * cascades correctly here instead of being flattened to `NONE` on the way.
692
+ *
693
+ * @param {object|null|undefined} page — a page row carrying `local_nav` and,
694
+ * optionally, `local_nav_tablet` / `local_nav_desktop`.
695
+ * @param {"mobile"|"tablet"|"desktop"} view — the device view being drawn.
696
+ * @returns {string} the model for that view; `NONE` when the page sets none.
697
+ */
698
+ function resolveLocalNavModel(page, view) {
699
+ const at = (key) => {
700
+ const value = isPlainObject(page) ? page[key] : null;
701
+ return typeof value === "string" && value ? value : null;
702
+ };
703
+ const mobile = at("local_nav") || "NONE";
704
+ if (view === "desktop") {
705
+ return at("local_nav_desktop") || at("local_nav_tablet") || mobile;
706
+ }
707
+ if (view === "tablet") return at("local_nav_tablet") || mobile;
708
+ return mobile;
709
+ }
710
+
711
+ // sc-8901: the three device views a local bar can differ across — REQ-LAY-07's
712
+ // own list, so a page's chrome and its content classify a width alike. Ordered
713
+ // narrowest first, which is also the cascade's direction.
714
+ const LOCAL_NAV_VIEWS = ["mobile", "tablet", "desktop"];
715
+
716
+ /**
717
+ * REQ-NAV-LOCAL (sc-8901) — does this page draw a local bar in ANY device view?
718
+ *
719
+ * The saved-menu reference is shared by all three views, so it is required — and
720
+ * resolved onto the wire — as soon as one of them draws. Asking
721
+ * `local_nav !== "NONE"` alone would miss a page that carries a bar only on the
722
+ * wider views.
723
+ *
724
+ * Beside the cascade it is derived from, so the API rule, the export's screen
725
+ * bake and Copy Workspace cannot disagree about which pages have a bar.
726
+ *
727
+ * @param {object|null|undefined} page — a page row.
728
+ * @returns {boolean}
729
+ */
730
+ function drawsLocalNavAnywhere(page) {
731
+ return LOCAL_NAV_VIEWS.some(
732
+ (view) => resolveLocalNavModel(page, view) !== "NONE",
733
+ );
734
+ }
735
+
678
736
  // REQ-THEME-LOOK: the divider's width in px, bounded HERE so both hosts inherit
679
737
  // one range. The export clamped it on its way into the StyleSheet
680
738
  // (`coerceBorderWidth(...) ?? 1`) where the Player passed any finite number
@@ -711,4 +769,4 @@ function borderWidthOr(...values) {
711
769
  return BORDER_WIDTH_MIN;
712
770
  }
713
771
 
714
- module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopStripeLayout, resolveTopBarTokens, topStripeShowKey, TOP_STRIPE_ALWAYS_SHOWN_SLOTS, TOP_STRIPE_SLOTS, MIN_TOP_STRIPE_HEIGHT_PX, MAX_TOP_STRIPE_HEIGHT_PX, MIN_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_OFFSET_Y_PX, DEFAULT_TOP_STRIPE_HEIGHT_PX, DEFAULT_TOP_STRIPE_PADDING_X_PX, resolveSidebarUnfold };
772
+ module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopStripeLayout, resolveTopBarTokens, topStripeShowKey, TOP_STRIPE_ALWAYS_SHOWN_SLOTS, TOP_STRIPE_SLOTS, MIN_TOP_STRIPE_HEIGHT_PX, MAX_TOP_STRIPE_HEIGHT_PX, MIN_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_PADDING_X_PX, MAX_TOP_STRIPE_OFFSET_Y_PX, DEFAULT_TOP_STRIPE_HEIGHT_PX, DEFAULT_TOP_STRIPE_PADDING_X_PX, resolveSidebarUnfold, resolveLocalNavModel, drawsLocalNavAnywhere };
@@ -666,6 +666,64 @@ export function resolveSidebarUnfold(input) {
666
666
  return { under: section.key, items: marked, standalone: true };
667
667
  }
668
668
 
669
+ /**
670
+ * REQ-NAV-LOCAL (sc-8901) — which local-navigation model a page wears in ONE
671
+ * device view, so the same saved menu can be a bottom bar on a phone and unfold
672
+ * in the rail on a desktop.
673
+ *
674
+ * `local_nav` is the MOBILE value and the base of the cascade: a page authored
675
+ * before the wider views existed leaves both of them unset and therefore
676
+ * renders identically at every width. The cascade is REQ-LAY-07's — the one the
677
+ * layout grid and every responsive widget prop already use — because a page's
678
+ * chrome and its content must not disagree about what a tablet is.
679
+ *
680
+ * The vocabulary stays the API's: any non-empty string is a model and the
681
+ * host's own switch decides what it can draw, so a kind a newer backend allows
682
+ * cascades correctly here instead of being flattened to `NONE` on the way.
683
+ *
684
+ * @param {object|null|undefined} page — a page row carrying `local_nav` and,
685
+ * optionally, `local_nav_tablet` / `local_nav_desktop`.
686
+ * @param {"mobile"|"tablet"|"desktop"} view — the device view being drawn.
687
+ * @returns {string} the model for that view; `NONE` when the page sets none.
688
+ */
689
+ export function resolveLocalNavModel(page, view) {
690
+ const at = (key) => {
691
+ const value = isPlainObject(page) ? page[key] : null;
692
+ return typeof value === "string" && value ? value : null;
693
+ };
694
+ const mobile = at("local_nav") || "NONE";
695
+ if (view === "desktop") {
696
+ return at("local_nav_desktop") || at("local_nav_tablet") || mobile;
697
+ }
698
+ if (view === "tablet") return at("local_nav_tablet") || mobile;
699
+ return mobile;
700
+ }
701
+
702
+ // sc-8901: the three device views a local bar can differ across — REQ-LAY-07's
703
+ // own list, so a page's chrome and its content classify a width alike. Ordered
704
+ // narrowest first, which is also the cascade's direction.
705
+ const LOCAL_NAV_VIEWS = ["mobile", "tablet", "desktop"];
706
+
707
+ /**
708
+ * REQ-NAV-LOCAL (sc-8901) — does this page draw a local bar in ANY device view?
709
+ *
710
+ * The saved-menu reference is shared by all three views, so it is required — and
711
+ * resolved onto the wire — as soon as one of them draws. Asking
712
+ * `local_nav !== "NONE"` alone would miss a page that carries a bar only on the
713
+ * wider views.
714
+ *
715
+ * Beside the cascade it is derived from, so the API rule, the export's screen
716
+ * bake and Copy Workspace cannot disagree about which pages have a bar.
717
+ *
718
+ * @param {object|null|undefined} page — a page row.
719
+ * @returns {boolean}
720
+ */
721
+ export function drawsLocalNavAnywhere(page) {
722
+ return LOCAL_NAV_VIEWS.some(
723
+ (view) => resolveLocalNavModel(page, view) !== "NONE",
724
+ );
725
+ }
726
+
669
727
  // REQ-THEME-LOOK: the divider's width in px, bounded HERE so both hosts inherit
670
728
  // one range. The export clamped it on its way into the StyleSheet
671
729
  // (`coerceBorderWidth(...) ?? 1`) where the Player passed any finite number
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.166.0",
3
+ "version": "0.168.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",