diff --git a/LICENSE b/LICENSE
index 5a2038e955..5b18900a3c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -253,9 +253,9 @@ License: MIT
Maka includes an ejected adaptation of Astryx ChatReasoning v0.1.9 in
`packages/ui/src/astryx-chat-reasoning.tsx` and generated theme artifacts in
-`apps/desktop/src/renderer/astryx-theme/`, based on Astryx theme-neutral v0.6.1.
+`apps/desktop/src/renderer/astryx-theme/`, based on Astryx theme-neutral v0.6.2.
Maka also redistributes
-`patches/@astryxdesign+core+0.6.1.patch`, which modifies the MIT-licensed Astryx
+`patches/@astryxdesign+core+0.6.2.patch`, which modifies the MIT-licensed Astryx
core package. The following MIT License applies to that material:
MIT License
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index f4ddef4303..086784adc4 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -73,8 +73,8 @@
},
"devDependencies": {
"@ant-design/icons-svg": "4.6.0",
- "@astryxdesign/core": "0.6.1",
- "@astryxdesign/theme-neutral": "0.6.1",
+ "@astryxdesign/core": "0.6.2",
+ "@astryxdesign/theme-neutral": "0.6.2",
"@babel/parser": "7.29.7",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt
index 7582543b57..21b1637472 100644
--- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt
+++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt
@@ -677,7 +677,7 @@ SOFTWARE.
================================================================================
-Package: @astryxdesign/core@0.6.1
+Package: @astryxdesign/core@0.6.2
Declared license: MIT
Selected license: MIT
Repository: git+https://github.com/facebook/astryx.git#packages/core
@@ -707,7 +707,7 @@ SOFTWARE.
================================================================================
-Package: @astryxdesign/theme-neutral@0.6.1
+Package: @astryxdesign/theme-neutral@0.6.2
Declared license: MIT
Selected license: MIT
Repository: git+https://github.com/facebook/astryx.git#packages/themes/neutral
diff --git a/apps/desktop/src/renderer/astryx-theme/maka.css b/apps/desktop/src/renderer/astryx-theme/maka.css
index 5b7e02ac25..c998cbdde9 100644
--- a/apps/desktop/src/renderer/astryx-theme/maka.css
+++ b/apps/desktop/src/renderer/astryx-theme/maka.css
@@ -2,7 +2,7 @@
* @generated by `astryx theme build` — do not edit manually.
* Source: src/renderer/astryx-theme/makaTheme.ts
* Command: astryx theme build src/renderer/astryx-theme/makaTheme.ts --out src/renderer/astryx-theme/maka.css
- * CLI: @astryxdesign/cli@0.6.1
+ * CLI: @astryxdesign/cli@0.6.2
* Core: @astryxdesign/core@0.1.0
*/
diff --git a/apps/desktop/src/renderer/astryx-theme/maka.d.ts b/apps/desktop/src/renderer/astryx-theme/maka.d.ts
index 6fa29d6d71..9018df6d24 100644
--- a/apps/desktop/src/renderer/astryx-theme/maka.d.ts
+++ b/apps/desktop/src/renderer/astryx-theme/maka.d.ts
@@ -2,7 +2,7 @@
* @generated by `astryx theme build` — do not edit manually.
* Source: src/renderer/astryx-theme/makaTheme.ts
* Command: astryx theme build src/renderer/astryx-theme/makaTheme.ts --out src/renderer/astryx-theme/maka.css
- * CLI: @astryxdesign/cli@0.6.1
+ * CLI: @astryxdesign/cli@0.6.2
* Core: @astryxdesign/core@0.1.0
*/
diff --git a/apps/desktop/src/renderer/astryx-theme/maka.js b/apps/desktop/src/renderer/astryx-theme/maka.js
index 0745cb487f..63168f40c8 100644
--- a/apps/desktop/src/renderer/astryx-theme/maka.js
+++ b/apps/desktop/src/renderer/astryx-theme/maka.js
@@ -2,7 +2,7 @@
* @generated by `astryx theme build` — do not edit manually.
* Source: src/renderer/astryx-theme/makaTheme.ts
* Command: astryx theme build src/renderer/astryx-theme/makaTheme.ts --out src/renderer/astryx-theme/maka.css
- * CLI: @astryxdesign/cli@0.6.1
+ * CLI: @astryxdesign/cli@0.6.2
* Core: @astryxdesign/core@0.1.0
*/
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index fa000af5d8..5bb6bb5d07 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -2,7 +2,7 @@
Generated by `scripts/generate-astryx-surface-inventory.mjs`.
Each row is one on-disk product surface file. Regenerated inventory must stay in sync with disk (coverage gate).
-Generated against `@astryxdesign/core@0.6.1` (195 component exports).
+Generated against `@astryxdesign/core@0.6.2` (195 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
diff --git a/package-lock.json b/package-lock.json
index c8d825607c..1371a7bfd0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,8 +24,8 @@
],
"devDependencies": {
"@ai-sdk/provider-utils": "5.0.40",
- "@astryxdesign/cli": "0.6.1",
- "@astryxdesign/core": "0.6.1",
+ "@astryxdesign/cli": "0.6.2",
+ "@astryxdesign/core": "0.6.2",
"@biomejs/biome": "2.5.13",
"@electron/asar": "4.3.0",
"@types/node": "^26.5.1",
@@ -63,8 +63,8 @@
},
"devDependencies": {
"@ant-design/icons-svg": "4.6.0",
- "@astryxdesign/core": "0.6.1",
- "@astryxdesign/theme-neutral": "0.6.1",
+ "@astryxdesign/core": "0.6.2",
+ "@astryxdesign/theme-neutral": "0.6.2",
"@babel/parser": "7.29.7",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
@@ -638,9 +638,9 @@
}
},
"node_modules/@astryxdesign/cli": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.6.1.tgz",
- "integrity": "sha512-m7AYFZvBnFPiTQM9iBLB8qeX7Mx+0433qZ8EU8Z6mLzAeWNfSKItfbqY4llA8PyvIbFjuP4jtAxZ1UeEFtjjPg==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.6.2.tgz",
+ "integrity": "sha512-7M3jqDRsFqsIyjMDJNHUaoJ9pcYa5e+MDrF/wMx40YAinkzRYonNy55mvcryiB6EcFrpmusrlWKspVvPDHd6QA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -715,9 +715,9 @@
}
},
"node_modules/@astryxdesign/core": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.6.1.tgz",
- "integrity": "sha512-s58U8AMEkbzX3hmDcXRAMBEttrGoj3vs416Azexw+Os7VpL3D7jU++bosbm6ehsSJN51g/uuW5CPgq+x1X+oYA==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@astryxdesign/core/-/core-0.6.2.tgz",
+ "integrity": "sha512-QzRE4Dn4hePtLL2q8KgdBcPKQVlxg316XIHez087iTSa3dkyY3429Zi23G35yCDrRHn6zoAMpLLLQp12s4FVFg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -730,16 +730,16 @@
}
},
"node_modules/@astryxdesign/theme-neutral": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/@astryxdesign/theme-neutral/-/theme-neutral-0.6.1.tgz",
- "integrity": "sha512-91VPmhkWDelA4+Y9q7ifZoQHihfVpWTjbld5dcFGE9lZKxD+0RJrKH4Of/YJX6gaTDTUYtgHUJlWRd1pmrWU1g==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@astryxdesign/theme-neutral/-/theme-neutral-0.6.2.tgz",
+ "integrity": "sha512-DI80GzPqrcnMXHMGzNB8SFJSbTKOvU4FBtNANg8g/NexY4bHrmpOLzlUomGxuLdmDH/HCwVX6BRDK8s96OKJjA==",
"dev": true,
"license": "MIT",
"dependencies": {
"lucide-react": "^1.18.0"
},
"peerDependencies": {
- "@astryxdesign/core": "0.6.1",
+ "@astryxdesign/core": "0.6.2",
"react": ">=19"
}
},
@@ -17356,7 +17356,7 @@
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
- "@astryxdesign/core": "0.6.1",
+ "@astryxdesign/core": "0.6.2",
"@maka/core": "0.1.0",
"clsx": "^2.1.1",
"katex": "0.18.7",
diff --git a/package.json b/package.json
index bc014959a2..719730f502 100644
--- a/package.json
+++ b/package.json
@@ -111,8 +111,8 @@
},
"devDependencies": {
"@ai-sdk/provider-utils": "5.0.40",
- "@astryxdesign/cli": "0.6.1",
- "@astryxdesign/core": "0.6.1",
+ "@astryxdesign/cli": "0.6.2",
+ "@astryxdesign/core": "0.6.2",
"@biomejs/biome": "2.5.13",
"@electron/asar": "4.3.0",
"@types/node": "^26.5.1",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index a82ff47e4a..85da464e78 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -28,7 +28,7 @@
"test:dist": "node --test \"dist/**/*.test.js\""
},
"dependencies": {
- "@astryxdesign/core": "0.6.1",
+ "@astryxdesign/core": "0.6.2",
"@maka/core": "0.1.0",
"clsx": "^2.1.1",
"katex": "0.18.7",
diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts
index 83e69eaab5..eca7a03013 100644
--- a/packages/ui/src/__tests__/markdown-body.test.ts
+++ b/packages/ui/src/__tests__/markdown-body.test.ts
@@ -199,6 +199,78 @@ it('renders multiline display math outside code for both supported delimiters',
}
});
+it('renders display math inside blockquotes and list items', () => {
+ for (const text of ['> \\[ x + 1 \\]', '- \\[ x + 1 \\]']) {
+ const markup = renderToStaticMarkup(createElement(MarkdownBody, { text }));
+ assert.match(markup, /class="maka-math maka-math-display"/);
+ assert.match(markup, /class="katex-display"/);
+ assert.doesNotMatch(markup, /\\\[/);
+ }
+});
+
+it('promotes a mid-paragraph $$…$$ span to display math', () => {
+ const markup = renderToStaticMarkup(createElement(MarkdownBody, {
+ text: 'Before $$x + 1$$ after',
+ }));
+
+ assert.match(markup, /Before/);
+ assert.match(markup, /after/);
+ assert.match(markup, /class="maka-math maka-math-display"/);
+ assert.doesNotMatch(markup, /\$\$/);
+});
+
+it('keeps `$` inside link destinations literal while prose stays dollar-escaped', () => {
+ const markup = renderToStaticMarkup(createElement(LocaleProvider, {
+ locale: 'en',
+ children: createElement(MarkdownBody, {
+ text: '[priced](https://example.com/?$from$=$to$) and $z$ here',
+ }),
+ }));
+
+ assert.match(markup, /href="https:\/\/example\.com\/\?\$from\$=\$to\$"/);
+ assert.match(markup, /and \$z\$ here/);
+ assert.doesNotMatch(markup, /class="maka-math/);
+});
+
+it('renders inline math glued to digits and formulas holding escaped dollars', () => {
+ for (const text of ['v2\\(x + 1\\)3 done', 'cost \\(a \\$ b\\) end']) {
+ const markup = renderToStaticMarkup(createElement(MarkdownBody, { text }));
+ assert.match(markup, /class="maka-math maka-math-inline"/);
+ assert.doesNotMatch(markup, /\\\(|\\\)/);
+ }
+});
+
+it('leaves no zero-width separators around inline math with benign neighbors', () => {
+ for (const text of [
+ 'the value \\(x\\) is fine',
+ 'ends with \\(x\\)',
+ ]) {
+ const markup = renderToStaticMarkup(createElement(MarkdownBody, { text }));
+ assert.match(markup, /class="maka-math maka-math-inline"/);
+ assert.doesNotMatch(markup, /\u200B/, text);
+ }
+});
+
+it('keeps adjacent inline formulas parseable through a separator', () => {
+ const markup = renderToStaticMarkup(createElement(MarkdownBody, {
+ text: 'compare \\(x\\)\\(y\\) here',
+ }));
+ assert.equal(
+ (markup.match(/class="maka-math maka-math-inline"/g) ?? []).length,
+ 2,
+ );
+});
+
+it('keeps display math inside a table cell without splitting the row', () => {
+ const markup = renderToStaticMarkup(createElement(MarkdownBody, {
+ text: '| a | b |\n| --- | --- |\n| \\(x + 1\\) | \\[y + 2\\] |',
+ }));
+
+ assert.match(markup, /
{
const bodies = [
['x + 1', '', 'y + 2'],
diff --git a/packages/ui/src/__tests__/streaming-text.test.tsx b/packages/ui/src/__tests__/streaming-text.test.tsx
index fa2a97f2a3..27fe4a993f 100644
--- a/packages/ui/src/__tests__/streaming-text.test.tsx
+++ b/packages/ui/src/__tests__/streaming-text.test.tsx
@@ -167,6 +167,30 @@ test('keeps settled math stable while the live Markdown tail grows and flushes',
await act(() => root.unmount());
});
+test('keeps inline math when a digit chunk lands after the committed closer', async () => {
+ const { container, root } = streamingRoot();
+
+ function render(text: string) {
+ return root.render(
+
+
+ ,
+ );
+ }
+
+ await act(() => render('v2 \\(x + 1\\)'));
+ assert.ok(container.querySelector('.maka-math .katex'));
+
+ await act(() => render('v2 \\(x + 1\\)3'));
+ assert.ok(container.querySelector('.maka-math .katex'));
+
+ await act(() => render('v2 \\(x + 1\\)3 done'));
+ assert.ok(container.querySelector('.maka-math .katex'));
+ assert.match(container.textContent ?? '', /3 done/);
+
+ await act(() => root.unmount());
+});
+
test('never exposes math transport syntax as a formula crosses the display cursor', async () => {
const frames: FrameRequestCallback[] = [];
const { container, root } = streamingRoot((callback) => {
diff --git a/packages/ui/src/astryx-i18n.tsx b/packages/ui/src/astryx-i18n.tsx
index 08f0e06fa2..ae9820895a 100644
--- a/packages/ui/src/astryx-i18n.tsx
+++ b/packages/ui/src/astryx-i18n.tsx
@@ -129,6 +129,8 @@ function chineseOverrides(locale: 'zh-CN' | 'zh-TW', astryx: typeof ASTRYX_COPY_
'@astryx.selector.placeholder': form.selectPlaceholder,
'@astryx.selector.clearLabel': form.clear,
'@astryx.numberInput.clearLabel': form.clear,
+ // Fallback accessible name of every unlabeled (0.6.2).
+ '@astryx.spinner.loading': shared.primitives.loading,
// App shell — the skip link is always the first focusable control, so an
// untranslated fallback pollutes every Chinese Computer Use observation.
diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx
index 2fd5a8c14d..af17e36ee6 100644
--- a/packages/ui/src/markdown-body.tsx
+++ b/packages/ui/src/markdown-body.tsx
@@ -48,7 +48,7 @@ import { getSharedUiCopy } from './shared-ui-copy.js';
import { MermaidDiagram } from './mermaid-diagram.js';
import {
createMarkdownMathCache,
- MARKDOWN_MATH_PLUGINS,
+ MarkdownMath,
prepareMarkdownMath,
} from './markdown-math.js';
import { parseAttachmentResourceRef } from '@maka/core/attachments';
@@ -57,6 +57,7 @@ import { useAttachmentImageSource } from './attachment-image.js';
const BASE_MARKDOWN_COMPONENTS = {
link: MarkdownLink,
image: MarkdownImage,
+ math: MarkdownMath,
};
export const MAX_AUTOMATIC_MERMAID_DIAGRAMS = 3;
@@ -194,7 +195,6 @@ export function MarkdownBody(props: {
// the one combination neither half of the argument asks for.
density={density}
components={components}
- inlinePlugins={MARKDOWN_MATH_PLUGINS}
isStreaming={props.streaming}
settledText={props.settledText}
transformSource={transformMathSource}
diff --git a/packages/ui/src/markdown-math.tsx b/packages/ui/src/markdown-math.tsx
index 74f5e9f19c..7a7ba1c077 100644
--- a/packages/ui/src/markdown-math.tsx
+++ b/packages/ui/src/markdown-math.tsx
@@ -18,17 +18,35 @@
*/
import katex from 'katex';
-import type { MarkdownInlinePlugin } from '@astryxdesign/core/Markdown';
-const TOKEN_START = '\uE000MAKA_MATH:';
-const TOKEN_END = '\uE001';
-const TOKEN_PATTERN = /\uE000MAKA_MATH:([012]):([0-9a-f]+)\uE001/g;
-const LITERAL_TOKEN_PATTERN = /^\uE000MAKA_MATH:[012]:[0-9a-f]+\uE001/;
+/**
+ * Renders upstream `components.math` nodes through KaTeX. The delimiter-free
+ * `value` arrives exactly as the expression appeared in the source.
+ */
+export function MarkdownMath(props: { value: string; display: 'inline' | 'block' }) {
+ const html = katex.renderToString(props.value, {
+ displayMode: props.display === 'block',
+ output: 'htmlAndMathml',
+ strict: 'warn',
+ throwOnError: false,
+ trust: false,
+ });
+ return (
+
+ );
+}
/**
* Discardable derived state owned by one MarkdownBody mount. Capacity is two
* strings no larger than that component's currently displayed source and its
- * transport form; a rewrite resets both and unmounting drops the cache.
+ * translated form; a rewrite resets both and unmounting drops the cache.
*/
export interface MarkdownMathCache {
source: string;
@@ -47,50 +65,44 @@ export function prepareMarkdownMath(
): string {
// The caller currently supplies a full string rather than an append token,
// so proving that a rewrite did not occur requires this prefix check. It
- // keeps the JavaScript lexer on the changing tail; it does not make the
- // full-string identity check itself incremental.
+ // keeps the scanner on the changing tail; it does not make the full-string
+ // identity check itself incremental.
const extendsPrevious = source.startsWith(cache.source);
const sourceStart = extendsPrevious ? cache.safeSourceEnd : 0;
const textStart = extendsPrevious ? cache.safeTextEnd : 0;
- const protectedTail = protectMarkdownMath(
+ const translatedTail = translateMarkdownMath(
source.slice(sourceStart),
sourceStart === 0 || source[sourceStart - 1] === '\n',
+ cache.text[textStart - 1],
);
- const text = `${extendsPrevious ? cache.text.slice(0, textStart) : ''}${protectedTail.text}`;
+ const text = `${extendsPrevious ? cache.text.slice(0, textStart) : ''}${translatedTail.text}`;
cache.source = source;
cache.text = text;
- cache.safeSourceEnd = sourceStart + protectedTail.safeSourceEnd;
- cache.safeTextEnd = textStart + protectedTail.safeTextEnd;
+ cache.safeSourceEnd = sourceStart + translatedTail.safeSourceEnd;
+ cache.safeTextEnd = textStart + translatedTail.safeTextEnd;
return text;
}
-export const MARKDOWN_MATH_PLUGINS = [{
- pattern: TOKEN_PATTERN,
- render: (match, key) => {
- const formula = decodeFormula(match[2] ?? '');
- if (match[1] === '2') return formula;
- const displayMode = match[1] === '1';
- const html = katex.renderToString(formula, {
- displayMode,
- output: 'htmlAndMathml',
- strict: 'warn',
- throwOnError: false,
- trust: false,
- });
- return (
-
- );
- },
-}] satisfies MarkdownInlinePlugin[];
+const ZWSP = '\u200B';
+const WORD_CHAR = /[\w$]/;
-function protectMarkdownMath(source: string, startsAtLineStart = true): {
+/**
+ * Translate Maka's math delimiters into the upstream grammar and neutralize
+ * bare `$` so prose never forms accidental inline math.
+ *
+ * Maka accepts `\(…\)` inline, `\[…\]` and `$$…$$` display math. Astryx parses
+ * `$…$` inline and `$$…$$` blocks only, so the translation emits `$…$` for
+ * inline math and newline-separated `$$` lines for display math. Every
+ * remaining bare `$` outside code and link destinations is escaped so dollar
+ * amounts, shell variables, and `$x$` prose stay literal; upstream's own
+ * inline-math grammar only ever sees delimiters this transform emitted.
+ */
+function translateMarkdownMath(
+ source: string,
+ startsAtLineStart = true,
+ priorTextChar?: string,
+): {
text: string;
safeSourceEnd: number;
safeTextEnd: number;
@@ -122,19 +134,6 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): {
continue;
}
- const literalToken = readLiteralToken(source, index);
- if (literalToken?.kind === 'pending') {
- text += source.slice(index);
- break;
- }
- if (literalToken?.kind === 'match') {
- text += transportToken(literalToken.source, '2');
- index = literalToken.end;
- atLineStart = false;
- markSafe();
- continue;
- }
-
if (source[index] === '`') {
let runEnd = index + 1;
while (source[runEnd] === '`') runEnd++;
@@ -155,10 +154,47 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): {
continue;
}
+ // Destinations take `$` verbatim: escaping there would reach the href,
+ // where `\$` does not round-trip back to `$`.
+ const destination = readLinkDestination(source, index);
+ if (destination !== undefined) {
+ text += source.slice(index, destination);
+ index = destination;
+ atLineStart = false;
+ markSafe();
+ continue;
+ }
+
+ const autolink = readAutolink(source, index);
+ if (autolink !== undefined) {
+ text += source.slice(index, autolink);
+ index = autolink;
+ atLineStart = false;
+ markSafe();
+ continue;
+ }
+
+ const bareUrl = readBareUrl(source, index);
+ if (bareUrl !== undefined) {
+ text += source.slice(index, bareUrl);
+ index = bareUrl;
+ atLineStart = false;
+ markSafe();
+ continue;
+ }
+
+ const reference = atLineStart ? readReferenceDefinition(source, index) : undefined;
+ if (reference !== undefined) {
+ text += source.slice(index, reference);
+ index = reference;
+ markSafe();
+ continue;
+ }
+
const delimited =
- readDelimitedMath(source, index, '\\(', '\\)', false, false)
- ?? readDelimitedMath(source, index, '\\[', '\\]', true, true)
- ?? readDelimitedMath(source, index, '$$', '$$', true, true);
+ readDelimitedMath(source, index, '\\(', '\\)', false)
+ ?? readDelimitedMath(source, index, '\\[', '\\]', true)
+ ?? readDelimitedMath(source, index, '$$', '$$', true);
if (delimited?.kind === 'pending') {
text += source.slice(index, delimited.end);
index = delimited.end;
@@ -167,15 +203,40 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): {
continue;
}
if (delimited?.kind === 'match') {
- text += mathToken(delimited.formula, delimited.displayMode);
+ // A `$$`-line block would split a table row, so display math on a line
+ // containing `|` degrades to an inline span inside the cell.
+ const displayInline = delimited.display
+ && !delimited.formula.includes('\n')
+ && lineHasPipe(source, index);
+ if (delimited.display && !displayInline) {
+ text += displayMathSource(delimited.formula, source, index);
+ index = delimited.end;
+ atLineStart = source[index - 1] === '\n';
+ markSafe();
+ continue;
+ }
+ // At the source tail the character after the formula is unknown, so
+ // the span stays uncommitted: the next chunk rescans from the opener
+ // and decides the closing guard with the neighbor in hand.
+ const next = source[delimited.end];
+ if (next === undefined) markSafe();
+ text += inlineMathSource(
+ delimited.formula,
+ text.length > 0 ? text[text.length - 1] : priorTextChar,
+ next !== undefined && nextBreaksInlineClose(source, delimited.end),
+ );
index = delimited.end;
- atLineStart = false;
- markSafe();
+ atLineStart = source[index - 1] === '\n';
+ if (next !== undefined) markSafe();
continue;
}
const character = source[index] ?? '';
- text += character;
+ if (character === '$' && !isEscaped(source, index)) {
+ text += '\\$';
+ } else {
+ text += character;
+ }
index++;
atLineStart = character === '\n';
if (
@@ -189,6 +250,142 @@ function protectMarkdownMath(source: string, startsAtLineStart = true): {
return { text, safeSourceEnd, safeTextEnd };
}
+/**
+ * Emit upstream `$…$` for a `\(…\)` formula. A zero-width space separates a
+ * delimiter from a neighbor that would trip upstream's guards — a digit or
+ * `$` hugging either side makes the whole span literal — and a backslash
+ * neighbor is doubled so it survives its own escape. The guards only exist
+ * when the neighbor is actually hostile; an unconditional one would leave
+ * invisible characters in copied text.
+ */
+function inlineMathSource(
+ formula: string,
+ previous: string | undefined,
+ nextBreaksClose: boolean,
+): string {
+ const prefix = previous === '\\'
+ ? '\\'
+ : previous !== undefined && /[\d$]/.test(previous)
+ ? ZWSP
+ : '';
+ return `${prefix}$${escapeFormulaDollars(formula)}$${nextBreaksClose ? ZWSP : ''}`;
+}
+
+/**
+ * Whether the token at `index` emits a digit or a `$` — the only neighbors
+ * that invalidate a just-emitted `$…$` closing delimiter. A pending `$$`
+ * opener passes through raw, so it counts even before its closer arrives.
+ */
+function nextBreaksInlineClose(source: string, index: number): boolean {
+ if (/\d/.test(source[index])) return true;
+ const next =
+ readDelimitedMath(source, index, '\\(', '\\)', false)
+ ?? readDelimitedMath(source, index, '\\[', '\\]', true)
+ ?? readDelimitedMath(source, index, '$$', '$$', true);
+ if (!next) return false;
+ if (next.kind === 'pending') return source[index] === '$';
+ return !next.display || (!next.formula.includes('\n') && lineHasPipe(source, index));
+}
+
+/**
+ * Escape bare `$` inside a formula so they cannot terminate the emitted
+ * `$…$` early. A `$` already preceded by an odd run of backslashes is an
+ * escaped dollar and stays `\$`.
+ */
+function escapeFormulaDollars(formula: string): string {
+ let out = '';
+ for (let i = 0; i < formula.length; i++) {
+ if (formula[i] === '$' && !isEscaped(formula, i)) out += '\\';
+ out += formula[i];
+ }
+ return out;
+}
+
+/** Whether the delimiter's enclosing source line contains a `|` — i.e. it may
+ * be a table row that a `$$` line would split apart. */
+function lineHasPipe(source: string, index: number): boolean {
+ const lineStart = source.lastIndexOf('\n', index - 1) + 1;
+ const lineEnd = source.indexOf('\n', index);
+ return source.slice(lineStart, lineEnd < 0 ? source.length : lineEnd).includes('|');
+}
+
+/**
+ * Emit upstream `$$`-line display math. Each emitted line repeats the quote
+ * and indentation context of the line the opener sat on, so `> \[…\]` and
+ * `- \[…\]` stay inside their container. Newlines around the block split any
+ * host paragraph; upstream only recognizes `$$` lines it owns outright.
+ */
+function displayMathSource(
+ formula: string,
+ source: string,
+ index: number,
+): string {
+ const lineStart = source.lastIndexOf('\n', index - 1) + 1;
+ const quotePrefix = /^ {0,3}(?:> ?)*/.exec(source.slice(lineStart, index))?.[0] ?? '';
+ const margin = quotePrefix + ' '.repeat(index - lineStart - quotePrefix.length);
+ const body = formula.replaceAll('\n', `\n${margin}`);
+ return `\n${margin}$$\n${margin}${body}\n${margin}$$\n${margin}`;
+}
+
+/**
+ * Span of `](destination …)`/`` and `][label]` reference
+ * links. A destination ends at the `)` that returns paren depth to zero; an
+ * unclosed one is prose and gets the ordinary `$` treatment.
+ */
+function readLinkDestination(source: string, index: number): number | undefined {
+ if (source[index] !== ']') return undefined;
+ if (source[index + 1] === '[') {
+ const close = source.indexOf(']', index + 2);
+ return close >= 0 && !source.slice(index + 2, close).includes('\n')
+ ? close + 1
+ : undefined;
+ }
+ if (source[index + 1] !== '(') return undefined;
+ let depth = 0;
+ for (let i = index + 1; i < source.length; i++) {
+ const char = source[i];
+ if (char === '\\') {
+ i++;
+ } else if (char === '(') {
+ depth++;
+ } else if (char === ')') {
+ depth--;
+ if (depth === 0) return i + 1;
+ } else if (char === '\n' && depth === 1) {
+ return undefined;
+ }
+ }
+ return undefined;
+}
+
+/** `` and `` autolinks take `$` verbatim. */
+function readAutolink(source: string, index: number): number | undefined {
+ if (source[index] !== '<') return undefined;
+ const match = /^<(?:[a-zA-Z][a-zA-Z0-9+.-]*:|[^<>\s@]+@[^<>\s@]+)[^<>\s]*>/.exec(
+ source.slice(index),
+ );
+ return match ? index + match[0].length : undefined;
+}
+
+/**
+ * Bare URLs and addresses that upstream autolinks (`https?://`, `www.`,
+ * `mailto:`, `user@host.tld`), only when not continuing a word.
+ */
+function readBareUrl(source: string, index: number): number | undefined {
+ const previous = source[index - 1];
+ if (previous !== undefined && WORD_CHAR.test(previous)) return undefined;
+ const match = /^(?:https?:\/\/|www\.|mailto:|[\w.+-]+@[\w-]+(?:\.[\w-]+)+)\S*/.exec(
+ source.slice(index),
+ );
+ return match ? index + match[0].length : undefined;
+}
+
+/** `[label]: destination` reference lines take `$` verbatim. */
+function readReferenceDefinition(source: string, index: number): number | undefined {
+ const match = /^ {0,3}\[[^\]\n]+\]:[^\n]*/.exec(source.slice(index));
+ return match ? index + match[0].length : undefined;
+}
+
function readFence(
source: string,
index: number,
@@ -225,43 +422,14 @@ function readFence(
return { kind: 'match', end: source.length, closed: false };
}
-function readLiteralToken(
- source: string,
- index: number,
-):
- | { kind: 'match'; source: string; end: number }
- | { kind: 'pending' }
- | undefined {
- if (source[index] !== TOKEN_START[0]) return undefined;
- if (!source.startsWith(TOKEN_START, index)) {
- const tail = source.slice(index);
- return tail.length < TOKEN_START.length && TOKEN_START.startsWith(tail)
- ? { kind: 'pending' }
- : undefined;
- }
- const tokenEnd = source.indexOf(TOKEN_END, index + TOKEN_START.length);
- if (tokenEnd < 0) {
- const payload = source.slice(index + TOKEN_START.length);
- return /^(?:[012](?::[0-9a-f]*)?)?$/.test(payload)
- ? { kind: 'pending' }
- : undefined;
- }
- const candidate = source.slice(index, tokenEnd + TOKEN_END.length);
- const match = LITERAL_TOKEN_PATTERN.exec(candidate);
- if (!match) return undefined;
- const token = match[0];
- return { kind: 'match', source: token, end: index + token.length };
-}
-
function readDelimitedMath(
source: string,
index: number,
opening: string,
closing: string,
- displayMode: boolean,
allowNewlines: boolean,
):
- | { kind: 'match'; formula: string; displayMode: boolean; end: number }
+ | { kind: 'match'; formula: string; display: boolean; end: number }
| { kind: 'pending'; end: number }
| undefined {
if (!source.startsWith(opening, index)) return undefined;
@@ -280,7 +448,13 @@ function readDelimitedMath(
}
const formula = rawFormula.trim();
if (formula === '') return undefined;
- return { kind: 'match', formula, displayMode, end: close + closing.length };
+ // A `$` hugging an emitted inline delimiter trips upstream's `$$` guard and
+ // leaves the span literal; display math takes lines verbatim instead.
+ const display = closing !== '\\)';
+ if (!display && (formula.startsWith('$') || formula.endsWith('$'))) {
+ return undefined;
+ }
+ return { kind: 'match', formula, display, end: close + closing.length };
}
function findPendingFenceBoundary(source: string, from: number): number {
@@ -290,26 +464,12 @@ function findPendingFenceBoundary(source: string, from: number): number {
return fence ? fence.index + (source[fence.index] === '\n' ? 1 : 0) : -1;
}
-function mathToken(formula: string, displayMode: boolean): string {
- return transportToken(formula, displayMode ? '1' : '0');
-}
-
-function transportToken(value: string, kind: '0' | '1' | '2'): string {
- return `${TOKEN_START}${kind}:${encodeFormula(value)}${TOKEN_END}`;
-}
-
-function encodeFormula(formula: string): string {
- let encoded = '';
- for (const byte of new TextEncoder().encode(formula)) {
- encoded += byte.toString(16).padStart(2, '0');
- }
- return encoded;
-}
-
-function decodeFormula(encoded: string): string {
- const bytes = new Uint8Array(encoded.length / 2);
- for (let index = 0; index < bytes.length; index++) {
- bytes[index] = Number.parseInt(encoded.slice(index * 2, index * 2 + 2), 16);
+function isEscaped(source: string, index: number): boolean {
+ let backslashes = 0;
+ let i = index - 1;
+ while (i >= 0 && source[i] === '\\') {
+ backslashes++;
+ i--;
}
- return new TextDecoder().decode(bytes);
+ return backslashes % 2 === 1;
}
diff --git a/packages/ui/stories/markdown.stories.tsx b/packages/ui/stories/markdown.stories.tsx
index 0a4f3b0c69..09865b4610 100644
--- a/packages/ui/stories/markdown.stories.tsx
+++ b/packages/ui/stories/markdown.stories.tsx
@@ -18,6 +18,7 @@
*/
import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, waitFor } from 'storybook/test';
import { ChatMessageBubble } from '@astryxdesign/core';
import { Markdown } from '../src/markdown.js';
@@ -128,6 +129,58 @@ export const TranscriptCodeBlock: Story = {
),
};
+// Real path: chat → an assistant turn answering a math question. The answer
+// mixes inline \(…\), display \[…\] and $$…$$, plus prose dollars and inline
+// code that must stay literal — the delimiter contract this story exists to
+// show at a glance. The frame is the transcript's compact density.
+export const TranscriptMath: Story = {
+ render: () => (
+
+
+
+ 注意:`$HOME`、`$5–$10`、`$x$` 和 `` `\\(x\\)` `` 都保持字面量,不会被当成公式。',
+ ].join('\n')}
+ />
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ // Math nodes land on a later parser pass than the first commit, so the
+ // count must wait rather than query synchronously.
+ await waitFor(() => {
+ expect(canvasElement.querySelectorAll('.maka-math-inline')).toHaveLength(6);
+ expect(canvasElement.querySelectorAll('.maka-math-display')).toHaveLength(2);
+ });
+ const text = canvasElement.textContent ?? '';
+ if (!text.includes('$HOME') || !text.includes('$5–$10') || !text.includes('$x$')) {
+ throw new Error('literal dollars were not preserved');
+ }
+ if (/MAKA_MATH|\uE000|\uE001/.test(text)) {
+ throw new Error('math transport syntax leaked into rendered output');
+ }
+ },
+};
+
// Real path: 每日回顾 → a generated report that mixes headings, emphasis, a list,
// a quote and a table. This is the document density — the Daily Review panel is
// the caller that leaves `density` unset. (It was annotated as a chat answer,
diff --git a/patches/@astryxdesign+core+0.6.1.patch b/patches/@astryxdesign+core+0.6.2.patch
similarity index 86%
rename from patches/@astryxdesign+core+0.6.1.patch
rename to patches/@astryxdesign+core+0.6.2.patch
index 8885848081..291ccf8e33 100644
--- a/patches/@astryxdesign+core+0.6.1.patch
+++ b/patches/@astryxdesign+core+0.6.2.patch
@@ -1,5 +1,5 @@
diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts
-index 90022b3..f1d8008 100644
+index 90022b3..36c0637 100644
--- a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts
+++ b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.d.ts
@@ -62,6 +62,8 @@ export type ChatComposerTriggerItem = SearchableItem;
@@ -144,7 +144,7 @@ index 3c457d1..7d5a11a 100644
}, getToolCallKey(call)))
})
diff --git a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js
-index 47d4e52..0a73bc0 100644
+index 47d4e52..d705783 100644
--- a/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js
+++ b/node_modules/@astryxdesign/core/dist/Chat/useTriggerMenu.js
@@ -48,6 +48,45 @@ const styles = {
@@ -395,10 +395,10 @@ index 8753b5f..43a5abe 100644
// unconditionally would erase a label a consumer pointed at their own
// heading.
diff --git a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts
-index 2ea4f3e..15dbdd6 100644
+index 6b3b7e1..c529123 100644
--- a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts
+++ b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.d.ts
-@@ -90,6 +90,10 @@ export interface MarkdownProps extends BaseProps {
+@@ -98,6 +98,10 @@ export interface MarkdownProps extends BaseProps {
*/
headingLevelStart?: 1 | 2 | 3 | 4 | 5 | 6;
isStreaming?: boolean;
@@ -409,7 +409,7 @@ index 2ea4f3e..15dbdd6 100644
onLinkClick?: (href: string, event: React.MouseEvent) => void | false;
/**
* Citation sources keyed by ID. When provided, `[id]` and `【id】` markers
-@@ -155,7 +159,7 @@ export interface MarkdownProps extends BaseProps {
+@@ -163,7 +167,7 @@ export interface MarkdownProps extends BaseProps {
*
* ```
*/
@@ -419,11 +419,11 @@ index 2ea4f3e..15dbdd6 100644
var displayName: string;
}
diff --git a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js
-index 36ed303..0184370 100644
+index 3432f13..898e131 100644
--- a/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js
+++ b/node_modules/@astryxdesign/core/dist/Markdown/Markdown.js
@@ -8,7 +8,7 @@
- * @output Exports Markdown component and MarkdownProps
+ * @output Exports Markdown component, MarkdownProps, and renderer contracts
* @position Core implementation; renders markdown as Astryx components
*/
-import { useMemo, useRef } from 'react';
@@ -431,7 +431,7 @@ index 36ed303..0184370 100644
import { Fragment } from 'react';
import * as stylex from '@stylexjs/stylex';
import "../theme/tokens.stylex.js";
-@@ -1053,6 +1053,8 @@ export function Markdown({
+@@ -1087,6 +1087,8 @@ export function Markdown({
density = 'default',
headingLevelStart = 1,
isStreaming = false,
@@ -440,7 +440,7 @@ index 36ed303..0184370 100644
onLinkClick,
sources,
citationStyle = 'label',
-@@ -1078,7 +1080,10 @@ export function Markdown({
+@@ -1118,7 +1120,10 @@ export function Markdown({
// Smooth bursty streamed chunks into a steady character-by-character reveal.
// When not streaming, the hook returns children unchanged (no-op).
@@ -450,36 +450,36 @@ index 36ed303..0184370 100644
+ });
+ const parsedText = transformSource ? transformSource(smoothedText) : smoothedText;
const incrementalStateRef = useRef(createIncrementalState());
- // Reset incremental cache when the autolink option toggles — cached
- // settled blocks were parsed with the previous setting.
-@@ -1096,11 +1101,11 @@ export function Markdown({
- incrementalStateRef.current = createIncrementalState();
+ // Reset incremental cache when parser-affecting component options toggle —
+ // cached settled blocks were parsed with the previous setting.
+@@ -1139,11 +1144,11 @@ export function Markdown({
return [];
}
-- const input = trimStreamingArtifacts(smoothedText);
-+ const input = trimStreamingArtifacts(parsedText);
- return parseMarkdownIncremental(input, incrementalStateRef.current, parseOptions);
+ const options = hasMathRenderer ? mathParseOptions : legacyParseOptions;
+- const input = trimStreamingArtifacts(smoothedText, options);
++ const input = trimStreamingArtifacts(parsedText, options);
+ return hasMathRenderer ? parseMarkdownIncremental(input, incrementalStateRef.current, mathParseOptions) : parseMarkdownIncremental(input, incrementalStateRef.current, legacyParseOptions);
}
-- return parseMarkdown(children, parseOptions);
-- }, [display, smoothedText, children, isStreaming, parseOptions]);
-+ return parseMarkdown(parsedText, parseOptions);
-+ }, [display, parsedText, isStreaming, parseOptions]);
+- return hasMathRenderer ? parseMarkdown(children, mathParseOptions) : parseMarkdown(children, legacyParseOptions);
+- }, [display, smoothedText, children, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]);
++ return hasMathRenderer ? parseMarkdown(parsedText, mathParseOptions) : parseMarkdown(parsedText, legacyParseOptions);
++ }, [display, parsedText, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]);
// Assign each top-level heading the slug that parseOutlineFromMarkdown
// would derive for it, so Outline hash links built from the same source
-@@ -1126,9 +1131,9 @@ export function Markdown({
- if (display !== 'inline') {
+@@ -1170,9 +1175,9 @@ export function Markdown({
return [];
}
-- const input = isStreaming ? trimStreamingArtifacts(smoothedText) : children;
-+ const input = isStreaming ? trimStreamingArtifacts(parsedText) : parsedText;
- return parseInline(input, parseOptions);
-- }, [display, smoothedText, children, isStreaming, parseOptions]);
-+ }, [display, parsedText, isStreaming, parseOptions]);
+ const options = hasMathRenderer ? mathParseOptions : legacyParseOptions;
+- const input = isStreaming ? trimStreamingArtifacts(smoothedText, options) : children;
++ const input = isStreaming ? trimStreamingArtifacts(parsedText, options) : parsedText;
+ return hasMathRenderer ? parseInline(input, mathParseOptions) : parseInline(input, legacyParseOptions);
+- }, [display, smoothedText, children, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]);
++ }, [display, parsedText, isStreaming, hasMathRenderer, mathParseOptions, legacyParseOptions]);
// Track recent boundaries for stacked fade-in animation.
// The number of spans needed = ceil(animationDuration / tickInterval).
-@@ -1145,17 +1150,25 @@ export function Markdown({
+@@ -1189,17 +1194,25 @@ export function Markdown({
const tickMs = tick != null ? Math.max(4, Math.round(tick / 10)) : 50;
return Math.min(Math.ceil(duration / tickMs), 12);
}, [token]);
@@ -516,7 +516,7 @@ index 36ed303..0184370 100644
const cursor = {
offset: 0,
boundaries,
-@@ -1183,9 +1196,6 @@ export function Markdown({
+@@ -1227,9 +1240,6 @@ export function Markdown({
children: inlineNodes.map((node, i) => renderInline(node, i, onLinkClick, cursor, citationCtx, LinkComponent, inlinePlugins, components))
});
@@ -526,7 +526,7 @@ index 36ed303..0184370 100644
return renderedInline;
}
const rendered = /*#__PURE__*/_jsx("div", {
-@@ -1202,11 +1212,6 @@ export function Markdown({
+@@ -1246,11 +1256,6 @@ export function Markdown({
children: blocks.map((block, i) => renderBlock(block, i, blocks.length, density, headingLevelStart, onLinkClick, cursor, citationCtx, contentWidth ? typeof contentWidth === 'number' ? `${contentWidth}px` : contentWidth : null, contentAlign, LinkComponent, inlinePlugins, components, t, headingIdMap))
});
@@ -539,6 +539,76 @@ index 36ed303..0184370 100644
}
Markdown.displayName = 'Markdown';
\ No newline at end of file
+diff --git a/node_modules/@astryxdesign/core/dist/Markdown/parser.js b/node_modules/@astryxdesign/core/dist/Markdown/parser.js
+index 55ac179..971f1fc 100644
+--- a/node_modules/@astryxdesign/core/dist/Markdown/parser.js
++++ b/node_modules/@astryxdesign/core/dist/Markdown/parser.js
+@@ -1741,6 +1741,7 @@ export function trimStreamingArtifacts(input, options) {
+ const lastNL = displayTrimmed.lastIndexOf('\n');
+ const prefix = lastNL === -1 ? '' : displayTrimmed.slice(0, lastNL + 1);
+ let tail = lastNL === -1 ? displayTrimmed : displayTrimmed.slice(lastNL + 1);
++ const mathRanges = [];
+ if (options?.math) {
+ // Hold an unmatched inline opener so raw TeX syntax does not flash while
+ // streaming. If another unescaped dollar is already present but fails the
+@@ -1755,6 +1756,7 @@ export function trimStreamingArtifacts(input, options) {
+ }
+ const close = findInlineMathEnd(tail, index);
+ if (close !== -1) {
++ mathRanges.push(index, close);
+ index = close;
+ continue;
+ }
+@@ -1772,10 +1774,23 @@ export function trimStreamingArtifacts(input, options) {
+ index = laterDollar;
+ }
+ }
++ // Unclosed-marker checks below must not see `[`, `*`, or `~~` that live
++ // inside a completed inline math span; TeX owns those characters.
++ const inMathSpan = (pos) => {
++ for (let i = 0; i < mathRanges.length; i += 2) {
++ if (pos >= mathRanges[i] && pos <= mathRanges[i + 1]) {
++ return true;
++ }
++ }
++ return false;
++ };
+
+ // Scan backwards for unclosed syntax markers — no regex to avoid ReDoS
+ // Find the last unclosed [ or  somewhere after the [
+@@ -1834,8 +1849,9 @@ export function trimStreamingArtifacts(input, options) {
+ while (idx + markerLen < tail.length && tail[idx + markerLen] === '*') {
+ markerLen++;
+ }
+- if (markerLen > 3) {
+- // 4+ stars — not standard markdown emphasis, skip
++ if (markerLen > 3 || inMathSpan(idx)) {
++ // 4+ stars — not standard markdown emphasis, skip. A `*` inside a
++ // completed math span is TeX, not emphasis.
+ searchFrom = idx + markerLen;
+ continue;
+ }
+@@ -1903,8 +1919,10 @@ export function trimStreamingArtifacts(input, options) {
+ if (idx === -1) {
+ break;
+ }
+- positions.push(idx);
+- count++;
++ if (!inMathSpan(idx)) {
++ positions.push(idx);
++ count++;
++ }
+ searchFrom = idx + 2;
+ }
+ if (count % 2 === 1) {
diff --git a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts
index c55f046..09efbbe 100644
--- a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts
@@ -587,10 +657,10 @@ index b56c9d5..463f665 100644
role: "group",
"aria-labelledby": `${id}-label`,
diff --git a/node_modules/@astryxdesign/core/dist/astryx.css b/node_modules/@astryxdesign/core/dist/astryx.css
-index f7c1792..0180bb3 100644
+index 465aaab..62e542c 100644
--- a/node_modules/@astryxdesign/core/dist/astryx.css
+++ b/node_modules/@astryxdesign/core/dist/astryx.css
-@@ -577,7 +577,7 @@
+@@ -578,7 +578,7 @@
.xk2sxw7:focus-within:not(#\#):not(#\#){border-color:var(--color-error)}
.xyq33ac:focus-within:not(#\#):not(#\#){border-color:var(--color-success)}
.xa1yw2k:focus-within:not(#\#):not(#\#){border-color:var(--color-warning)}
@@ -599,7 +669,7 @@ index f7c1792..0180bb3 100644
.x1dordxg:focus-within:not(#\#):not(#\#){overflow:visible}
.xb0m1lw:has(> div > .astryx-layout-content):not(#\#):not(#\#){margin-inline:0}
.x16s19ga:has(:focus-visible):not(#\#):not(#\#){outline:var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color)}
-@@ -1364,7 +1364,7 @@
+@@ -1365,7 +1365,7 @@
.x7s97pk:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:2px}
.x13aywxo:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--button-focus-offset)}
.x1vwwbsn:focus-visible:not(#\#):not(#\#):not(#\#){outline-offset:var(--focus-outline-offset)}
@@ -608,7 +678,7 @@ index f7c1792..0180bb3 100644
.x1jhp3zv:focus-visible:not(#\#):not(#\#):not(#\#){outline-style:var(--focus-outline-style)}
.x1vidyx5:focus-visible:not(#\#):not(#\#):not(#\#){outline-width:var(--focus-outline-width)}
.x1n6uwha:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){padding-inline-end:max(var(--spacing-4),env(safe-area-inset-left,0px))}
-@@ -1843,8 +1843,8 @@
+@@ -1844,8 +1844,8 @@
.x5lhr3w:not(#\#):not(#\#):not(#\#):not(#\#){width:var(--x-width)}
.x1wp2rvj.x1wp2rvj:where(.x139im0d:last-child *):not(#\#):not(#\#):not(#\#):not(#\#){border-bottom-width:0}
.x132qfvm:focus-within:not(#\#):not(#\#):not(#\#):not(#\#){height:auto}
@@ -1175,7 +1245,7 @@ index 5b63cd6..76627fe 100644
// unconditionally would erase a label a consumer pointed at their own
// heading.
diff --git a/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx b/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx
-index 0ba2449..1a9fbd7 100644
+index 75e68af..ca671d4 100644
--- a/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx
+++ b/node_modules/@astryxdesign/core/src/Markdown/Markdown.tsx
@@ -9,7 +9,7 @@
@@ -1187,7 +1257,7 @@ index 0ba2449..1a9fbd7 100644
import type React from 'react';
import {Fragment} from 'react';
import * as stylex from '@stylexjs/stylex';
-@@ -145,6 +145,10 @@ export interface MarkdownProps extends BaseProps {
+@@ -159,6 +159,10 @@ export interface MarkdownProps extends BaseProps {
*/
headingLevelStart?: 1 | 2 | 3 | 4 | 5 | 6;
isStreaming?: boolean;
@@ -1198,7 +1268,7 @@ index 0ba2449..1a9fbd7 100644
onLinkClick?: (
href: string,
event: React.MouseEvent,
-@@ -1615,6 +1619,8 @@ export function Markdown({
+@@ -1656,6 +1660,8 @@ export function Markdown({
density = 'default',
headingLevelStart = 1,
isStreaming = false,
@@ -1207,7 +1277,7 @@ index 0ba2449..1a9fbd7 100644
onLinkClick,
sources,
citationStyle = 'label',
-@@ -1644,7 +1650,10 @@ export function Markdown({
+@@ -1690,7 +1696,10 @@ export function Markdown({
// Smooth bursty streamed chunks into a steady character-by-character reveal.
// When not streaming, the hook returns children unchanged (no-op).
@@ -1217,45 +1287,58 @@ index 0ba2449..1a9fbd7 100644
+ });
+ const parsedText = transformSource ? transformSource(smoothedText) : smoothedText;
- const incrementalStateRef = useRef(
- createIncrementalState(),
-@@ -1666,15 +1675,15 @@ export function Markdown({
- incrementalStateRef.current = createIncrementalState();
+ const incrementalStateRef = useRef>(
+ createIncrementalState(),
+@@ -1718,7 +1727,7 @@ export function Markdown({
return [];
}
-- const input = trimStreamingArtifacts(smoothedText);
-+ const input = trimStreamingArtifacts(parsedText);
- return parseMarkdownIncremental(
- input,
- incrementalStateRef.current,
- parseOptions,
- );
+ const options = hasMathRenderer ? mathParseOptions : legacyParseOptions;
+- const input = trimStreamingArtifacts(smoothedText, options);
++ const input = trimStreamingArtifacts(parsedText, options);
+ return hasMathRenderer
+ ? parseMarkdownIncremental(
+ input,
+@@ -1732,12 +1741,11 @@ export function Markdown({
+ );
}
-- return parseMarkdown(children, parseOptions);
-- }, [display, smoothedText, children, isStreaming, parseOptions]);
-+ return parseMarkdown(parsedText, parseOptions);
-+ }, [display, parsedText, isStreaming, parseOptions]);
-
- // Assign each top-level heading the slug that parseOutlineFromMarkdown
- // would derive for it, so Outline hash links built from the same source
-@@ -1701,9 +1710,9 @@ export function Markdown({
- if (display !== 'inline') {
- return [];
+ return hasMathRenderer
+- ? parseMarkdown(children, mathParseOptions)
+- : parseMarkdown(children, legacyParseOptions);
++ ? parseMarkdown(parsedText, mathParseOptions)
++ : parseMarkdown(parsedText, legacyParseOptions);
+ }, [
+ display,
+- smoothedText,
+- children,
++ parsedText,
+ isStreaming,
+ hasMathRenderer,
+ mathParseOptions,
+@@ -1771,15 +1779,14 @@ export function Markdown({
}
-- const input = isStreaming ? trimStreamingArtifacts(smoothedText) : children;
-+ const input = isStreaming ? trimStreamingArtifacts(parsedText) : parsedText;
- return parseInline(input, parseOptions);
-- }, [display, smoothedText, children, isStreaming, parseOptions]);
-+ }, [display, parsedText, isStreaming, parseOptions]);
-
- // Track recent boundaries for stacked fade-in animation.
- // The number of spans needed = ceil(animationDuration / tickInterval).
-@@ -1719,20 +1728,43 @@ export function Markdown({
+ const options = hasMathRenderer ? mathParseOptions : legacyParseOptions;
+ const input = isStreaming
+- ? trimStreamingArtifacts(smoothedText, options)
+- : children;
++ ? trimStreamingArtifacts(parsedText, options)
++ : parsedText;
+ return hasMathRenderer
+ ? parseInline(input, mathParseOptions)
+ : parseInline(input, legacyParseOptions);
+ }, [
+ display,
+- smoothedText,
+- children,
++ parsedText,
+ isStreaming,
+ hasMathRenderer,
+ mathParseOptions,
+@@ -1800,20 +1807,43 @@ export function Markdown({
return Math.min(Math.ceil(duration / tickMs), 12);
}, [token]);
-- const prevBlocksRef = useRef([]);
-- const prevInlineNodesRef = useRef([]);
+- const prevBlocksRef = useRef([]);
+- const prevInlineNodesRef = useRef([]);
- const boundariesRef = useRef([]);
- const smoothedLen = smoothedText.length;
- const boundaries = useMemo(() => {
@@ -1308,7 +1391,7 @@ index 0ba2449..1a9fbd7 100644
const cursor: StreamingCursor = {
offset: 0,
-@@ -1774,10 +1806,6 @@ export function Markdown({
+@@ -1855,10 +1885,6 @@ export function Markdown({
);
@@ -1319,7 +1402,7 @@ index 0ba2449..1a9fbd7 100644
return renderedInline;
}
-@@ -1821,12 +1849,6 @@ export function Markdown({
+@@ -1902,12 +1928,6 @@ export function Markdown({
);
@@ -1332,6 +1415,76 @@ index 0ba2449..1a9fbd7 100644
return rendered;
}
+diff --git a/node_modules/@astryxdesign/core/src/Markdown/parser.ts b/node_modules/@astryxdesign/core/src/Markdown/parser.ts
+index 6dee3b2..3717b78 100644
+--- a/node_modules/@astryxdesign/core/src/Markdown/parser.ts
++++ b/node_modules/@astryxdesign/core/src/Markdown/parser.ts
+@@ -2149,6 +2149,7 @@ export function trimStreamingArtifacts(
+ const prefix = lastNL === -1 ? '' : displayTrimmed.slice(0, lastNL + 1);
+ let tail = lastNL === -1 ? displayTrimmed : displayTrimmed.slice(lastNL + 1);
+
++ const mathRanges: number[] = [];
+ if (options?.math) {
+ // Hold an unmatched inline opener so raw TeX syntax does not flash while
+ // streaming. If another unescaped dollar is already present but fails the
+@@ -2169,6 +2170,7 @@ export function trimStreamingArtifacts(
+ }
+ const close = findInlineMathEnd(tail, index);
+ if (close !== -1) {
++ mathRanges.push(index, close);
+ index = close;
+ continue;
+ }
+@@ -2186,10 +2188,23 @@ export function trimStreamingArtifacts(
+ index = laterDollar;
+ }
+ }
++ // Unclosed-marker checks below must not see `[`, `*`, or `~~` that live
++ // inside a completed inline math span; TeX owns those characters.
++ const inMathSpan = (pos: number): boolean => {
++ for (let i = 0; i < mathRanges.length; i += 2) {
++ if (pos >= (mathRanges[i] ?? 0) && pos <= (mathRanges[i + 1] ?? 0)) {
++ return true;
++ }
++ }
++ return false;
++ };
+
+ // Scan backwards for unclosed syntax markers — no regex to avoid ReDoS
+ // Find the last unclosed [ or  somewhere after the [
+@@ -2251,8 +2266,9 @@ export function trimStreamingArtifacts(
+ while (idx + markerLen < tail.length && tail[idx + markerLen] === '*') {
+ markerLen++;
+ }
+- if (markerLen > 3) {
+- // 4+ stars — not standard markdown emphasis, skip
++ if (markerLen > 3 || inMathSpan(idx)) {
++ // 4+ stars — not standard markdown emphasis, skip. A `*` inside a
++ // completed math span is TeX, not emphasis.
+ searchFrom = idx + markerLen;
+ continue;
+ }
+@@ -2317,8 +2333,10 @@ export function trimStreamingArtifacts(
+ if (idx === -1) {
+ break;
+ }
+- positions.push(idx);
+- count++;
++ if (!inMathSpan(idx)) {
++ positions.push(idx);
++ count++;
++ }
+ searchFrom = idx + 2;
+ }
+ if (count % 2 === 1) {
diff --git a/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx b/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx
index 6c581d4..d3a9857 100644
--- a/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx
diff --git a/patches/README.md b/patches/README.md
index a20eeddca3..ccd89519bb 100644
--- a/patches/README.md
+++ b/patches/README.md
@@ -172,7 +172,7 @@ Streaming tool-call association for gateways that reuse or omit `index` / `id`
Delete when that guard passes against an unpatched package.
-## `@astryxdesign/core@0.6.1`
+## `@astryxdesign/core@0.6.2`
The shared code tokenizer caches only valid language definitions. Caching `null`
for arbitrary unsupported fence labels grows a process-lifetime map; a short
@@ -225,6 +225,12 @@ Markdown can also transform the displayed prefix immediately before its
existing incremental parser, so host syntax such as math stays behind the
streaming cursor without adding another parser or scheduler.
+`trimStreamingArtifacts` pairs its unclosed-marker scans with the inline math
+spans it already found. Without that, a complete `$…$` whose TeX contains
+`[`, `*`, or `~~` on the last streamed line is trimmed or auto-closed as if
+the characters were Markdown, mangling a finished formula. Remove this hunk
+when upstream scopes those scans to text outside math.
+
One hunk is a geometry fix rather than a seam. `ChatLayout`'s frosted dock
layer is a per-density constant (80/100/120px) while the dock it fades is
sized by its content. At `balanced` the 100px layer starts 90px inside the
diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs
index 954ab2cb56..e813df844a 100644
--- a/scripts/generate-third-party-notices.mjs
+++ b/scripts/generate-third-party-notices.mjs
@@ -145,6 +145,7 @@ const MIT_COPYRIGHT_OVERRIDES = new Map([
['@astryxdesign/core@0.5.0', 'Copyright (c) 2026 Meta Platforms, Inc.'],
['@astryxdesign/core@0.5.2', 'Copyright (c) 2026 Meta Platforms, Inc.'],
['@astryxdesign/core@0.6.1', 'Copyright (c) 2026 Meta Platforms, Inc.'],
+ ['@astryxdesign/core@0.6.2', 'Copyright (c) 2026 Meta Platforms, Inc.'],
['@stylexjs/stylex@0.19.0', 'Copyright (c) Meta Platforms, Inc. and affiliates.'],
['@wecom/aibot-node-sdk@1.0.7', 'Copyright (c) WeComTeam contributors'],
[