Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/quote-companion.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on
// Quiet composer stages quotes as drawer Tokens (Astryx Token + remove).
const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token');
await expect(quoteTokens).toHaveCount(2);
await quoteTokens.first().getByRole('button', { name: /^Remove / }).click();
await quoteTokens.first().getByRole('button', { name: /^移除/ }).click();
await expect(quoteTokens).toHaveCount(1);

// Full text authority is the companion panel list, not truncated token labels.
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/__tests__/chat-view-empty-state.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => {
assert.equal(markup.match(/astryx-badge/g)?.length, 2);
});
});

describe('ChatView #642 streaming fallback', () => {
// The fallback renders outside TurnView, so it must localize the message
// aria-label itself — a bare ChatMessage resolves Astryx's shipped
// "Message from {sender}" and leaks English into a Chinese a11y tree.
it('localizes the fallback assistant message aria-label', () => {
// No liveTurn: a live turn projects into `turns` and takes the localized
// TurnView path instead. The fallback needs streaming with zero turns —
// wait indicators alone — which is exactly the #642 replay window.
const markup = renderToStaticMarkup(
<LocaleProvider locale="zh">
<OwnedChatView
messages={[]}
activeSession={activeSession}
processingIndicator
onNew={() => undefined}
/>
</LocaleProvider>,
);

assert.match(markup, /data-live-streaming="true"/);
assert.match(markup, /aria-label="Maka 的回答"/);
assert.doesNotMatch(markup, /Message from/);
});
});
90 changes: 85 additions & 5 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { it } from 'node:test';
Expand DownExpand Up@@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => {
assert.doesNotMatch(markup, />Checkbox</);
});

it('ships overrides only for Astryx surfaces Maka renders', () => {
// A dead-config guard used to sit here, banning override keys for Astryx
// surfaces Maka supposedly never rendered. Both of its entries rotted the
// same way: `chat` stopped being true at #1795 (ChatLayout took over the
// transcript, and the guard then blocked the fix for the English
// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx
// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban
// list keyed to "what we render today" goes stale silently, so it is gone;
// the tests below pin the surfaces we know are live instead.
function assertChineseAstryxOverrides(keys: readonly string[]) {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
for (const key of Object.keys(messages)) {
for (const key of keys) {
// Assert presence first: this reads the override map directly (no catalog
// resolution), so a deleted entry yields undefined → '' — which holds no
// Latin letters and would satisfy the translation check on its own. (At
// runtime the same missing entry falls back to Astryx's shipped en
// catalog, i.e. English in the UI.)
const value = messages[key];
assert.ok(value, `missing override: ${key}`);
assert.doesNotMatch(
key,
/^@astryx\.(?:lightbox|chat)/,
`dead Astryx locale override: ${key}`,
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key}`,
);
}
}

it('localizes the Astryx chat chrome adopted in #1795', () => {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息');
assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部');
assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']);
});

// Whole-map sweep: every override must target a key Astryx actually ships,
// carry the same ICU arguments as the en default (a renamed placeholder
// throws at format time), and hold no Latin outside {…} segments. The pinned
// tests above cover specific regressions; this keeps the other ~70 entries
// honest without naming them one by one.
it('every zh override is a real Astryx key, translated, with matching ICU args', () => {
const require = createRequire(import.meta.url);
const catalog: Record<string, { defaultMessage: string }> = require(
'@astryxdesign/core/locales/en.json',
);
// Top-level ICU argument names only: inside `{count, plural, one {result}}`
// the `{result}` is branch text, not an argument — a naive regex would
// report it and flag every zh string that drops an inapplicable plural.
const icuArgs = (message: string) => {
const args = new Set<string>();
let depth = 0;
for (let i = 0; i < message.length; i++) {
if (message[i] === '{') {
if (depth === 0) {
const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i));
if (m?.[1]) args.add(m[1]);
}
depth++;
} else if (message[i] === '}') {
depth = Math.max(0, depth - 1);
}
}
return args;
};
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.ok(Object.keys(messages).length > 0);
for (const [key, value] of Object.entries(messages)) {
const shipped = catalog[key];
assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`);
assert.ok(value, `empty override: ${key}`);
assert.doesNotMatch(
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key} = ${value}`,
);
assert.deepEqual(
icuArgs(value),
icuArgs(shipped.defaultMessage),
`ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`,
);
}
});

it('localizes the Lightbox reached via useLightbox in chat-turn', () => {
assertChineseAstryxOverrides([
'@astryx.lightbox.mediaViewer',
'@astryx.lightbox.close',
'@astryx.lightbox.previous',
'@astryx.lightbox.next',
]);
});

it('uses the localized Astryx code block and syntax tokenizer', () => {
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/src/astryx-copy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
/**
* Chinese copy for Astryx's own message catalog, which ships no `zh`: without
* an override every `@astryx.*` string falls back to the shipped `en` catalog
* silently. Grouped by the component that renders it so a slice adopting a new
* Astryx surface can see at a glance whether its strings are already covered.
*
* Deliberately NOT exported from the package barrel (`index.ts`): the only
* consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the
* README's off-barrel convention a symbol earns barrel export only with a
* cross-package consumer. Strings that Maka's own components also render live
* in `shared-ui-copy.ts` instead and are referenced from the override map, so
* shared wording keeps one home.
*
* `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which
* resolves Astryx's shipped defaults — an `en` mirror here would be dead
* config drifting against upstream.
*/
export interface AstryxCopy {
banner: { collapse: string; expand: string };
breadcrumbs: { label: string };
calendar: {
dayInRange: string;
dayRangeEnd: string;
dayRangeStart: string;
dayRangeStartAndEnd: string;
daySelected: string;
nextMonth: string;
previousMonth: string;
rangeCompleteAnnounce: string;
rangeStartAnnounce: string;
};
chat: {
composerPlaceholder: string;
composerDrawerLabel: string;
composerInputLabel: string;
messageAriaLabel: string;
pastedTextExpand: string;
statusDelivered: string;
statusFailed: string;
statusRead: string;
statusSending: string;
statusSent: string;
drawerCollapse: string;
drawerExpand: string;
newMessages: string;
scrollToBottom: string;
toolCallsError: string;
toolCallsGroupLabel: string;
triggerSuggestions: string;
};
commandPalette: {
emptyBootstrap: string;
emptySearch: string;
inputPlaceholder: string;
label: string;
noResultsFor: string;
resultCount: string;
};
dateTime: {
closeCalendar: string;
openCalendar: string;
dialogLabel: string;
datePlaceholder: string;
timePlaceholder: string;
timeSuffix: string;
};
inputStatus: { error: string; success: string; warning: string };
lightbox: { mediaViewer: string; previous: string; next: string };
menus: { dropdown: string; more: string };
multiSelector: { clearAll: string; selectAll: string };
/** Selector and MultiSelector render the same two search affordances. */
search: { options: string; placeholder: string };
sideNav: {
label: string;
resizeSidebar: string;
collapseSidebar: string;
expandSidebar: string;
itemCollapse: string;
itemExpand: string;
};
tabList: { label: string };
table: { label: string };
thumbnail: { fallbackName: string; open: string; remove: string };
token: { remove: string };
}

export const ASTRYX_COPY_ZH: AstryxCopy = {
banner: { collapse: '收起', expand: '展开' },
breadcrumbs: { label: '面包屑导航' },
calendar: {
dayInRange: '{date},在所选范围内',
dayRangeEnd: '{date},范围结束',
dayRangeStart: '{date},范围开始',
dayRangeStartAndEnd: '{date},范围开始与结束',
daySelected: '{date},已选择',
nextMonth: '下个月',
previousMonth: '上个月',
rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。',
rangeStartAnnounce: '开始日期 {date}。请选择结束日期。',
},
chat: {
composerPlaceholder: '输入消息…',
composerDrawerLabel: '附加内容',
composerInputLabel: '消息输入框',
messageAriaLabel: '消息:{status}',
pastedTextExpand: '展开',
statusDelivered: '已送达',
statusFailed: '发送失败',
statusRead: '已读',
statusSending: '发送中',
statusSent: '已发送',
drawerCollapse: '收起{label}',
drawerExpand: '展开{label}',
newMessages: '跳到最新消息',
scrollToBottom: '滚动到底部',
toolCallsError: '错误:{message}',
toolCallsGroupLabel: '{count} 次工具调用',
triggerSuggestions: '建议',
},
commandPalette: {
emptyBootstrap: '输入以搜索',
emptySearch: '无结果',
inputPlaceholder: '搜索…',
label: '命令面板',
noResultsFor: '没有与「{query}」匹配的结果',
resultCount: '{count, number} 条结果',
},
dateTime: {
closeCalendar: '关闭日历',
openCalendar: '打开日历',
dialogLabel: '选择日期',
datePlaceholder: '选择日期',
timePlaceholder: '选择时间',
timeSuffix: '{label}时间',
},
inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' },
lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
menus: { dropdown: '菜单', more: '更多选项' },
multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
search: { options: '搜索选项', placeholder: '搜索…' },
sideNav: {
label: '侧边导航',
resizeSidebar: '调整侧边栏宽度',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
itemCollapse: '收起{label}',
itemExpand: '展开{label}',
},
tabList: { label: '标签页' },
table: { label: '表格' },
thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' },
token: { remove: '移除{label}' },
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/quote-companion.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on
// Quiet composer stages quotes as drawer Tokens (Astryx Token + remove).
const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token');
await expect(quoteTokens).toHaveCount(2);
await quoteTokens.first().getByRole('button', { name: /^Remove / }).click();
await quoteTokens.first().getByRole('button', { name: /^移除/ }).click();
await expect(quoteTokens).toHaveCount(1);

// Full text authority is the companion panel list, not truncated token labels.
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/__tests__/chat-view-empty-state.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => {
assert.equal(markup.match(/astryx-badge/g)?.length, 2);
});
});

describe('ChatView #642 streaming fallback', () => {
// The fallback renders outside TurnView, so it must localize the message
// aria-label itself — a bare ChatMessage resolves Astryx's shipped
// "Message from {sender}" and leaks English into a Chinese a11y tree.
it('localizes the fallback assistant message aria-label', () => {
// No liveTurn: a live turn projects into `turns` and takes the localized
// TurnView path instead. The fallback needs streaming with zero turns —
// wait indicators alone — which is exactly the #642 replay window.
const markup = renderToStaticMarkup(
<LocaleProvider locale="zh">
<OwnedChatView
messages={[]}
activeSession={activeSession}
processingIndicator
onNew={() => undefined}
/>
</LocaleProvider>,
);

assert.match(markup, /data-live-streaming="true"/);
assert.match(markup, /aria-label="Maka 的回答"/);
assert.doesNotMatch(markup, /Message from/);
});
});
90 changes: 85 additions & 5 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { it } from 'node:test';
Expand DownExpand Up@@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => {
assert.doesNotMatch(markup, />Checkbox</);
});

it('ships overrides only for Astryx surfaces Maka renders', () => {
// A dead-config guard used to sit here, banning override keys for Astryx
// surfaces Maka supposedly never rendered. Both of its entries rotted the
// same way: `chat` stopped being true at #1795 (ChatLayout took over the
// transcript, and the guard then blocked the fix for the English
// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx
// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban
// list keyed to "what we render today" goes stale silently, so it is gone;
// the tests below pin the surfaces we know are live instead.
function assertChineseAstryxOverrides(keys: readonly string[]) {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
for (const key of Object.keys(messages)) {
for (const key of keys) {
// Assert presence first: this reads the override map directly (no catalog
// resolution), so a deleted entry yields undefined → '' — which holds no
// Latin letters and would satisfy the translation check on its own. (At
// runtime the same missing entry falls back to Astryx's shipped en
// catalog, i.e. English in the UI.)
const value = messages[key];
assert.ok(value, `missing override: ${key}`);
assert.doesNotMatch(
key,
/^@astryx\.(?:lightbox|chat)/,
`dead Astryx locale override: ${key}`,
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key}`,
);
}
}

it('localizes the Astryx chat chrome adopted in #1795', () => {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息');
assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部');
assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']);
});

// Whole-map sweep: every override must target a key Astryx actually ships,
// carry the same ICU arguments as the en default (a renamed placeholder
// throws at format time), and hold no Latin outside {…} segments. The pinned
// tests above cover specific regressions; this keeps the other ~70 entries
// honest without naming them one by one.
it('every zh override is a real Astryx key, translated, with matching ICU args', () => {
const require = createRequire(import.meta.url);
const catalog: Record<string, { defaultMessage: string }> = require(
'@astryxdesign/core/locales/en.json',
);
// Top-level ICU argument names only: inside `{count, plural, one {result}}`
// the `{result}` is branch text, not an argument — a naive regex would
// report it and flag every zh string that drops an inapplicable plural.
const icuArgs = (message: string) => {
const args = new Set<string>();
let depth = 0;
for (let i = 0; i < message.length; i++) {
if (message[i] === '{') {
if (depth === 0) {
const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i));
if (m?.[1]) args.add(m[1]);
}
depth++;
} else if (message[i] === '}') {
depth = Math.max(0, depth - 1);
}
}
return args;
};
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.ok(Object.keys(messages).length > 0);
for (const [key, value] of Object.entries(messages)) {
const shipped = catalog[key];
assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`);
assert.ok(value, `empty override: ${key}`);
assert.doesNotMatch(
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key} = ${value}`,
);
assert.deepEqual(
icuArgs(value),
icuArgs(shipped.defaultMessage),
`ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`,
);
}
});

it('localizes the Lightbox reached via useLightbox in chat-turn', () => {
assertChineseAstryxOverrides([
'@astryx.lightbox.mediaViewer',
'@astryx.lightbox.close',
'@astryx.lightbox.previous',
'@astryx.lightbox.next',
]);
});

it('uses the localized Astryx code block and syntax tokenizer', () => {
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/src/astryx-copy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
/**
* Chinese copy for Astryx's own message catalog, which ships no `zh`: without
* an override every `@astryx.*` string falls back to the shipped `en` catalog
* silently. Grouped by the component that renders it so a slice adopting a new
* Astryx surface can see at a glance whether its strings are already covered.
*
* Deliberately NOT exported from the package barrel (`index.ts`): the only
* consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the
* README's off-barrel convention a symbol earns barrel export only with a
* cross-package consumer. Strings that Maka's own components also render live
* in `shared-ui-copy.ts` instead and are referenced from the override map, so
* shared wording keeps one home.
*
* `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which
* resolves Astryx's shipped defaults — an `en` mirror here would be dead
* config drifting against upstream.
*/
export interface AstryxCopy {
banner: { collapse: string; expand: string };
breadcrumbs: { label: string };
calendar: {
dayInRange: string;
dayRangeEnd: string;
dayRangeStart: string;
dayRangeStartAndEnd: string;
daySelected: string;
nextMonth: string;
previousMonth: string;
rangeCompleteAnnounce: string;
rangeStartAnnounce: string;
};
chat: {
composerPlaceholder: string;
composerDrawerLabel: string;
composerInputLabel: string;
messageAriaLabel: string;
pastedTextExpand: string;
statusDelivered: string;
statusFailed: string;
statusRead: string;
statusSending: string;
statusSent: string;
drawerCollapse: string;
drawerExpand: string;
newMessages: string;
scrollToBottom: string;
toolCallsError: string;
toolCallsGroupLabel: string;
triggerSuggestions: string;
};
commandPalette: {
emptyBootstrap: string;
emptySearch: string;
inputPlaceholder: string;
label: string;
noResultsFor: string;
resultCount: string;
};
dateTime: {
closeCalendar: string;
openCalendar: string;
dialogLabel: string;
datePlaceholder: string;
timePlaceholder: string;
timeSuffix: string;
};
inputStatus: { error: string; success: string; warning: string };
lightbox: { mediaViewer: string; previous: string; next: string };
menus: { dropdown: string; more: string };
multiSelector: { clearAll: string; selectAll: string };
/** Selector and MultiSelector render the same two search affordances. */
search: { options: string; placeholder: string };
sideNav: {
label: string;
resizeSidebar: string;
collapseSidebar: string;
expandSidebar: string;
itemCollapse: string;
itemExpand: string;
};
tabList: { label: string };
table: { label: string };
thumbnail: { fallbackName: string; open: string; remove: string };
token: { remove: string };
}

export const ASTRYX_COPY_ZH: AstryxCopy = {
banner: { collapse: '收起', expand: '展开' },
breadcrumbs: { label: '面包屑导航' },
calendar: {
dayInRange: '{date},在所选范围内',
dayRangeEnd: '{date},范围结束',
dayRangeStart: '{date},范围开始',
dayRangeStartAndEnd: '{date},范围开始与结束',
daySelected: '{date},已选择',
nextMonth: '下个月',
previousMonth: '上个月',
rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。',
rangeStartAnnounce: '开始日期 {date}。请选择结束日期。',
},
chat: {
composerPlaceholder: '输入消息…',
composerDrawerLabel: '附加内容',
composerInputLabel: '消息输入框',
messageAriaLabel: '消息:{status}',
pastedTextExpand: '展开',
statusDelivered: '已送达',
statusFailed: '发送失败',
statusRead: '已读',
statusSending: '发送中',
statusSent: '已发送',
drawerCollapse: '收起{label}',
drawerExpand: '展开{label}',
newMessages: '跳到最新消息',
scrollToBottom: '滚动到底部',
toolCallsError: '错误:{message}',
toolCallsGroupLabel: '{count} 次工具调用',
triggerSuggestions: '建议',
},
commandPalette: {
emptyBootstrap: '输入以搜索',
emptySearch: '无结果',
inputPlaceholder: '搜索…',
label: '命令面板',
noResultsFor: '没有与「{query}」匹配的结果',
resultCount: '{count, number} 条结果',
},
dateTime: {
closeCalendar: '关闭日历',
openCalendar: '打开日历',
dialogLabel: '选择日期',
datePlaceholder: '选择日期',
timePlaceholder: '选择时间',
timeSuffix: '{label}时间',
},
inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' },
lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
menus: { dropdown: '菜单', more: '更多选项' },
multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
search: { options: '搜索选项', placeholder: '搜索…' },
sideNav: {
label: '侧边导航',
resizeSidebar: '调整侧边栏宽度',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
itemCollapse: '收起{label}',
itemExpand: '展开{label}',
},
tabList: { label: '标签页' },
table: { label: '表格' },
thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' },
token: { remove: '移除{label}' },
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/quote-companion.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on
// Quiet composer stages quotes as drawer Tokens (Astryx Token + remove).
const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token');
await expect(quoteTokens).toHaveCount(2);
await quoteTokens.first().getByRole('button', { name: /^Remove / }).click();
await quoteTokens.first().getByRole('button', { name: /^移除/ }).click();
await expect(quoteTokens).toHaveCount(1);

// Full text authority is the companion panel list, not truncated token labels.
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/__tests__/chat-view-empty-state.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => {
assert.equal(markup.match(/astryx-badge/g)?.length, 2);
});
});

describe('ChatView #642 streaming fallback', () => {
// The fallback renders outside TurnView, so it must localize the message
// aria-label itself — a bare ChatMessage resolves Astryx's shipped
// "Message from {sender}" and leaks English into a Chinese a11y tree.
it('localizes the fallback assistant message aria-label', () => {
// No liveTurn: a live turn projects into `turns` and takes the localized
// TurnView path instead. The fallback needs streaming with zero turns —
// wait indicators alone — which is exactly the #642 replay window.
const markup = renderToStaticMarkup(
<LocaleProvider locale="zh">
<OwnedChatView
messages={[]}
activeSession={activeSession}
processingIndicator
onNew={() => undefined}
/>
</LocaleProvider>,
);

assert.match(markup, /data-live-streaming="true"/);
assert.match(markup, /aria-label="Maka 的回答"/);
assert.doesNotMatch(markup, /Message from/);
});
});
90 changes: 85 additions & 5 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { it } from 'node:test';
Expand DownExpand Up@@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => {
assert.doesNotMatch(markup, />Checkbox</);
});

it('ships overrides only for Astryx surfaces Maka renders', () => {
// A dead-config guard used to sit here, banning override keys for Astryx
// surfaces Maka supposedly never rendered. Both of its entries rotted the
// same way: `chat` stopped being true at #1795 (ChatLayout took over the
// transcript, and the guard then blocked the fix for the English
// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx
// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban
// list keyed to "what we render today" goes stale silently, so it is gone;
// the tests below pin the surfaces we know are live instead.
function assertChineseAstryxOverrides(keys: readonly string[]) {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
for (const key of Object.keys(messages)) {
for (const key of keys) {
// Assert presence first: this reads the override map directly (no catalog
// resolution), so a deleted entry yields undefined → '' — which holds no
// Latin letters and would satisfy the translation check on its own. (At
// runtime the same missing entry falls back to Astryx's shipped en
// catalog, i.e. English in the UI.)
const value = messages[key];
assert.ok(value, `missing override: ${key}`);
assert.doesNotMatch(
key,
/^@astryx\.(?:lightbox|chat)/,
`dead Astryx locale override: ${key}`,
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key}`,
);
}
}

it('localizes the Astryx chat chrome adopted in #1795', () => {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息');
assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部');
assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']);
});

// Whole-map sweep: every override must target a key Astryx actually ships,
// carry the same ICU arguments as the en default (a renamed placeholder
// throws at format time), and hold no Latin outside {…} segments. The pinned
// tests above cover specific regressions; this keeps the other ~70 entries
// honest without naming them one by one.
it('every zh override is a real Astryx key, translated, with matching ICU args', () => {
const require = createRequire(import.meta.url);
const catalog: Record<string, { defaultMessage: string }> = require(
'@astryxdesign/core/locales/en.json',
);
// Top-level ICU argument names only: inside `{count, plural, one {result}}`
// the `{result}` is branch text, not an argument — a naive regex would
// report it and flag every zh string that drops an inapplicable plural.
const icuArgs = (message: string) => {
const args = new Set<string>();
let depth = 0;
for (let i = 0; i < message.length; i++) {
if (message[i] === '{') {
if (depth === 0) {
const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i));
if (m?.[1]) args.add(m[1]);
}
depth++;
} else if (message[i] === '}') {
depth = Math.max(0, depth - 1);
}
}
return args;
};
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.ok(Object.keys(messages).length > 0);
for (const [key, value] of Object.entries(messages)) {
const shipped = catalog[key];
assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`);
assert.ok(value, `empty override: ${key}`);
assert.doesNotMatch(
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key} = ${value}`,
);
assert.deepEqual(
icuArgs(value),
icuArgs(shipped.defaultMessage),
`ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`,
);
}
});

it('localizes the Lightbox reached via useLightbox in chat-turn', () => {
assertChineseAstryxOverrides([
'@astryx.lightbox.mediaViewer',
'@astryx.lightbox.close',
'@astryx.lightbox.previous',
'@astryx.lightbox.next',
]);
});

it('uses the localized Astryx code block and syntax tokenizer', () => {
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/src/astryx-copy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
/**
* Chinese copy for Astryx's own message catalog, which ships no `zh`: without
* an override every `@astryx.*` string falls back to the shipped `en` catalog
* silently. Grouped by the component that renders it so a slice adopting a new
* Astryx surface can see at a glance whether its strings are already covered.
*
* Deliberately NOT exported from the package barrel (`index.ts`): the only
* consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the
* README's off-barrel convention a symbol earns barrel export only with a
* cross-package consumer. Strings that Maka's own components also render live
* in `shared-ui-copy.ts` instead and are referenced from the override map, so
* shared wording keeps one home.
*
* `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which
* resolves Astryx's shipped defaults — an `en` mirror here would be dead
* config drifting against upstream.
*/
export interface AstryxCopy {
banner: { collapse: string; expand: string };
breadcrumbs: { label: string };
calendar: {
dayInRange: string;
dayRangeEnd: string;
dayRangeStart: string;
dayRangeStartAndEnd: string;
daySelected: string;
nextMonth: string;
previousMonth: string;
rangeCompleteAnnounce: string;
rangeStartAnnounce: string;
};
chat: {
composerPlaceholder: string;
composerDrawerLabel: string;
composerInputLabel: string;
messageAriaLabel: string;
pastedTextExpand: string;
statusDelivered: string;
statusFailed: string;
statusRead: string;
statusSending: string;
statusSent: string;
drawerCollapse: string;
drawerExpand: string;
newMessages: string;
scrollToBottom: string;
toolCallsError: string;
toolCallsGroupLabel: string;
triggerSuggestions: string;
};
commandPalette: {
emptyBootstrap: string;
emptySearch: string;
inputPlaceholder: string;
label: string;
noResultsFor: string;
resultCount: string;
};
dateTime: {
closeCalendar: string;
openCalendar: string;
dialogLabel: string;
datePlaceholder: string;
timePlaceholder: string;
timeSuffix: string;
};
inputStatus: { error: string; success: string; warning: string };
lightbox: { mediaViewer: string; previous: string; next: string };
menus: { dropdown: string; more: string };
multiSelector: { clearAll: string; selectAll: string };
/** Selector and MultiSelector render the same two search affordances. */
search: { options: string; placeholder: string };
sideNav: {
label: string;
resizeSidebar: string;
collapseSidebar: string;
expandSidebar: string;
itemCollapse: string;
itemExpand: string;
};
tabList: { label: string };
table: { label: string };
thumbnail: { fallbackName: string; open: string; remove: string };
token: { remove: string };
}

export const ASTRYX_COPY_ZH: AstryxCopy = {
banner: { collapse: '收起', expand: '展开' },
breadcrumbs: { label: '面包屑导航' },
calendar: {
dayInRange: '{date},在所选范围内',
dayRangeEnd: '{date},范围结束',
dayRangeStart: '{date},范围开始',
dayRangeStartAndEnd: '{date},范围开始与结束',
daySelected: '{date},已选择',
nextMonth: '下个月',
previousMonth: '上个月',
rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。',
rangeStartAnnounce: '开始日期 {date}。请选择结束日期。',
},
chat: {
composerPlaceholder: '输入消息…',
composerDrawerLabel: '附加内容',
composerInputLabel: '消息输入框',
messageAriaLabel: '消息:{status}',
pastedTextExpand: '展开',
statusDelivered: '已送达',
statusFailed: '发送失败',
statusRead: '已读',
statusSending: '发送中',
statusSent: '已发送',
drawerCollapse: '收起{label}',
drawerExpand: '展开{label}',
newMessages: '跳到最新消息',
scrollToBottom: '滚动到底部',
toolCallsError: '错误:{message}',
toolCallsGroupLabel: '{count} 次工具调用',
triggerSuggestions: '建议',
},
commandPalette: {
emptyBootstrap: '输入以搜索',
emptySearch: '无结果',
inputPlaceholder: '搜索…',
label: '命令面板',
noResultsFor: '没有与「{query}」匹配的结果',
resultCount: '{count, number} 条结果',
},
dateTime: {
closeCalendar: '关闭日历',
openCalendar: '打开日历',
dialogLabel: '选择日期',
datePlaceholder: '选择日期',
timePlaceholder: '选择时间',
timeSuffix: '{label}时间',
},
inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' },
lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
menus: { dropdown: '菜单', more: '更多选项' },
multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
search: { options: '搜索选项', placeholder: '搜索…' },
sideNav: {
label: '侧边导航',
resizeSidebar: '调整侧边栏宽度',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
itemCollapse: '收起{label}',
itemExpand: '展开{label}',
},
tabList: { label: '标签页' },
table: { label: '表格' },
thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' },
token: { remove: '移除{label}' },
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/quote-companion.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on
// Quiet composer stages quotes as drawer Tokens (Astryx Token + remove).
const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token');
await expect(quoteTokens).toHaveCount(2);
await quoteTokens.first().getByRole('button', { name: /^Remove / }).click();
await quoteTokens.first().getByRole('button', { name: /^移除/ }).click();
await expect(quoteTokens).toHaveCount(1);

// Full text authority is the companion panel list, not truncated token labels.
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/__tests__/chat-view-empty-state.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => {
assert.equal(markup.match(/astryx-badge/g)?.length, 2);
});
});

describe('ChatView #642 streaming fallback', () => {
// The fallback renders outside TurnView, so it must localize the message
// aria-label itself — a bare ChatMessage resolves Astryx's shipped
// "Message from {sender}" and leaks English into a Chinese a11y tree.
it('localizes the fallback assistant message aria-label', () => {
// No liveTurn: a live turn projects into `turns` and takes the localized
// TurnView path instead. The fallback needs streaming with zero turns —
// wait indicators alone — which is exactly the #642 replay window.
const markup = renderToStaticMarkup(
<LocaleProvider locale="zh">
<OwnedChatView
messages={[]}
activeSession={activeSession}
processingIndicator
onNew={() => undefined}
/>
</LocaleProvider>,
);

assert.match(markup, /data-live-streaming="true"/);
assert.match(markup, /aria-label="Maka 的回答"/);
assert.doesNotMatch(markup, /Message from/);
});
});
90 changes: 85 additions & 5 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { it } from 'node:test';
Expand DownExpand Up@@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => {
assert.doesNotMatch(markup, />Checkbox</);
});

it('ships overrides only for Astryx surfaces Maka renders', () => {
// A dead-config guard used to sit here, banning override keys for Astryx
// surfaces Maka supposedly never rendered. Both of its entries rotted the
// same way: `chat` stopped being true at #1795 (ChatLayout took over the
// transcript, and the guard then blocked the fix for the English
// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx
// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban
// list keyed to "what we render today" goes stale silently, so it is gone;
// the tests below pin the surfaces we know are live instead.
function assertChineseAstryxOverrides(keys: readonly string[]) {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
for (const key of Object.keys(messages)) {
for (const key of keys) {
// Assert presence first: this reads the override map directly (no catalog
// resolution), so a deleted entry yields undefined → '' — which holds no
// Latin letters and would satisfy the translation check on its own. (At
// runtime the same missing entry falls back to Astryx's shipped en
// catalog, i.e. English in the UI.)
const value = messages[key];
assert.ok(value, `missing override: ${key}`);
assert.doesNotMatch(
key,
/^@astryx\.(?:lightbox|chat)/,
`dead Astryx locale override: ${key}`,
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key}`,
);
}
}

it('localizes the Astryx chat chrome adopted in #1795', () => {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息');
assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部');
assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']);
});

// Whole-map sweep: every override must target a key Astryx actually ships,
// carry the same ICU arguments as the en default (a renamed placeholder
// throws at format time), and hold no Latin outside {…} segments. The pinned
// tests above cover specific regressions; this keeps the other ~70 entries
// honest without naming them one by one.
it('every zh override is a real Astryx key, translated, with matching ICU args', () => {
const require = createRequire(import.meta.url);
const catalog: Record<string, { defaultMessage: string }> = require(
'@astryxdesign/core/locales/en.json',
);
// Top-level ICU argument names only: inside `{count, plural, one {result}}`
// the `{result}` is branch text, not an argument — a naive regex would
// report it and flag every zh string that drops an inapplicable plural.
const icuArgs = (message: string) => {
const args = new Set<string>();
let depth = 0;
for (let i = 0; i < message.length; i++) {
if (message[i] === '{') {
if (depth === 0) {
const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i));
if (m?.[1]) args.add(m[1]);
}
depth++;
} else if (message[i] === '}') {
depth = Math.max(0, depth - 1);
}
}
return args;
};
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.ok(Object.keys(messages).length > 0);
for (const [key, value] of Object.entries(messages)) {
const shipped = catalog[key];
assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`);
assert.ok(value, `empty override: ${key}`);
assert.doesNotMatch(
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key} = ${value}`,
);
assert.deepEqual(
icuArgs(value),
icuArgs(shipped.defaultMessage),
`ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`,
);
}
});

it('localizes the Lightbox reached via useLightbox in chat-turn', () => {
assertChineseAstryxOverrides([
'@astryx.lightbox.mediaViewer',
'@astryx.lightbox.close',
'@astryx.lightbox.previous',
'@astryx.lightbox.next',
]);
});

it('uses the localized Astryx code block and syntax tokenizer', () => {
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/src/astryx-copy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
/**
* Chinese copy for Astryx's own message catalog, which ships no `zh`: without
* an override every `@astryx.*` string falls back to the shipped `en` catalog
* silently. Grouped by the component that renders it so a slice adopting a new
* Astryx surface can see at a glance whether its strings are already covered.
*
* Deliberately NOT exported from the package barrel (`index.ts`): the only
* consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the
* README's off-barrel convention a symbol earns barrel export only with a
* cross-package consumer. Strings that Maka's own components also render live
* in `shared-ui-copy.ts` instead and are referenced from the override map, so
* shared wording keeps one home.
*
* `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which
* resolves Astryx's shipped defaults — an `en` mirror here would be dead
* config drifting against upstream.
*/
export interface AstryxCopy {
banner: { collapse: string; expand: string };
breadcrumbs: { label: string };
calendar: {
dayInRange: string;
dayRangeEnd: string;
dayRangeStart: string;
dayRangeStartAndEnd: string;
daySelected: string;
nextMonth: string;
previousMonth: string;
rangeCompleteAnnounce: string;
rangeStartAnnounce: string;
};
chat: {
composerPlaceholder: string;
composerDrawerLabel: string;
composerInputLabel: string;
messageAriaLabel: string;
pastedTextExpand: string;
statusDelivered: string;
statusFailed: string;
statusRead: string;
statusSending: string;
statusSent: string;
drawerCollapse: string;
drawerExpand: string;
newMessages: string;
scrollToBottom: string;
toolCallsError: string;
toolCallsGroupLabel: string;
triggerSuggestions: string;
};
commandPalette: {
emptyBootstrap: string;
emptySearch: string;
inputPlaceholder: string;
label: string;
noResultsFor: string;
resultCount: string;
};
dateTime: {
closeCalendar: string;
openCalendar: string;
dialogLabel: string;
datePlaceholder: string;
timePlaceholder: string;
timeSuffix: string;
};
inputStatus: { error: string; success: string; warning: string };
lightbox: { mediaViewer: string; previous: string; next: string };
menus: { dropdown: string; more: string };
multiSelector: { clearAll: string; selectAll: string };
/** Selector and MultiSelector render the same two search affordances. */
search: { options: string; placeholder: string };
sideNav: {
label: string;
resizeSidebar: string;
collapseSidebar: string;
expandSidebar: string;
itemCollapse: string;
itemExpand: string;
};
tabList: { label: string };
table: { label: string };
thumbnail: { fallbackName: string; open: string; remove: string };
token: { remove: string };
}

export const ASTRYX_COPY_ZH: AstryxCopy = {
banner: { collapse: '收起', expand: '展开' },
breadcrumbs: { label: '面包屑导航' },
calendar: {
dayInRange: '{date},在所选范围内',
dayRangeEnd: '{date},范围结束',
dayRangeStart: '{date},范围开始',
dayRangeStartAndEnd: '{date},范围开始与结束',
daySelected: '{date},已选择',
nextMonth: '下个月',
previousMonth: '上个月',
rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。',
rangeStartAnnounce: '开始日期 {date}。请选择结束日期。',
},
chat: {
composerPlaceholder: '输入消息…',
composerDrawerLabel: '附加内容',
composerInputLabel: '消息输入框',
messageAriaLabel: '消息:{status}',
pastedTextExpand: '展开',
statusDelivered: '已送达',
statusFailed: '发送失败',
statusRead: '已读',
statusSending: '发送中',
statusSent: '已发送',
drawerCollapse: '收起{label}',
drawerExpand: '展开{label}',
newMessages: '跳到最新消息',
scrollToBottom: '滚动到底部',
toolCallsError: '错误:{message}',
toolCallsGroupLabel: '{count} 次工具调用',
triggerSuggestions: '建议',
},
commandPalette: {
emptyBootstrap: '输入以搜索',
emptySearch: '无结果',
inputPlaceholder: '搜索…',
label: '命令面板',
noResultsFor: '没有与「{query}」匹配的结果',
resultCount: '{count, number} 条结果',
},
dateTime: {
closeCalendar: '关闭日历',
openCalendar: '打开日历',
dialogLabel: '选择日期',
datePlaceholder: '选择日期',
timePlaceholder: '选择时间',
timeSuffix: '{label}时间',
},
inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' },
lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
menus: { dropdown: '菜单', more: '更多选项' },
multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
search: { options: '搜索选项', placeholder: '搜索…' },
sideNav: {
label: '侧边导航',
resizeSidebar: '调整侧边栏宽度',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
itemCollapse: '收起{label}',
itemExpand: '展开{label}',
},
tabList: { label: '标签页' },
table: { label: '表格' },
thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' },
token: { remove: '移除{label}' },
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/quote-companion.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on
// Quiet composer stages quotes as drawer Tokens (Astryx Token + remove).
const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token');
await expect(quoteTokens).toHaveCount(2);
await quoteTokens.first().getByRole('button', { name: /^Remove / }).click();
await quoteTokens.first().getByRole('button', { name: /^移除/ }).click();
await expect(quoteTokens).toHaveCount(1);

// Full text authority is the companion panel list, not truncated token labels.
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/__tests__/chat-view-empty-state.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => {
assert.equal(markup.match(/astryx-badge/g)?.length, 2);
});
});

describe('ChatView #642 streaming fallback', () => {
// The fallback renders outside TurnView, so it must localize the message
// aria-label itself — a bare ChatMessage resolves Astryx's shipped
// "Message from {sender}" and leaks English into a Chinese a11y tree.
it('localizes the fallback assistant message aria-label', () => {
// No liveTurn: a live turn projects into `turns` and takes the localized
// TurnView path instead. The fallback needs streaming with zero turns —
// wait indicators alone — which is exactly the #642 replay window.
const markup = renderToStaticMarkup(
<LocaleProvider locale="zh">
<OwnedChatView
messages={[]}
activeSession={activeSession}
processingIndicator
onNew={() => undefined}
/>
</LocaleProvider>,
);

assert.match(markup, /data-live-streaming="true"/);
assert.match(markup, /aria-label="Maka 的回答"/);
assert.doesNotMatch(markup, /Message from/);
});
});
90 changes: 85 additions & 5 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { it } from 'node:test';
Expand DownExpand Up@@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => {
assert.doesNotMatch(markup, />Checkbox</);
});

it('ships overrides only for Astryx surfaces Maka renders', () => {
// A dead-config guard used to sit here, banning override keys for Astryx
// surfaces Maka supposedly never rendered. Both of its entries rotted the
// same way: `chat` stopped being true at #1795 (ChatLayout took over the
// transcript, and the guard then blocked the fix for the English
// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx
// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban
// list keyed to "what we render today" goes stale silently, so it is gone;
// the tests below pin the surfaces we know are live instead.
function assertChineseAstryxOverrides(keys: readonly string[]) {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
for (const key of Object.keys(messages)) {
for (const key of keys) {
// Assert presence first: this reads the override map directly (no catalog
// resolution), so a deleted entry yields undefined → '' — which holds no
// Latin letters and would satisfy the translation check on its own. (At
// runtime the same missing entry falls back to Astryx's shipped en
// catalog, i.e. English in the UI.)
const value = messages[key];
assert.ok(value, `missing override: ${key}`);
assert.doesNotMatch(
key,
/^@astryx\.(?:lightbox|chat)/,
`dead Astryx locale override: ${key}`,
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key}`,
);
}
}

it('localizes the Astryx chat chrome adopted in #1795', () => {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息');
assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部');
assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']);
});

// Whole-map sweep: every override must target a key Astryx actually ships,
// carry the same ICU arguments as the en default (a renamed placeholder
// throws at format time), and hold no Latin outside {…} segments. The pinned
// tests above cover specific regressions; this keeps the other ~70 entries
// honest without naming them one by one.
it('every zh override is a real Astryx key, translated, with matching ICU args', () => {
const require = createRequire(import.meta.url);
const catalog: Record<string, { defaultMessage: string }> = require(
'@astryxdesign/core/locales/en.json',
);
// Top-level ICU argument names only: inside `{count, plural, one {result}}`
// the `{result}` is branch text, not an argument — a naive regex would
// report it and flag every zh string that drops an inapplicable plural.
const icuArgs = (message: string) => {
const args = new Set<string>();
let depth = 0;
for (let i = 0; i < message.length; i++) {
if (message[i] === '{') {
if (depth === 0) {
const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i));
if (m?.[1]) args.add(m[1]);
}
depth++;
} else if (message[i] === '}') {
depth = Math.max(0, depth - 1);
}
}
return args;
};
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.ok(Object.keys(messages).length > 0);
for (const [key, value] of Object.entries(messages)) {
const shipped = catalog[key];
assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`);
assert.ok(value, `empty override: ${key}`);
assert.doesNotMatch(
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key} = ${value}`,
);
assert.deepEqual(
icuArgs(value),
icuArgs(shipped.defaultMessage),
`ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`,
);
}
});

it('localizes the Lightbox reached via useLightbox in chat-turn', () => {
assertChineseAstryxOverrides([
'@astryx.lightbox.mediaViewer',
'@astryx.lightbox.close',
'@astryx.lightbox.previous',
'@astryx.lightbox.next',
]);
});

it('uses the localized Astryx code block and syntax tokenizer', () => {
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/src/astryx-copy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
/**
* Chinese copy for Astryx's own message catalog, which ships no `zh`: without
* an override every `@astryx.*` string falls back to the shipped `en` catalog
* silently. Grouped by the component that renders it so a slice adopting a new
* Astryx surface can see at a glance whether its strings are already covered.
*
* Deliberately NOT exported from the package barrel (`index.ts`): the only
* consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the
* README's off-barrel convention a symbol earns barrel export only with a
* cross-package consumer. Strings that Maka's own components also render live
* in `shared-ui-copy.ts` instead and are referenced from the override map, so
* shared wording keeps one home.
*
* `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which
* resolves Astryx's shipped defaults — an `en` mirror here would be dead
* config drifting against upstream.
*/
export interface AstryxCopy {
banner: { collapse: string; expand: string };
breadcrumbs: { label: string };
calendar: {
dayInRange: string;
dayRangeEnd: string;
dayRangeStart: string;
dayRangeStartAndEnd: string;
daySelected: string;
nextMonth: string;
previousMonth: string;
rangeCompleteAnnounce: string;
rangeStartAnnounce: string;
};
chat: {
composerPlaceholder: string;
composerDrawerLabel: string;
composerInputLabel: string;
messageAriaLabel: string;
pastedTextExpand: string;
statusDelivered: string;
statusFailed: string;
statusRead: string;
statusSending: string;
statusSent: string;
drawerCollapse: string;
drawerExpand: string;
newMessages: string;
scrollToBottom: string;
toolCallsError: string;
toolCallsGroupLabel: string;
triggerSuggestions: string;
};
commandPalette: {
emptyBootstrap: string;
emptySearch: string;
inputPlaceholder: string;
label: string;
noResultsFor: string;
resultCount: string;
};
dateTime: {
closeCalendar: string;
openCalendar: string;
dialogLabel: string;
datePlaceholder: string;
timePlaceholder: string;
timeSuffix: string;
};
inputStatus: { error: string; success: string; warning: string };
lightbox: { mediaViewer: string; previous: string; next: string };
menus: { dropdown: string; more: string };
multiSelector: { clearAll: string; selectAll: string };
/** Selector and MultiSelector render the same two search affordances. */
search: { options: string; placeholder: string };
sideNav: {
label: string;
resizeSidebar: string;
collapseSidebar: string;
expandSidebar: string;
itemCollapse: string;
itemExpand: string;
};
tabList: { label: string };
table: { label: string };
thumbnail: { fallbackName: string; open: string; remove: string };
token: { remove: string };
}

export const ASTRYX_COPY_ZH: AstryxCopy = {
banner: { collapse: '收起', expand: '展开' },
breadcrumbs: { label: '面包屑导航' },
calendar: {
dayInRange: '{date},在所选范围内',
dayRangeEnd: '{date},范围结束',
dayRangeStart: '{date},范围开始',
dayRangeStartAndEnd: '{date},范围开始与结束',
daySelected: '{date},已选择',
nextMonth: '下个月',
previousMonth: '上个月',
rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。',
rangeStartAnnounce: '开始日期 {date}。请选择结束日期。',
},
chat: {
composerPlaceholder: '输入消息…',
composerDrawerLabel: '附加内容',
composerInputLabel: '消息输入框',
messageAriaLabel: '消息:{status}',
pastedTextExpand: '展开',
statusDelivered: '已送达',
statusFailed: '发送失败',
statusRead: '已读',
statusSending: '发送中',
statusSent: '已发送',
drawerCollapse: '收起{label}',
drawerExpand: '展开{label}',
newMessages: '跳到最新消息',
scrollToBottom: '滚动到底部',
toolCallsError: '错误:{message}',
toolCallsGroupLabel: '{count} 次工具调用',
triggerSuggestions: '建议',
},
commandPalette: {
emptyBootstrap: '输入以搜索',
emptySearch: '无结果',
inputPlaceholder: '搜索…',
label: '命令面板',
noResultsFor: '没有与「{query}」匹配的结果',
resultCount: '{count, number} 条结果',
},
dateTime: {
closeCalendar: '关闭日历',
openCalendar: '打开日历',
dialogLabel: '选择日期',
datePlaceholder: '选择日期',
timePlaceholder: '选择时间',
timeSuffix: '{label}时间',
},
inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' },
lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
menus: { dropdown: '菜单', more: '更多选项' },
multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
search: { options: '搜索选项', placeholder: '搜索…' },
sideNav: {
label: '侧边导航',
resizeSidebar: '调整侧边栏宽度',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
itemCollapse: '收起{label}',
itemExpand: '展开{label}',
},
tabList: { label: '标签页' },
table: { label: '表格' },
thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' },
token: { remove: '移除{label}' },
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/quote-companion.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on
// Quiet composer stages quotes as drawer Tokens (Astryx Token + remove).
const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token');
await expect(quoteTokens).toHaveCount(2);
await quoteTokens.first().getByRole('button', { name: /^Remove / }).click();
await quoteTokens.first().getByRole('button', { name: /^移除/ }).click();
await expect(quoteTokens).toHaveCount(1);

// Full text authority is the companion panel list, not truncated token labels.
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/__tests__/chat-view-empty-state.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => {
assert.equal(markup.match(/astryx-badge/g)?.length, 2);
});
});

describe('ChatView #642 streaming fallback', () => {
// The fallback renders outside TurnView, so it must localize the message
// aria-label itself — a bare ChatMessage resolves Astryx's shipped
// "Message from {sender}" and leaks English into a Chinese a11y tree.
it('localizes the fallback assistant message aria-label', () => {
// No liveTurn: a live turn projects into `turns` and takes the localized
// TurnView path instead. The fallback needs streaming with zero turns —
// wait indicators alone — which is exactly the #642 replay window.
const markup = renderToStaticMarkup(
<LocaleProvider locale="zh">
<OwnedChatView
messages={[]}
activeSession={activeSession}
processingIndicator
onNew={() => undefined}
/>
</LocaleProvider>,
);

assert.match(markup, /data-live-streaming="true"/);
assert.match(markup, /aria-label="Maka 的回答"/);
assert.doesNotMatch(markup, /Message from/);
});
});
90 changes: 85 additions & 5 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { it } from 'node:test';
Expand DownExpand Up@@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => {
assert.doesNotMatch(markup, />Checkbox</);
});

it('ships overrides only for Astryx surfaces Maka renders', () => {
// A dead-config guard used to sit here, banning override keys for Astryx
// surfaces Maka supposedly never rendered. Both of its entries rotted the
// same way: `chat` stopped being true at #1795 (ChatLayout took over the
// transcript, and the guard then blocked the fix for the English
// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx
// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban
// list keyed to "what we render today" goes stale silently, so it is gone;
// the tests below pin the surfaces we know are live instead.
function assertChineseAstryxOverrides(keys: readonly string[]) {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
for (const key of Object.keys(messages)) {
for (const key of keys) {
// Assert presence first: this reads the override map directly (no catalog
// resolution), so a deleted entry yields undefined → '' — which holds no
// Latin letters and would satisfy the translation check on its own. (At
// runtime the same missing entry falls back to Astryx's shipped en
// catalog, i.e. English in the UI.)
const value = messages[key];
assert.ok(value, `missing override: ${key}`);
assert.doesNotMatch(
key,
/^@astryx\.(?:lightbox|chat)/,
`dead Astryx locale override: ${key}`,
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key}`,
);
}
}

it('localizes the Astryx chat chrome adopted in #1795', () => {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息');
assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部');
assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']);
});

// Whole-map sweep: every override must target a key Astryx actually ships,
// carry the same ICU arguments as the en default (a renamed placeholder
// throws at format time), and hold no Latin outside {…} segments. The pinned
// tests above cover specific regressions; this keeps the other ~70 entries
// honest without naming them one by one.
it('every zh override is a real Astryx key, translated, with matching ICU args', () => {
const require = createRequire(import.meta.url);
const catalog: Record<string, { defaultMessage: string }> = require(
'@astryxdesign/core/locales/en.json',
);
// Top-level ICU argument names only: inside `{count, plural, one {result}}`
// the `{result}` is branch text, not an argument — a naive regex would
// report it and flag every zh string that drops an inapplicable plural.
const icuArgs = (message: string) => {
const args = new Set<string>();
let depth = 0;
for (let i = 0; i < message.length; i++) {
if (message[i] === '{') {
if (depth === 0) {
const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i));
if (m?.[1]) args.add(m[1]);
}
depth++;
} else if (message[i] === '}') {
depth = Math.max(0, depth - 1);
}
}
return args;
};
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.ok(Object.keys(messages).length > 0);
for (const [key, value] of Object.entries(messages)) {
const shipped = catalog[key];
assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`);
assert.ok(value, `empty override: ${key}`);
assert.doesNotMatch(
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key} = ${value}`,
);
assert.deepEqual(
icuArgs(value),
icuArgs(shipped.defaultMessage),
`ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`,
);
}
});

it('localizes the Lightbox reached via useLightbox in chat-turn', () => {
assertChineseAstryxOverrides([
'@astryx.lightbox.mediaViewer',
'@astryx.lightbox.close',
'@astryx.lightbox.previous',
'@astryx.lightbox.next',
]);
});

it('uses the localized Astryx code block and syntax tokenizer', () => {
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/src/astryx-copy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
/**
* Chinese copy for Astryx's own message catalog, which ships no `zh`: without
* an override every `@astryx.*` string falls back to the shipped `en` catalog
* silently. Grouped by the component that renders it so a slice adopting a new
* Astryx surface can see at a glance whether its strings are already covered.
*
* Deliberately NOT exported from the package barrel (`index.ts`): the only
* consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the
* README's off-barrel convention a symbol earns barrel export only with a
* cross-package consumer. Strings that Maka's own components also render live
* in `shared-ui-copy.ts` instead and are referenced from the override map, so
* shared wording keeps one home.
*
* `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which
* resolves Astryx's shipped defaults — an `en` mirror here would be dead
* config drifting against upstream.
*/
export interface AstryxCopy {
banner: { collapse: string; expand: string };
breadcrumbs: { label: string };
calendar: {
dayInRange: string;
dayRangeEnd: string;
dayRangeStart: string;
dayRangeStartAndEnd: string;
daySelected: string;
nextMonth: string;
previousMonth: string;
rangeCompleteAnnounce: string;
rangeStartAnnounce: string;
};
chat: {
composerPlaceholder: string;
composerDrawerLabel: string;
composerInputLabel: string;
messageAriaLabel: string;
pastedTextExpand: string;
statusDelivered: string;
statusFailed: string;
statusRead: string;
statusSending: string;
statusSent: string;
drawerCollapse: string;
drawerExpand: string;
newMessages: string;
scrollToBottom: string;
toolCallsError: string;
toolCallsGroupLabel: string;
triggerSuggestions: string;
};
commandPalette: {
emptyBootstrap: string;
emptySearch: string;
inputPlaceholder: string;
label: string;
noResultsFor: string;
resultCount: string;
};
dateTime: {
closeCalendar: string;
openCalendar: string;
dialogLabel: string;
datePlaceholder: string;
timePlaceholder: string;
timeSuffix: string;
};
inputStatus: { error: string; success: string; warning: string };
lightbox: { mediaViewer: string; previous: string; next: string };
menus: { dropdown: string; more: string };
multiSelector: { clearAll: string; selectAll: string };
/** Selector and MultiSelector render the same two search affordances. */
search: { options: string; placeholder: string };
sideNav: {
label: string;
resizeSidebar: string;
collapseSidebar: string;
expandSidebar: string;
itemCollapse: string;
itemExpand: string;
};
tabList: { label: string };
table: { label: string };
thumbnail: { fallbackName: string; open: string; remove: string };
token: { remove: string };
}

export const ASTRYX_COPY_ZH: AstryxCopy = {
banner: { collapse: '收起', expand: '展开' },
breadcrumbs: { label: '面包屑导航' },
calendar: {
dayInRange: '{date},在所选范围内',
dayRangeEnd: '{date},范围结束',
dayRangeStart: '{date},范围开始',
dayRangeStartAndEnd: '{date},范围开始与结束',
daySelected: '{date},已选择',
nextMonth: '下个月',
previousMonth: '上个月',
rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。',
rangeStartAnnounce: '开始日期 {date}。请选择结束日期。',
},
chat: {
composerPlaceholder: '输入消息…',
composerDrawerLabel: '附加内容',
composerInputLabel: '消息输入框',
messageAriaLabel: '消息:{status}',
pastedTextExpand: '展开',
statusDelivered: '已送达',
statusFailed: '发送失败',
statusRead: '已读',
statusSending: '发送中',
statusSent: '已发送',
drawerCollapse: '收起{label}',
drawerExpand: '展开{label}',
newMessages: '跳到最新消息',
scrollToBottom: '滚动到底部',
toolCallsError: '错误:{message}',
toolCallsGroupLabel: '{count} 次工具调用',
triggerSuggestions: '建议',
},
commandPalette: {
emptyBootstrap: '输入以搜索',
emptySearch: '无结果',
inputPlaceholder: '搜索…',
label: '命令面板',
noResultsFor: '没有与「{query}」匹配的结果',
resultCount: '{count, number} 条结果',
},
dateTime: {
closeCalendar: '关闭日历',
openCalendar: '打开日历',
dialogLabel: '选择日期',
datePlaceholder: '选择日期',
timePlaceholder: '选择时间',
timeSuffix: '{label}时间',
},
inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' },
lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
menus: { dropdown: '菜单', more: '更多选项' },
multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
search: { options: '搜索选项', placeholder: '搜索…' },
sideNav: {
label: '侧边导航',
resizeSidebar: '调整侧边栏宽度',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
itemCollapse: '收起{label}',
itemExpand: '展开{label}',
},
tabList: { label: '标签页' },
table: { label: '表格' },
thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' },
token: { remove: '移除{label}' },
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/quote-companion.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on
// Quiet composer stages quotes as drawer Tokens (Astryx Token + remove).
const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token');
await expect(quoteTokens).toHaveCount(2);
await quoteTokens.first().getByRole('button', { name: /^Remove / }).click();
await quoteTokens.first().getByRole('button', { name: /^移除/ }).click();
await expect(quoteTokens).toHaveCount(1);

// Full text authority is the companion panel list, not truncated token labels.
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/__tests__/chat-view-empty-state.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => {
assert.equal(markup.match(/astryx-badge/g)?.length, 2);
});
});

describe('ChatView #642 streaming fallback', () => {
// The fallback renders outside TurnView, so it must localize the message
// aria-label itself — a bare ChatMessage resolves Astryx's shipped
// "Message from {sender}" and leaks English into a Chinese a11y tree.
it('localizes the fallback assistant message aria-label', () => {
// No liveTurn: a live turn projects into `turns` and takes the localized
// TurnView path instead. The fallback needs streaming with zero turns —
// wait indicators alone — which is exactly the #642 replay window.
const markup = renderToStaticMarkup(
<LocaleProvider locale="zh">
<OwnedChatView
messages={[]}
activeSession={activeSession}
processingIndicator
onNew={() => undefined}
/>
</LocaleProvider>,
);

assert.match(markup, /data-live-streaming="true"/);
assert.match(markup, /aria-label="Maka 的回答"/);
assert.doesNotMatch(markup, /Message from/);
});
});
90 changes: 85 additions & 5 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { it } from 'node:test';
Expand DownExpand Up@@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => {
assert.doesNotMatch(markup, />Checkbox</);
});

it('ships overrides only for Astryx surfaces Maka renders', () => {
// A dead-config guard used to sit here, banning override keys for Astryx
// surfaces Maka supposedly never rendered. Both of its entries rotted the
// same way: `chat` stopped being true at #1795 (ChatLayout took over the
// transcript, and the guard then blocked the fix for the English
// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx
// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban
// list keyed to "what we render today" goes stale silently, so it is gone;
// the tests below pin the surfaces we know are live instead.
function assertChineseAstryxOverrides(keys: readonly string[]) {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
for (const key of Object.keys(messages)) {
for (const key of keys) {
// Assert presence first: this reads the override map directly (no catalog
// resolution), so a deleted entry yields undefined → '' — which holds no
// Latin letters and would satisfy the translation check on its own. (At
// runtime the same missing entry falls back to Astryx's shipped en
// catalog, i.e. English in the UI.)
const value = messages[key];
assert.ok(value, `missing override: ${key}`);
assert.doesNotMatch(
key,
/^@astryx\.(?:lightbox|chat)/,
`dead Astryx locale override: ${key}`,
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key}`,
);
}
}

it('localizes the Astryx chat chrome adopted in #1795', () => {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息');
assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部');
assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']);
});

// Whole-map sweep: every override must target a key Astryx actually ships,
// carry the same ICU arguments as the en default (a renamed placeholder
// throws at format time), and hold no Latin outside {…} segments. The pinned
// tests above cover specific regressions; this keeps the other ~70 entries
// honest without naming them one by one.
it('every zh override is a real Astryx key, translated, with matching ICU args', () => {
const require = createRequire(import.meta.url);
const catalog: Record<string, { defaultMessage: string }> = require(
'@astryxdesign/core/locales/en.json',
);
// Top-level ICU argument names only: inside `{count, plural, one {result}}`
// the `{result}` is branch text, not an argument — a naive regex would
// report it and flag every zh string that drops an inapplicable plural.
const icuArgs = (message: string) => {
const args = new Set<string>();
let depth = 0;
for (let i = 0; i < message.length; i++) {
if (message[i] === '{') {
if (depth === 0) {
const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i));
if (m?.[1]) args.add(m[1]);
}
depth++;
} else if (message[i] === '}') {
depth = Math.max(0, depth - 1);
}
}
return args;
};
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.ok(Object.keys(messages).length > 0);
for (const [key, value] of Object.entries(messages)) {
const shipped = catalog[key];
assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`);
assert.ok(value, `empty override: ${key}`);
assert.doesNotMatch(
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key} = ${value}`,
);
assert.deepEqual(
icuArgs(value),
icuArgs(shipped.defaultMessage),
`ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`,
);
}
});

it('localizes the Lightbox reached via useLightbox in chat-turn', () => {
assertChineseAstryxOverrides([
'@astryx.lightbox.mediaViewer',
'@astryx.lightbox.close',
'@astryx.lightbox.previous',
'@astryx.lightbox.next',
]);
});

it('uses the localized Astryx code block and syntax tokenizer', () => {
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/src/astryx-copy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
/**
* Chinese copy for Astryx's own message catalog, which ships no `zh`: without
* an override every `@astryx.*` string falls back to the shipped `en` catalog
* silently. Grouped by the component that renders it so a slice adopting a new
* Astryx surface can see at a glance whether its strings are already covered.
*
* Deliberately NOT exported from the package barrel (`index.ts`): the only
* consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the
* README's off-barrel convention a symbol earns barrel export only with a
* cross-package consumer. Strings that Maka's own components also render live
* in `shared-ui-copy.ts` instead and are referenced from the override map, so
* shared wording keeps one home.
*
* `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which
* resolves Astryx's shipped defaults — an `en` mirror here would be dead
* config drifting against upstream.
*/
export interface AstryxCopy {
banner: { collapse: string; expand: string };
breadcrumbs: { label: string };
calendar: {
dayInRange: string;
dayRangeEnd: string;
dayRangeStart: string;
dayRangeStartAndEnd: string;
daySelected: string;
nextMonth: string;
previousMonth: string;
rangeCompleteAnnounce: string;
rangeStartAnnounce: string;
};
chat: {
composerPlaceholder: string;
composerDrawerLabel: string;
composerInputLabel: string;
messageAriaLabel: string;
pastedTextExpand: string;
statusDelivered: string;
statusFailed: string;
statusRead: string;
statusSending: string;
statusSent: string;
drawerCollapse: string;
drawerExpand: string;
newMessages: string;
scrollToBottom: string;
toolCallsError: string;
toolCallsGroupLabel: string;
triggerSuggestions: string;
};
commandPalette: {
emptyBootstrap: string;
emptySearch: string;
inputPlaceholder: string;
label: string;
noResultsFor: string;
resultCount: string;
};
dateTime: {
closeCalendar: string;
openCalendar: string;
dialogLabel: string;
datePlaceholder: string;
timePlaceholder: string;
timeSuffix: string;
};
inputStatus: { error: string; success: string; warning: string };
lightbox: { mediaViewer: string; previous: string; next: string };
menus: { dropdown: string; more: string };
multiSelector: { clearAll: string; selectAll: string };
/** Selector and MultiSelector render the same two search affordances. */
search: { options: string; placeholder: string };
sideNav: {
label: string;
resizeSidebar: string;
collapseSidebar: string;
expandSidebar: string;
itemCollapse: string;
itemExpand: string;
};
tabList: { label: string };
table: { label: string };
thumbnail: { fallbackName: string; open: string; remove: string };
token: { remove: string };
}

export const ASTRYX_COPY_ZH: AstryxCopy = {
banner: { collapse: '收起', expand: '展开' },
breadcrumbs: { label: '面包屑导航' },
calendar: {
dayInRange: '{date},在所选范围内',
dayRangeEnd: '{date},范围结束',
dayRangeStart: '{date},范围开始',
dayRangeStartAndEnd: '{date},范围开始与结束',
daySelected: '{date},已选择',
nextMonth: '下个月',
previousMonth: '上个月',
rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。',
rangeStartAnnounce: '开始日期 {date}。请选择结束日期。',
},
chat: {
composerPlaceholder: '输入消息…',
composerDrawerLabel: '附加内容',
composerInputLabel: '消息输入框',
messageAriaLabel: '消息:{status}',
pastedTextExpand: '展开',
statusDelivered: '已送达',
statusFailed: '发送失败',
statusRead: '已读',
statusSending: '发送中',
statusSent: '已发送',
drawerCollapse: '收起{label}',
drawerExpand: '展开{label}',
newMessages: '跳到最新消息',
scrollToBottom: '滚动到底部',
toolCallsError: '错误:{message}',
toolCallsGroupLabel: '{count} 次工具调用',
triggerSuggestions: '建议',
},
commandPalette: {
emptyBootstrap: '输入以搜索',
emptySearch: '无结果',
inputPlaceholder: '搜索…',
label: '命令面板',
noResultsFor: '没有与「{query}」匹配的结果',
resultCount: '{count, number} 条结果',
},
dateTime: {
closeCalendar: '关闭日历',
openCalendar: '打开日历',
dialogLabel: '选择日期',
datePlaceholder: '选择日期',
timePlaceholder: '选择时间',
timeSuffix: '{label}时间',
},
inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' },
lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
menus: { dropdown: '菜单', more: '更多选项' },
multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
search: { options: '搜索选项', placeholder: '搜索…' },
sideNav: {
label: '侧边导航',
resizeSidebar: '调整侧边栏宽度',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
itemCollapse: '收起{label}',
itemExpand: '展开{label}',
},
tabList: { label: '标签页' },
table: { label: '表格' },
thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' },
token: { remove: '移除{label}' },
};
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/quote-companion.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on
// Quiet composer stages quotes as drawer Tokens (Astryx Token + remove).
const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token');
await expect(quoteTokens).toHaveCount(2);
await quoteTokens.first().getByRole('button', { name: /^Remove / }).click();
await quoteTokens.first().getByRole('button', { name: /^移除/ }).click();
await expect(quoteTokens).toHaveCount(1);

// Full text authority is the companion panel list, not truncated token labels.
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/__tests__/chat-view-empty-state.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => {
assert.equal(markup.match(/astryx-badge/g)?.length, 2);
});
});

describe('ChatView #642 streaming fallback', () => {
// The fallback renders outside TurnView, so it must localize the message
// aria-label itself — a bare ChatMessage resolves Astryx's shipped
// "Message from {sender}" and leaks English into a Chinese a11y tree.
it('localizes the fallback assistant message aria-label', () => {
// No liveTurn: a live turn projects into `turns` and takes the localized
// TurnView path instead. The fallback needs streaming with zero turns —
// wait indicators alone — which is exactly the #642 replay window.
const markup = renderToStaticMarkup(
<LocaleProvider locale="zh">
<OwnedChatView
messages={[]}
activeSession={activeSession}
processingIndicator
onNew={() => undefined}
/>
</LocaleProvider>,
);

assert.match(markup, /data-live-streaming="true"/);
assert.match(markup, /aria-label="Maka 的回答"/);
assert.doesNotMatch(markup, /Message from/);
});
});
90 changes: 85 additions & 5 deletions packages/ui/src/__tests__/markdown-body.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { it } from 'node:test';
Expand DownExpand Up@@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => {
assert.doesNotMatch(markup, />Checkbox</);
});

it('ships overrides only for Astryx surfaces Maka renders', () => {
// A dead-config guard used to sit here, banning override keys for Astryx
// surfaces Maka supposedly never rendered. Both of its entries rotted the
// same way: `chat` stopped being true at #1795 (ChatLayout took over the
// transcript, and the guard then blocked the fix for the English
// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx
// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban
// list keyed to "what we render today" goes stale silently, so it is gone;
// the tests below pin the surfaces we know are live instead.
function assertChineseAstryxOverrides(keys: readonly string[]) {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
for (const key of Object.keys(messages)) {
for (const key of keys) {
// Assert presence first: this reads the override map directly (no catalog
// resolution), so a deleted entry yields undefined → '' — which holds no
// Latin letters and would satisfy the translation check on its own. (At
// runtime the same missing entry falls back to Astryx's shipped en
// catalog, i.e. English in the UI.)
const value = messages[key];
assert.ok(value, `missing override: ${key}`);
assert.doesNotMatch(
key,
/^@astryx\.(?:lightbox|chat)/,
`dead Astryx locale override: ${key}`,
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key}`,
);
}
}

it('localizes the Astryx chat chrome adopted in #1795', () => {
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息');
assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部');
assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']);
});

// Whole-map sweep: every override must target a key Astryx actually ships,
// carry the same ICU arguments as the en default (a renamed placeholder
// throws at format time), and hold no Latin outside {…} segments. The pinned
// tests above cover specific regressions; this keeps the other ~70 entries
// honest without naming them one by one.
it('every zh override is a real Astryx key, translated, with matching ICU args', () => {
const require = createRequire(import.meta.url);
const catalog: Record<string, { defaultMessage: string }> = require(
'@astryxdesign/core/locales/en.json',
);
// Top-level ICU argument names only: inside `{count, plural, one {result}}`
// the `{result}` is branch text, not an argument — a naive regex would
// report it and flag every zh string that drops an inapplicable plural.
const icuArgs = (message: string) => {
const args = new Set<string>();
let depth = 0;
for (let i = 0; i < message.length; i++) {
if (message[i] === '{') {
if (depth === 0) {
const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i));
if (m?.[1]) args.add(m[1]);
}
depth++;
} else if (message[i] === '}') {
depth = Math.max(0, depth - 1);
}
}
return args;
};
const messages = astryxMessageOverrides('zh')?.zh ?? {};
assert.ok(Object.keys(messages).length > 0);
for (const [key, value] of Object.entries(messages)) {
const shipped = catalog[key];
assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`);
assert.ok(value, `empty override: ${key}`);
assert.doesNotMatch(
value.replace(/\{[^}]*\}/g, ''),
/[A-Za-z]/,
`untranslated: ${key} = ${value}`,
);
assert.deepEqual(
icuArgs(value),
icuArgs(shipped.defaultMessage),
`ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`,
);
}
});

it('localizes the Lightbox reached via useLightbox in chat-turn', () => {
assertChineseAstryxOverrides([
'@astryx.lightbox.mediaViewer',
'@astryx.lightbox.close',
'@astryx.lightbox.previous',
'@astryx.lightbox.next',
]);
});

it('uses the localized Astryx code block and syntax tokenizer', () => {
Expand Down
153 changes: 153 additions & 0 deletions packages/ui/src/astryx-copy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
/**
* Chinese copy for Astryx's own message catalog, which ships no `zh`: without
* an override every `@astryx.*` string falls back to the shipped `en` catalog
* silently. Grouped by the component that renders it so a slice adopting a new
* Astryx surface can see at a glance whether its strings are already covered.
*
* Deliberately NOT exported from the package barrel (`index.ts`): the only
* consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the
* README's off-barrel convention a symbol earns barrel export only with a
* cross-package consumer. Strings that Maka's own components also render live
* in `shared-ui-copy.ts` instead and are referenced from the override map, so
* shared wording keeps one home.
*
* `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which
* resolves Astryx's shipped defaults — an `en` mirror here would be dead
* config drifting against upstream.
*/
export interface AstryxCopy {
banner: { collapse: string; expand: string };
breadcrumbs: { label: string };
calendar: {
dayInRange: string;
dayRangeEnd: string;
dayRangeStart: string;
dayRangeStartAndEnd: string;
daySelected: string;
nextMonth: string;
previousMonth: string;
rangeCompleteAnnounce: string;
rangeStartAnnounce: string;
};
chat: {
composerPlaceholder: string;
composerDrawerLabel: string;
composerInputLabel: string;
messageAriaLabel: string;
pastedTextExpand: string;
statusDelivered: string;
statusFailed: string;
statusRead: string;
statusSending: string;
statusSent: string;
drawerCollapse: string;
drawerExpand: string;
newMessages: string;
scrollToBottom: string;
toolCallsError: string;
toolCallsGroupLabel: string;
triggerSuggestions: string;
};
commandPalette: {
emptyBootstrap: string;
emptySearch: string;
inputPlaceholder: string;
label: string;
noResultsFor: string;
resultCount: string;
};
dateTime: {
closeCalendar: string;
openCalendar: string;
dialogLabel: string;
datePlaceholder: string;
timePlaceholder: string;
timeSuffix: string;
};
inputStatus: { error: string; success: string; warning: string };
lightbox: { mediaViewer: string; previous: string; next: string };
menus: { dropdown: string; more: string };
multiSelector: { clearAll: string; selectAll: string };
/** Selector and MultiSelector render the same two search affordances. */
search: { options: string; placeholder: string };
sideNav: {
label: string;
resizeSidebar: string;
collapseSidebar: string;
expandSidebar: string;
itemCollapse: string;
itemExpand: string;
};
tabList: { label: string };
table: { label: string };
thumbnail: { fallbackName: string; open: string; remove: string };
token: { remove: string };
}

export const ASTRYX_COPY_ZH: AstryxCopy = {
banner: { collapse: '收起', expand: '展开' },
breadcrumbs: { label: '面包屑导航' },
calendar: {
dayInRange: '{date},在所选范围内',
dayRangeEnd: '{date},范围结束',
dayRangeStart: '{date},范围开始',
dayRangeStartAndEnd: '{date},范围开始与结束',
daySelected: '{date},已选择',
nextMonth: '下个月',
previousMonth: '上个月',
rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。',
rangeStartAnnounce: '开始日期 {date}。请选择结束日期。',
},
chat: {
composerPlaceholder: '输入消息…',
composerDrawerLabel: '附加内容',
composerInputLabel: '消息输入框',
messageAriaLabel: '消息:{status}',
pastedTextExpand: '展开',
statusDelivered: '已送达',
statusFailed: '发送失败',
statusRead: '已读',
statusSending: '发送中',
statusSent: '已发送',
drawerCollapse: '收起{label}',
drawerExpand: '展开{label}',
newMessages: '跳到最新消息',
scrollToBottom: '滚动到底部',
toolCallsError: '错误:{message}',
toolCallsGroupLabel: '{count} 次工具调用',
triggerSuggestions: '建议',
},
commandPalette: {
emptyBootstrap: '输入以搜索',
emptySearch: '无结果',
inputPlaceholder: '搜索…',
label: '命令面板',
noResultsFor: '没有与「{query}」匹配的结果',
resultCount: '{count, number} 条结果',
},
dateTime: {
closeCalendar: '关闭日历',
openCalendar: '打开日历',
dialogLabel: '选择日期',
datePlaceholder: '选择日期',
timePlaceholder: '选择时间',
timeSuffix: '{label}时间',
},
inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' },
lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' },
menus: { dropdown: '菜单', more: '更多选项' },
multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' },
search: { options: '搜索选项', placeholder: '搜索…' },
sideNav: {
label: '侧边导航',
resizeSidebar: '调整侧边栏宽度',
collapseSidebar: '收起侧边栏',
expandSidebar: '展开侧边栏',
itemCollapse: '收起{label}',
itemExpand: '展开{label}',
},
tabList: { label: '标签页' },
table: { label: '表格' },
thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' },
token: { remove: '移除{label}' },
};
Loading
Loading