@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 +25 -1
- package/dist/contract.cjs +137 -10
- package/dist/contract.js +137 -10
- package/dist/host.d.ts +2 -1
- package/dist/index.d.ts +61 -3
- package/dist/index.js +4 -0
- package/dist/index.native.js +4 -0
- package/dist/markdown.js +106 -11
- package/dist/richtext-view.js +58 -7
- package/dist/theme-token-refs.js +22 -8
- package/package.json +1 -1
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.
|
|
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
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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
|
-
|
|
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 `` 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 `` 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
|
-
|
|
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
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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
|
-
|
|
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 `` 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 `` 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
|
-
|
|
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(
|
|
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(
|
|
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";
|
package/dist/index.native.js
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
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]
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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 }`,
|
|
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
|
|
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") {
|
package/dist/richtext-view.js
CHANGED
|
@@ -126,13 +126,23 @@ export function makeRichText(rn) {
|
|
|
126
126
|
);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
function RichText({
|
|
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(
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
308
|
+
return React.createElement(
|
|
309
|
+
Text,
|
|
310
|
+
{ key: i, style: [paragraph, { marginBottom: gap }] },
|
|
311
|
+
spans,
|
|
312
|
+
);
|
|
262
313
|
}),
|
|
263
314
|
);
|
|
264
315
|
}
|
package/dist/theme-token-refs.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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",
|