@colixsystems/widget-sdk 0.149.1 → 0.150.1

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.
@@ -0,0 +1,68 @@
1
+ // sc-8104 — the values `<EmojiPicker>` is painted with, and its recents store.
2
+ //
3
+ // Presentation-free like ./overlay-tokens.js: both hosts render the picker
4
+ // from the SAME component, and this is where the theme becomes numbers.
5
+
6
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
7
+
8
+ export const EMOJI_PICKER_DEFAULTS = Object.freeze({
9
+ // Fits a comfortable grid at phone width without shrinking the glyph.
10
+ cellSize: 40,
11
+ glyphSize: 24,
12
+ recentsCap: 24,
13
+ searchLimit: 60,
14
+ });
15
+
16
+ export function resolveEmojiPickerTokens(theme) {
17
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
18
+ const fallback = DEFAULT_THEME_TOKENS;
19
+ const colors = base.colors || fallback.colors;
20
+ const radii = base.radii || fallback.radii;
21
+ const spacing = base.spacing || fallback.spacing;
22
+
23
+ return {
24
+ surface: colors.surface || fallback.colors.surface,
25
+ surfaceMuted: colors.surfaceMuted || fallback.colors.surfaceMuted,
26
+ onSurface: colors.onSurface || fallback.colors.onSurface,
27
+ onSurfaceMuted: colors.onSurfaceMuted || fallback.colors.onSurfaceMuted,
28
+ border: colors.border || fallback.colors.border,
29
+ primary: colors.primary || fallback.colors.primary,
30
+ radius: radii.md || fallback.radii.md,
31
+ gap: spacing.sm || fallback.spacing.sm,
32
+ padding: spacing.md || fallback.spacing.md,
33
+ cellSize: EMOJI_PICKER_DEFAULTS.cellSize,
34
+ glyphSize: EMOJI_PICKER_DEFAULTS.glyphSize,
35
+ };
36
+ }
37
+
38
+ /** Most-recent-first, de-duplicated, capped. Pure so the host can persist it. */
39
+ export function pushRecentEmoji(recents, emoji, cap = EMOJI_PICKER_DEFAULTS.recentsCap) {
40
+ if (typeof emoji !== "string" || !emoji) return Array.isArray(recents) ? recents : [];
41
+ const rest = (Array.isArray(recents) ? recents : []).filter((e) => e !== emoji);
42
+ return [emoji, ...rest].slice(0, Math.max(0, cap));
43
+ }
44
+
45
+ /**
46
+ * Insert text at the caret, returning the new value and where the caret lands.
47
+ * A composer inserts mid-draft rather than appending, so the selection has to
48
+ * move with the insertion.
49
+ */
50
+ export function insertAtCaret(value, insertion, selection) {
51
+ const text = typeof value === "string" ? value : "";
52
+ const add = typeof insertion === "string" ? insertion : "";
53
+ const rawStart = selection?.start;
54
+ const rawEnd = selection?.end;
55
+ const start = clampIndex(rawStart, text.length, text.length);
56
+ const end = clampIndex(rawEnd, text.length, start);
57
+ const from = Math.min(start, end);
58
+ const to = Math.max(start, end);
59
+ return {
60
+ value: text.slice(0, from) + add + text.slice(to),
61
+ caret: from + add.length,
62
+ };
63
+ }
64
+
65
+ function clampIndex(value, max, fallback) {
66
+ if (!Number.isFinite(value)) return fallback;
67
+ return Math.min(Math.max(0, Math.trunc(value)), max);
68
+ }
@@ -0,0 +1,193 @@
1
+ // sc-8104 — the ONE implementation behind the `<EmojiPicker>` SDK primitive.
2
+ //
3
+ // Typed emoji already worked on both hosts; what forced users out to the OS
4
+ // keyboard was having no in-app way to PICK one. The picker serves both the
5
+ // composer (insert into a draft) and the reaction flow (start a new emoji
6
+ // group), so it is one shared component bound per host the way `<Overlay>` is
7
+ // (./overlay-view.js) — the hosts cannot drift (CLAUDE.md §3, §8).
8
+ //
9
+ // It renders INSIDE the SDK `Overlay`, which already escapes widget clipping
10
+ // on both hosts, so a picker opened from a card is never cut off.
11
+
12
+ import React from "react";
13
+ import { useHostTheme } from "./hooks.js";
14
+ import { resolveEmojiPickerTokens, EMOJI_PICKER_DEFAULTS, pushRecentEmoji } from "./emoji-picker-tokens.js";
15
+ import { EMOJI_CATEGORIES, emojiForCategory, searchEmoji } from "./emoji-data.js";
16
+
17
+ const RECENTS_KEY = "recent";
18
+
19
+ /**
20
+ * Bind the shared emoji picker to one platform's primitives.
21
+ *
22
+ * @param {{Overlay, View, Text, Pressable, ScrollView, TextInput}} rn
23
+ */
24
+ export function makeEmojiPicker(rn) {
25
+ const { Overlay, View, Text, Pressable, ScrollView, TextInput } = rn;
26
+
27
+ function EmojiPicker({
28
+ visible = false,
29
+ onSelect,
30
+ onRequestClose,
31
+ recents,
32
+ onRecentsChange,
33
+ accessibilityLabel = "Choose an emoji",
34
+ closeAccessibilityLabel = "Close the emoji picker",
35
+ searchPlaceholder = "Search emoji",
36
+ }) {
37
+ const theme = useHostTheme();
38
+ const tokens = React.useMemo(() => resolveEmojiPickerTokens(theme), [theme]);
39
+ const [query, setQuery] = React.useState("");
40
+ const [category, setCategory] = React.useState(EMOJI_CATEGORIES[0].key);
41
+
42
+ const safeRecents = React.useMemo(
43
+ () => (Array.isArray(recents) ? recents.filter((e) => typeof e === "string" && e) : []),
44
+ [recents],
45
+ );
46
+
47
+ // Reopening with a stale query would show yesterday's search, not the grid.
48
+ React.useEffect(() => {
49
+ if (!visible) return;
50
+ setQuery("");
51
+ setCategory(safeRecents.length ? RECENTS_KEY : EMOJI_CATEGORIES[0].key);
52
+ }, [visible, safeRecents.length]);
53
+
54
+ const trimmed = query.trim();
55
+ const results = React.useMemo(() => {
56
+ if (trimmed) return searchEmoji(trimmed, EMOJI_PICKER_DEFAULTS.searchLimit);
57
+ if (category === RECENTS_KEY) return safeRecents.map((char) => ({ char, name: char }));
58
+ return emojiForCategory(category);
59
+ }, [trimmed, category, safeRecents]);
60
+
61
+ const handleSelect = React.useCallback(
62
+ (char) => {
63
+ if (typeof onRecentsChange === "function") {
64
+ onRecentsChange(pushRecentEmoji(safeRecents, char));
65
+ }
66
+ if (typeof onSelect === "function") onSelect(char);
67
+ },
68
+ [onSelect, onRecentsChange, safeRecents],
69
+ );
70
+
71
+ const tabs = safeRecents.length
72
+ ? [{ key: RECENTS_KEY, label: "Recent" }, ...EMOJI_CATEGORIES]
73
+ : EMOJI_CATEGORIES;
74
+
75
+ return React.createElement(
76
+ Overlay,
77
+ {
78
+ visible,
79
+ onRequestClose,
80
+ size: "md",
81
+ accessibilityLabel,
82
+ closeAccessibilityLabel,
83
+ },
84
+ React.createElement(
85
+ View,
86
+ { style: { gap: tokens.gap }, testID: "emoji-picker" },
87
+ React.createElement(TextInput, {
88
+ testID: "emoji-picker-search",
89
+ value: query,
90
+ onChangeText: setQuery,
91
+ placeholder: searchPlaceholder,
92
+ placeholderTextColor: tokens.onSurfaceMuted,
93
+ accessibilityLabel: searchPlaceholder,
94
+ style: {
95
+ color: tokens.onSurface,
96
+ backgroundColor: tokens.surfaceMuted,
97
+ borderRadius: tokens.radius,
98
+ paddingHorizontal: tokens.padding,
99
+ paddingVertical: tokens.gap,
100
+ fontSize: 14,
101
+ },
102
+ }),
103
+
104
+ // Category tabs are hidden while searching: results already span them.
105
+ trimmed
106
+ ? null
107
+ : React.createElement(
108
+ ScrollView,
109
+ { horizontal: true, showsHorizontalScrollIndicator: false, testID: "emoji-picker-tabs" },
110
+ React.createElement(
111
+ View,
112
+ { style: { flexDirection: "row", gap: tokens.gap } },
113
+ tabs.map((tab) =>
114
+ React.createElement(
115
+ Pressable,
116
+ {
117
+ key: tab.key,
118
+ testID: `emoji-picker-tab-${tab.key}`,
119
+ onPress: () => setCategory(tab.key),
120
+ accessibilityRole: "tab",
121
+ accessibilityLabel: tab.label,
122
+ "aria-selected": category === tab.key,
123
+ style: {
124
+ paddingHorizontal: tokens.padding,
125
+ paddingVertical: tokens.gap,
126
+ borderRadius: tokens.radius,
127
+ backgroundColor:
128
+ category === tab.key ? tokens.surfaceMuted : "transparent",
129
+ },
130
+ },
131
+ React.createElement(
132
+ Text,
133
+ {
134
+ style: {
135
+ fontSize: 12,
136
+ fontWeight: "600",
137
+ color: category === tab.key ? tokens.primary : tokens.onSurfaceMuted,
138
+ },
139
+ },
140
+ tab.label,
141
+ ),
142
+ ),
143
+ ),
144
+ ),
145
+ ),
146
+
147
+ React.createElement(
148
+ ScrollView,
149
+ { style: { maxHeight: 280 }, testID: "emoji-picker-grid" },
150
+ results.length
151
+ ? React.createElement(
152
+ View,
153
+ { style: { flexDirection: "row", flexWrap: "wrap" } },
154
+ results.map((entry) =>
155
+ React.createElement(
156
+ Pressable,
157
+ {
158
+ key: entry.char,
159
+ testID: `emoji-picker-option-${entry.char}`,
160
+ onPress: () => handleSelect(entry.char),
161
+ accessibilityRole: "button",
162
+ accessibilityLabel: entry.name || entry.char,
163
+ style: {
164
+ width: tokens.cellSize,
165
+ height: tokens.cellSize,
166
+ alignItems: "center",
167
+ justifyContent: "center",
168
+ },
169
+ },
170
+ React.createElement(
171
+ Text,
172
+ { style: { fontSize: tokens.glyphSize, lineHeight: tokens.cellSize } },
173
+ entry.char,
174
+ ),
175
+ ),
176
+ ),
177
+ )
178
+ : React.createElement(
179
+ Text,
180
+ {
181
+ testID: "emoji-picker-empty",
182
+ style: { color: tokens.onSurfaceMuted, fontSize: 13, padding: tokens.padding },
183
+ },
184
+ "No emoji found",
185
+ ),
186
+ ),
187
+ ),
188
+ );
189
+ }
190
+
191
+ EmojiPicker.displayName = "EmojiPicker";
192
+ return EmojiPicker;
193
+ }
@@ -0,0 +1,14 @@
1
+ // sc-8104 — `<EmojiPicker>` (web). Binds the shared implementation in
2
+ // ./emoji-picker-view.js to react-native-web's primitives plus the web
3
+ // `<Overlay>`, so the picker escapes the widget's clipping ancestors the same
4
+ // way every other SDK overlay does.
5
+ //
6
+ // The import is `react-native-web` rather than the bare `react-native`
7
+ // specifier for the rolldown optional-peer-dep reason at the top of
8
+ // ./primitives.js.
9
+
10
+ import * as ReactNative from "react-native-web";
11
+ import { makeEmojiPicker } from "./emoji-picker-view.js";
12
+ import { Overlay } from "./overlay.js";
13
+
14
+ export const EmojiPicker = makeEmojiPicker({ ...ReactNative, Overlay });
@@ -0,0 +1,16 @@
1
+ // sc-8104 — `<EmojiPicker>` (native). Binds the shared implementation in
2
+ // ./emoji-picker-view.js to React Native's own primitives plus the native
3
+ // `<Overlay>`. Only the imports differ from the web mirror (./emoji-picker.js).
4
+
5
+ import { View, Text, Pressable, ScrollView, TextInput } from "react-native";
6
+ import { makeEmojiPicker } from "./emoji-picker-view.js";
7
+ import { Overlay } from "./overlay.native.js";
8
+
9
+ export const EmojiPicker = makeEmojiPicker({
10
+ View,
11
+ Text,
12
+ Pressable,
13
+ ScrollView,
14
+ TextInput,
15
+ Overlay,
16
+ });
@@ -0,0 +1,167 @@
1
+ // sc-8104 — the ONE reaction model behind Slack-style emoji reactions.
2
+ //
3
+ // Newsfeed and Chat both render per-emoji pills over rows of a Reactions
4
+ // table, so the grouping, the self-reaction rule and the legacy-slug mapping
5
+ // live here once rather than in each widget (CLAUDE.md §3). Presentation-free
6
+ // and platform-free like ./overlay-tokens.js: the maths is unit-testable
7
+ // without a renderer, and both hosts render it from the same numbers (§8).
8
+
9
+ import { isEmojiChar } from "./emoji-data.js";
10
+
11
+ // Reactions shipped before this as five slugs plus the bare pre-set-era
12
+ // `like`. Production holds those rows, so they are mapped at READ time and
13
+ // keep counting; no backfill, and writes are emoji from here on (§7).
14
+ export const LEGACY_REACTION_EMOJI = Object.freeze({
15
+ like: "👍",
16
+ love: "❤️",
17
+ laugh: "😂",
18
+ celebrate: "🎉",
19
+ sad: "😢",
20
+ });
21
+
22
+ // Looked up through a Map, never the object above: a stored "constructor" or
23
+ // "__proto__" would resolve off Object.prototype and be counted as a reaction,
24
+ // rendering a function as a React child and crashing the whole list.
25
+ const LEGACY_BY_SLUG = new Map(Object.entries(LEGACY_REACTION_EMOJI));
26
+
27
+ // What a plain tap writes, and what every legacy `like` row maps onto.
28
+ export const DEFAULT_REACTION_EMOJI = LEGACY_REACTION_EMOJI.like;
29
+
30
+ /**
31
+ * The emoji a stored Type value stands for, or null when it is neither a
32
+ * legacy slug nor an emoji we ship — a hand-edited value must not be counted.
33
+ */
34
+ export function readReactionEmoji(value) {
35
+ if (typeof value !== "string" || !value) return null;
36
+ const legacy = LEGACY_BY_SLUG.get(value);
37
+ if (legacy) return legacy;
38
+ return isEmojiChar(value) ? value : null;
39
+ }
40
+
41
+ /** Whether a stored Type value counts at all. */
42
+ export function isCountableReaction(value) {
43
+ return readReactionEmoji(value) !== null;
44
+ }
45
+
46
+ /**
47
+ * The emoji a stored ROW stands for. An unbound type column means every row is
48
+ * a plain default reaction, which is what rows written before the type set
49
+ * existed look like.
50
+ */
51
+ export function rowReactionEmoji(row, typeField) {
52
+ if (!typeField) return DEFAULT_REACTION_EMOJI;
53
+ return readReactionEmoji(row?.[typeField]);
54
+ }
55
+
56
+ /**
57
+ * Whether one row is this user's reaction with this emoji — the set an
58
+ * un-react must clear, and the single predicate deciding add-vs-remove.
59
+ *
60
+ * Shared because each widget deriving it separately is how they drift: read
61
+ * against the ROW's stored value, never against the tapped emoji, or an
62
+ * unbound type column makes every row match whatever was tapped and the tap
63
+ * silently deletes an unrelated reaction (CLAUDE.md §3).
64
+ */
65
+ export function isMyReactionRow(row, { typeField, userField, userId, emoji }) {
66
+ if (!userId || !emoji) return false;
67
+ if (readRelationId(row?.[userField]) !== userId) return false;
68
+ return rowReactionEmoji(row, typeField) === emoji;
69
+ }
70
+
71
+ /**
72
+ * Group reaction rows into per-emoji pills.
73
+ *
74
+ * One row per (target, user, emoji) is the invariant: a user may hold several
75
+ * DIFFERENT emoji on one target but never two rows of the same one, so each
76
+ * group de-duplicates by user. `mine` keeps every one of the current user's
77
+ * rows for that emoji, which is what lets an un-react clear duplicates a table
78
+ * already picked up.
79
+ *
80
+ * @returns {Array<{emoji, count, reactedByMe, userIds, rows, mine}>}
81
+ * in descending count, ties broken by first appearance.
82
+ */
83
+ export function groupReactions(rows, { typeField, userField, userId } = {}) {
84
+ const groups = new Map();
85
+ let order = 0;
86
+
87
+ for (const row of Array.isArray(rows) ? rows : []) {
88
+ // An unbound Type column means every row is a plain like, which is what
89
+ // rows written before the type set existed look like.
90
+ const stored = typeField ? row?.[typeField] : DEFAULT_REACTION_EMOJI;
91
+ const emoji = readReactionEmoji(stored);
92
+ if (!emoji) continue;
93
+
94
+ const rowUserId = readRelationId(row?.[userField]);
95
+ let group = groups.get(emoji);
96
+ if (!group) {
97
+ group = { emoji, order: order++, userIds: [], seen: new Set(), rows: [], mine: [] };
98
+ groups.set(emoji, group);
99
+ }
100
+ group.rows.push(row);
101
+ if (rowUserId && userId && rowUserId === userId) group.mine.push(row);
102
+ if (!rowUserId || group.seen.has(rowUserId)) continue;
103
+ group.seen.add(rowUserId);
104
+ group.userIds.push(rowUserId);
105
+ }
106
+
107
+ return [...groups.values()]
108
+ .map((group) => ({
109
+ emoji: group.emoji,
110
+ count: group.userIds.length,
111
+ reactedByMe: group.mine.length > 0,
112
+ userIds: group.userIds,
113
+ rows: group.rows,
114
+ mine: group.mine,
115
+ order: group.order,
116
+ }))
117
+ .filter((group) => group.count > 0)
118
+ .sort((a, b) => b.count - a.count || a.order - b.order)
119
+ .map(({ order: _order, ...group }) => group);
120
+ }
121
+
122
+ /**
123
+ * The groups a tap should produce before the write lands, so the pill moves on
124
+ * the tap rather than on the refetch. `pending` is a Map of emoji → boolean:
125
+ * true means "mine is being added", false "mine is being removed".
126
+ */
127
+ export function applyPendingReactions(groups, pending, userId) {
128
+ if (!pending || pending.size === 0) return groups;
129
+ const byEmoji = new Map(groups.map((group) => [group.emoji, { ...group }]));
130
+
131
+ for (const [emoji, adding] of pending) {
132
+ const group = byEmoji.get(emoji);
133
+ if (adding) {
134
+ if (!group) {
135
+ byEmoji.set(emoji, {
136
+ emoji,
137
+ count: 1,
138
+ reactedByMe: true,
139
+ userIds: userId ? [userId] : [],
140
+ rows: [],
141
+ mine: [],
142
+ });
143
+ } else if (!group.reactedByMe) {
144
+ group.reactedByMe = true;
145
+ group.count += 1;
146
+ group.userIds = userId ? [...group.userIds, userId] : group.userIds;
147
+ }
148
+ } else if (group?.reactedByMe) {
149
+ group.reactedByMe = false;
150
+ group.count = Math.max(0, group.count - 1);
151
+ group.userIds = group.userIds.filter((id) => id !== userId);
152
+ }
153
+ }
154
+
155
+ return [...byEmoji.values()]
156
+ .filter((group) => group.count > 0)
157
+ .sort((a, b) => b.count - a.count);
158
+ }
159
+
160
+ function readRelationId(value) {
161
+ if (!value) return null;
162
+ if (typeof value === "string") return value || null;
163
+ if (typeof value === "object" && typeof value.id === "string") {
164
+ return value.id || null;
165
+ }
166
+ return null;
167
+ }
package/dist/index.js CHANGED
@@ -101,6 +101,9 @@ export { useToast } from "./toast.js";
101
101
  // each platform's primitives, so a widget's modal escapes its container box
102
102
  // identically on the Player and the Expo export.
103
103
  export { Overlay } from "./overlay.js";
104
+ // sc-8104 — `<EmojiPicker>` is bound the same way, so the in-app picker that
105
+ // serves both composers and the reaction flow renders identically on both hosts.
106
+ export { EmojiPicker } from "./emoji-picker.js";
104
107
  // sc-6970 — formatted content. `<RichText>` renders the markdown subset and
105
108
  // `<MarkdownInput>` authors it; widgets have no HTML path on either host, so
106
109
  // these are the ONE way content with bold/headings/lists is written and shown.
@@ -158,6 +161,27 @@ export {
158
161
  clampSpacingScale,
159
162
  scaleSpacing,
160
163
  } from "./contract.js";
164
+ // sc-8104 — the curated emoji set ships WITH the SDK (plain string data, no
165
+ // dependency), and the reaction model is shared so Newsfeed and Chat group
166
+ // and migrate reactions identically.
167
+ export {
168
+ EMOJI_CATEGORIES,
169
+ EMOJI_LIST,
170
+ searchEmoji,
171
+ emojiForCategory,
172
+ isEmojiChar,
173
+ } from "./emoji-data.js";
174
+ export {
175
+ LEGACY_REACTION_EMOJI,
176
+ DEFAULT_REACTION_EMOJI,
177
+ readReactionEmoji,
178
+ isCountableReaction,
179
+ rowReactionEmoji,
180
+ isMyReactionRow,
181
+ groupReactions,
182
+ applyPendingReactions,
183
+ } from "./emoji-reactions.js";
184
+ export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
161
185
  export { normalizeLucideIconName } from "./lucideIconName.js";
162
186
  export { pressableLift } from "./interaction.js";
163
187
  export { styleGroup } from "./style-group.js";
@@ -97,6 +97,8 @@ export { useClipboard, ClipboardError } from "./clipboard.native.js";
97
97
  export { useToast } from "./toast.native.js";
98
98
  // sc-6607 — see the note on the web mirror in ./index.js.
99
99
  export { Overlay } from "./overlay.native.js";
100
+ // sc-8104 — see the note on the web mirror in ./index.js.
101
+ export { EmojiPicker } from "./emoji-picker.native.js";
100
102
  // sc-6970 — see the note on the web mirror in ./index.js.
101
103
  export { RichText } from "./richtext.native.js";
102
104
  export { MarkdownInput } from "./markdown-input.native.js";
@@ -152,6 +154,27 @@ export {
152
154
  clampSpacingScale,
153
155
  scaleSpacing,
154
156
  } from "./contract.js";
157
+ // sc-8104 — the curated emoji set ships WITH the SDK (plain string data, no
158
+ // dependency), and the reaction model is shared so Newsfeed and Chat group
159
+ // and migrate reactions identically.
160
+ export {
161
+ EMOJI_CATEGORIES,
162
+ EMOJI_LIST,
163
+ searchEmoji,
164
+ emojiForCategory,
165
+ isEmojiChar,
166
+ } from "./emoji-data.js";
167
+ export {
168
+ LEGACY_REACTION_EMOJI,
169
+ DEFAULT_REACTION_EMOJI,
170
+ readReactionEmoji,
171
+ isCountableReaction,
172
+ rowReactionEmoji,
173
+ isMyReactionRow,
174
+ groupReactions,
175
+ applyPendingReactions,
176
+ } from "./emoji-reactions.js";
177
+ export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
155
178
  export { normalizeLucideIconName } from "./lucideIconName.js";
156
179
  export { pressableLift } from "./interaction.native.js";
157
180
  export { styleGroup } from "./style-group.js";
package/dist/linter.cjs CHANGED
@@ -567,13 +567,13 @@ function _scopeRules(source, manifest) {
567
567
  }
568
568
 
569
569
  // sc-7991 — every send spends the workspace's AI credits, so the manifest has
570
- // to declare the capability. This gate is ADVISORY, not an authorization
571
- // boundary: unlike users/groups (minted into X-Widget-Scopes and checked
572
- // server-side), nothing re-checks ai.invoke at call time, so a widget that
573
- // evades this regex still reaches the endpoint. What actually bounds abuse is
574
- // the app-user JWT, the per-actor and per-tenant limiters and the AI quota
575
- // gate. The scope's real job is DISCLOSURE — it is what the marketplace
576
- // install consent screen shows.
570
+ // to declare the capability. This gate is still ADVISORY: the scope itself is
571
+ // not minted into X-Widget-Scopes, so a widget that evades this regex still
572
+ // reaches the endpoint, and the scope's real job is DISCLOSURE — it is what
573
+ // the marketplace install consent screen shows.
574
+ // sc-8128 — but the endpoint is no longer unguarded: a turn is refused unless
575
+ // the agent is bound on a page the caller can read. Abuse is bounded by that,
576
+ // the app-user JWT, the per-actor and per-tenant limiters and the AI quota.
577
577
  if (/\buseAgent\s*\(/.test(source)) {
578
578
  const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
579
579
  if (!invokes) {
@@ -1596,6 +1596,19 @@ function _bracedSpan(code, from) {
1596
1596
  return null;
1597
1597
  }
1598
1598
 
1599
+ // The non-escape branch excludes `\` so the two branches can never both
1600
+ // match the same character. With a bare `.`, escape pairs before a quote
1601
+ // the anchor then rejects backtrack exponentially — 60 characters of an
1602
+ // authored widget draft wedge the single-threaded API pod for ~60s, on the
1603
+ // AI-widget-agent publish vet, not the package-upload path (sc-8051, sc-8041).
1604
+ //
1605
+ // It excludes the line terminators too, because a character class — unlike
1606
+ // `.` — matches them, which would widen the literal past its own line.
1607
+ // An odd trailing backslash run therefore no longer matches; `_bracedSpan`
1608
+ // shares the escape rule and never yields one.
1609
+ const _TEXT_LITERAL_CHILD_RE =
1610
+ /^(["'`])((?:\\.|(?!\1)[^\\\r\n\u2028\u2029])*)\1$/;
1611
+
1599
1612
  // Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
1600
1613
  // NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
1601
1614
  // A braced expression carrying an identifier is left alone: it may already be
@@ -1618,8 +1631,7 @@ function _textLiteralOccurrences(codes, masks) {
1618
1631
  let text = null;
1619
1632
  if (code[i] === "{") {
1620
1633
  const inner = _bracedSpan(code, i);
1621
- const lit =
1622
- inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
1634
+ const lit = inner && inner.trim().match(_TEXT_LITERAL_CHILD_RE);
1623
1635
  // A template carrying `${…}` interpolates a value, so it is not a literal.
1624
1636
  if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
1625
1637
  } else {
package/dist/linter.js CHANGED
@@ -530,13 +530,13 @@ function _scopeRules(source, manifest) {
530
530
  }
531
531
 
532
532
  // sc-7991 — every send spends the workspace's AI credits, so the manifest has
533
- // to declare the capability. This gate is ADVISORY, not an authorization
534
- // boundary: unlike users/groups (minted into X-Widget-Scopes and checked
535
- // server-side), nothing re-checks ai.invoke at call time, so a widget that
536
- // evades this regex still reaches the endpoint. What actually bounds abuse is
537
- // the app-user JWT, the per-actor and per-tenant limiters and the AI quota
538
- // gate. The scope's real job is DISCLOSURE — it is what the marketplace
539
- // install consent screen shows.
533
+ // to declare the capability. This gate is still ADVISORY: the scope itself is
534
+ // not minted into X-Widget-Scopes, so a widget that evades this regex still
535
+ // reaches the endpoint, and the scope's real job is DISCLOSURE — it is what
536
+ // the marketplace install consent screen shows.
537
+ // sc-8128 — but the endpoint is no longer unguarded: a turn is refused unless
538
+ // the agent is bound on a page the caller can read. Abuse is bounded by that,
539
+ // the app-user JWT, the per-actor and per-tenant limiters and the AI quota.
540
540
  if (/\buseAgent\s*\(/.test(source)) {
541
541
  const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
542
542
  if (!invokes) {
@@ -1667,6 +1667,19 @@ function _bracedSpan(code, from) {
1667
1667
  return null;
1668
1668
  }
1669
1669
 
1670
+ // The non-escape branch excludes `\` so the two branches can never both
1671
+ // match the same character. With a bare `.`, escape pairs before a quote
1672
+ // the anchor then rejects backtrack exponentially — 60 characters of an
1673
+ // authored widget draft wedge the single-threaded API pod for ~60s, on the
1674
+ // AI-widget-agent publish vet, not the package-upload path (sc-8051, sc-8041).
1675
+ //
1676
+ // It excludes the line terminators too, because a character class — unlike
1677
+ // `.` — matches them, which would widen the literal past its own line.
1678
+ // An odd trailing backslash run therefore no longer matches; `_bracedSpan`
1679
+ // shares the escape rule and never yields one.
1680
+ const _TEXT_LITERAL_CHILD_RE =
1681
+ /^(["'`])((?:\\.|(?!\1)[^\\\r\n\u2028\u2029])*)\1$/;
1682
+
1670
1683
  // Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
1671
1684
  // NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
1672
1685
  // A braced expression carrying an identifier is left alone: it may already be
@@ -1689,8 +1702,7 @@ function _textLiteralOccurrences(codes, masks) {
1689
1702
  let text = null;
1690
1703
  if (code[i] === "{") {
1691
1704
  const inner = _bracedSpan(code, i);
1692
- const lit =
1693
- inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
1705
+ const lit = inner && inner.trim().match(_TEXT_LITERAL_CHILD_RE);
1694
1706
  // A template carrying `${…}` interpolates a value, so it is not a literal.
1695
1707
  if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
1696
1708
  } else {
@@ -99,6 +99,11 @@ function normaliseThemeComponents(raw) {
99
99
  }
100
100
 
101
101
 
102
+ // Long enough for a hex, an enum value or a font name; short enough that a
103
+ // hand-edited theme_config cannot smuggle a payload into every widget.
104
+ // Named because the Mason plan gate prints it when it rejects a value.
105
+ const MAX_STYLE_STRING_LENGTH = 64;
106
+
102
107
  // Values are assigned onto plain object literals, so `__proto__` would set a
103
108
  // PROTOTYPE rather than a style field. Refused at every level.
104
109
  const UNSAFE_STYLE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
@@ -125,9 +130,9 @@ function coerceWidgetStyleValue(value, depth = 0) {
125
130
  const { maxValueDepth, maxValueEntries } = CONTRACT.themeWidgetStyles;
126
131
  if (typeof value === "string") {
127
132
  const trimmed = value.trim();
128
- // Long enough for a hex, an enum value or a font name; short enough that a
129
- // hand-edited theme_config cannot smuggle a payload into every widget.
130
- return trimmed && trimmed.length <= 64 ? trimmed : undefined;
133
+ return trimmed && trimmed.length <= MAX_STYLE_STRING_LENGTH
134
+ ? trimmed
135
+ : undefined;
131
136
  }
132
137
  if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
133
138
  if (typeof value === "boolean") return value;
@@ -393,4 +398,4 @@ function applyThemeComponentStyle(manifestId, theme, props, styleSchema) {
393
398
  return { ...base, style: { ...themed, ...authored } };
394
399
  }
395
400
 
396
- module.exports = { normaliseThemeComponents, normaliseWidgetStyleFields, normaliseWidgetStyles, applyThemeComponentStyle };
401
+ module.exports = { normaliseThemeComponents, normaliseWidgetStyleFields, normaliseWidgetStyles, applyThemeComponentStyle, styleValueBytes, MAX_STYLE_STRING_LENGTH };