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
69 changes: 69 additions & 0 deletions docs/explorations/2026-08-23-seven-languages-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 七语言上线后的整体复查(2026-08-23)

站点从 2 种语言变成 7 种、落地页全部生成化之后,做了一次横向复查。下面是查出来的东西,
按"用户会不会撞到"排序。

## 1. 韩语编辑器在空白文档上弹错误框(已修,PR #194)

单独记在 CLAUDE.md 的 vendor 补丁一节。要点:vendor 的 44 个非英文 locale **没有一个**
对得齐 `en.json`,而有些字符串只存在于 locale 文件里,缺翻译就是 `undefined`,
`updateHint` 直接 `hint[0]` → TypeError → 被 app 当成文档错误。两道防线:
`bin/locale-fill.mjs`(补齐站点 7 种语言)+ 守卫 11(`updateHint(undefined)` 空操作)。

## 2. 语言菜单把语言名切两半(已修)

触发器宽 104px 是当年按 "Deutsch" 定的。组件在 shadow root 里 `overflow: hidden`,
**并且面板宽度按触发器宽度算**,所以 "Português" 同时在两处被切:列表里 `Portug…`,
触发器里文字压到 caret 上。改成 132px,并由 `test/e2e/language-menu.spec.ts` 逐语言
量 `scrollWidth <= clientWidth`——这个数字从此是量出来的,不是猜的。

## 3. 加宽触发器之后,手机顶栏塞不下(同一轮修掉)

390px 下 132px 的触发器把自己的 caret 推出视口,"Document Editor" 被挤成两行。
≤620px 隐藏顶栏的 GitHub 链接(每个页脚都有它),语言切换是那里唯一不能省的控件。
用例同样落在 language-menu.spec.ts。

## 4. 首页 body 还带着浏览器默认的 8px margin(已修)

`landing.css` 一直有 reset,`home.css` 从来没有——因为首页自建 chrome、不加载
landing.css。白色主题下看不出来,暗色下就是页面四周一圈底色。`design-contract`
现在要求两个样式表都有这条,E2E 在四类页面上验计算值。

## 5. `/history` 与 `/404` 的语言切换只有 2 种(已修)

这两个页面是手写的(一个应用页、一个错误页,都不经生成器),语言列表没跟着 `LOCALES`
走,站点都七种语言了它们还只列 en + zh。**没有任何东西会发现**:两者都不在 sitemap、
不在 hreflang 图里。补齐 7 项,并加了一条单测按 `Object.keys(LOCALES)` 比对。

## 6. 葡萄牙语在站点与应用之间不是同一种葡语(已修)

`/pt/` 的页面、`bin/build-pages.mjs` 的 UI 表、编辑器加载的 vendor 包(`pt.json` 而不是
`pt-pt.json`)都是巴西葡语,而 `packages/shared` 的 pt 词条表混着欧洲葡语:
`ficheiro` / `guardados` / `Definições` / `Não é possível anular` / `está a meio`。
于是同一个用户在落地页看到巴西葡语、进 `/history` 看到欧洲葡语、编辑器又回到巴西葡语。
14 条词条改写为巴西葡语(`arquivo` / `salvos` / `Configurações` / `Não dá para desfazer`)。

顺带修掉了更早一轮自己引入的同类问题:新增的 history 词条当时写成了欧洲葡语。

## 7. `llms-full.txt` 被翻译撑到 7 倍(已修)

它遍历 `public/` 下所有 HTML,七种语言进来之后从 97 KB 涨到 680 KB——同样的事实说七遍。
对读它的模型只有害处。改成只收英文页面,开头说明镜像在哪;`/llms.txt` 仍然逐语言列出。

## 复查过但没有问题的

- **SEO 覆盖**:7 种语言 × 21 页齐全;hreflang 是完整的七语言互指 + x-default;
sitemap 154 条;robots 允许全部。
- **编辑器多语言**:不是七个 HTML,而是 `?locale=` 驱动的同一个 app。逐语言实测过
`<html lang>` 与 vendor frame 的 `lang=`,工具栏确实是对应语言。
- **横向溢出**:7 种语言 × 桌面/手机两档,除了上面第 3 条之外没有元素越界。
- **德语长词**:`Installieren und im Flugzeug nutzen`(35 字符)在 pillars 列里正常换行,
没有溢出。
- **`/embed-demo`**:仍然是英文,这是 CLAUDE.md 的规矩(共用页面用英文),不是遗漏。

## 还没做的

- **`bin/design-audit.mjs` 只审英文页面**。七种语言的行宽/字号没有被审计脚本覆盖,
目前靠 E2E 的溢出检查兜底。
- **`/history` 与 `/404` 仍是手写页**。它们的 chrome 与生成页重复了一份,这次的语言列表
漏更新就是这个重复的代价。要么把它们也纳入生成器,要么就靠刚加的那条单测钉着。
5 changes: 5 additions & 0 deletions history.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
<r-select class="lang-select" type="text" value="en" aria-label="Language">
<r-option value="en" data-href="/history?locale=en">EN</r-option>
<r-option value="zh-CN" data-href="/history?locale=zh-CN">中文</r-option>
<r-option value="ja" data-href="/history?locale=ja">日本語</r-option>
<r-option value="de" data-href="/history?locale=de">Deutsch</r-option>
<r-option value="es" data-href="/history?locale=es">Español</r-option>
<r-option value="ko" data-href="/history?locale=ko">한국어</r-option>
<r-option value="pt" data-href="/history?locale=pt">Português</r-option>
</r-select>
</span>
<a href="https://github.com/ranuts/document" rel="noopener" target="_blank">
Expand Down
28 changes: 14 additions & 14 deletions packages/shared/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,7 @@ const partialMessages: Record<Exclude<Language, LanguageCode.ZH | LanguageCode.E
'O editor não estava pronto quando o documento foi aberto; a tentar novamente de forma automática…',
agentTitle: 'Assistente de IA',
agentOpenTip: 'Abrir o assistente de IA',
agentSettings: 'Definições',
agentSettings: 'Configurações',
agentRoleUser: 'Você',
agentRoleTool: 'Ferramenta',
agentRoleError: 'Erro',
Expand All @@ -982,14 +982,14 @@ const partialMessages: Record<Exclude<Language, LanguageCode.ZH | LanguageCode.E
agentProviderOllama: 'Ollama (servidor local, executado por si)',
agentOllamaModelPlaceholder: 'Nome do modelo, por exemplo llama3.2',
agentOllamaHint:
'Liga-se ao Ollama local (http://localhost:11434); não é preciso chave de API — confirme que o modelo está a correr.',
'Conecta ao Ollama local (http://localhost:11434); não precisa de chave de API — confirme que o modelo está rodando.',
agentLoadModel: 'Carregar modelo',
agentModelLoaded: 'Modelo carregado — já pode começar a conversar.',
agentCheckingCache: 'A verificar a cache do modelo…',
agentModelCached:
'Este modelo já está em cache — clique em «Carregar modelo» para começar de imediato (recarregar a página não o volta a transferir).',
'Este modelo já está em cache — clique em «Carregar modelo» para começar na hora (recarregar a página não baixa de novo).',
agentModelFirstDownload:
'Na primeira utilização o modelo é transferido ({size}); depois fica em cache, por isso recarregar a página não o volta a transferir.',
'No primeiro uso o modelo é baixado ({size}); depois fica em cache, então recarregar a página não baixa de novo.',
agentNoWebGPU: 'Este navegador não suporta WebGPU, pelo que o modo local não está disponível.',
agentLocalChatOnly:
'O modelo local apenas responde e reescreve — não edita o documento diretamente. Para que a IA edite, ',
Expand All @@ -1010,24 +1010,24 @@ const partialMessages: Record<Exclude<Language, LanguageCode.ZH | LanguageCode.E
agentToolCallPrefix: 'Chamada de ferramenta: ',
agentToolErrorPrefix: 'Erro da ferramenta: ',
autosaveStopped:
'A gravação automática parou: este navegador ficou sem espaço. Exporte este documento e apague alguns dos que tem guardados.',
historyTitle: 'Documentos guardados',
'O salvamento automático parou: este navegador ficou sem espaço. Exporte este documento e apague alguns dos que estão salvos.',
historyTitle: 'Documentos salvos',
historyIntro:
'Cópias dos documentos que editou, mantidas neste navegador e neste dispositivo, para que recarregar, fechar um separador ou uma falha não lhe custem o trabalho. Nada disto foi enviado para a Internet.',
historyColDocument: 'Documento',
historyColEdited: 'Última edição',
historyColSize: 'Tamanho',
historyColExpires: 'Apaga-se',
historyNotBackup:
'Estas cópias servem para retomar aquilo que estava a meio. Não são uma cópia de segurança — exporte tudo o que quiser guardar.',
historySearchPlaceholder: 'Procurar pelo nome do ficheiro',
historyEmpty: 'Ainda não há nada guardado. Os documentos que editar aqui aparecem sozinhos nesta lista.',
historyEmptySearch: 'Nenhum nome de ficheiro corresponde a essa procura.',
'Estas cópias servem para retomar o que ficou pela metade. Não são um backup — exporte tudo o que quiser manter.',
historySearchPlaceholder: 'Buscar pelo nome do arquivo',
historyEmpty: 'Ainda não há nada salvo. Os documentos que você editar aqui aparecem sozinhos nesta lista.',
historyEmptySearch: 'Nenhum nome de arquivo corresponde a essa busca.',
historyOpen: 'Abrir',
historyDelete: 'Eliminar',
historyDeleteConfirm: 'Eliminar «{title}» e todas as cópias guardadas? Não é possível anular.',
historyDeleteConfirm: 'Excluir «{title}» e todas as cópias salvas? Não dá para desfazer.',
historyClearAll: 'Eliminar tudo',
historyClearConfirm: 'Eliminar todas as cópias de documentos guardadas neste dispositivo? Não é possível anular.',
historyClearConfirm: 'Excluir todas as cópias de documentos salvas neste dispositivo? Não dá para desfazer.',
historyUnsaved: 'por exportar',
historyUsage: '{size} em uso',
historyCount: '{count} documentos',
Expand All @@ -1037,7 +1037,7 @@ const partialMessages: Record<Exclude<Language, LanguageCode.ZH | LanguageCode.E
historyBack: 'Abrir o editor',
historyAutosaveLabel: 'Gravação automática',
historyAutosaveOff:
'A gravação automática está desligada: o que editar agora não fica guardado e perde-se ao fechar a página.',
'O salvamento automático está desligado: o que você editar agora não fica salvo e se perde ao fechar a página.',
historyRetention:
'Cada documento é eliminado automaticamente 7 dias depois de o ter editado ou aberto pela última vez. Também pode apagar aqui o que quiser, com efeito imediato.',
historyExpiresIn: 'faltam {days} dias',
Expand All @@ -1051,7 +1051,7 @@ const partialMessages: Record<Exclude<Language, LanguageCode.ZH | LanguageCode.E
historyRailRetention: 'Retenção',
historyCancel: 'Cancelar',
historyChip: 'Neste dispositivo · nunca enviado',
historyEmptyTitle: 'Ainda não há nada guardado',
historyEmptyTitle: 'Ainda não há nada salvo',
historyEmptySearchTitle: 'Sem resultados',
historyClearSearch: 'Limpar a procura',
historyDeleteTitle: 'Eliminar este documento',
Expand Down
5 changes: 5 additions & 0 deletions public/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@
<r-select class="lang-select" type="text" value="en" aria-label="Language">
<r-option value="en" data-href="/">EN</r-option>
<r-option value="zh-CN" data-href="/zh-CN/">中文</r-option>
<r-option value="ja" data-href="/ja/">日本語</r-option>
<r-option value="de" data-href="/de/">Deutsch</r-option>
<r-option value="es" data-href="/es/">Español</r-option>
<r-option value="ko" data-href="/ko/">한국어</r-option>
<r-option value="pt" data-href="/pt/">Português</r-option>
</r-select>
</span>
<a href="https://github.com/ranuts/document" rel="noopener" target="_blank">
Expand Down
11 changes: 11 additions & 0 deletions public/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,17 @@ body {
display: none;
}
}
@media (max-width: 620px) {
/* Phone: the bar is brand + GitHub + a language name, and at 390px the
language trigger (wide enough for "Português") pushed the caret off the
viewport and broke the brand onto two lines. GitHub loses: it is one line
down in the footer of every page, while the language switch is the only
way to change the language. */
#landing-hero .bar nav .navlink.gh {
display: none;
}
}

@media (max-width: 560px) {
#landing-hero .dw-body {
grid-template-columns: 1fr;
Expand Down
11 changes: 11 additions & 0 deletions public/landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ r-select.lang-select:not(:defined) {
--ran-dropdown-option-active-font-weight: 600;
}

@media (max-width: 620px) {
/* Phone: the bar is brand + GitHub + a language name, and at 390px the
language trigger (wide enough for "Português") pushed the caret off the
viewport and broke the brand onto two lines. GitHub loses: it is one line
down in the footer of every page, while the language switch is the only
way to change the language. */
header.bar nav > a[href*='github.com'] {
display: none;
}
}

/* ---------- article column ---------- */
.wrap {
max-width: 720px;
Expand Down
23 changes: 23 additions & 0 deletions test/e2e/language-menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ test.describe('language menu', () => {
});
}

/**
* The trigger is sized for the longest endonym, which on a 390px phone is
* most of what is left after the brand: at 132px it pushed its own caret past
* the viewport edge and wrapped "Document Editor" onto two lines. The bar
* drops GitHub there (it is in every footer) rather than clipping the only
* control that changes the language.
*/
test('fits on a phone, in the longest language, on both kinds of page', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
for (const route of ['/pt/', '/pt/open/docx']) {
await page.goto(route);
const fits = await page
.locator('r-select.lang-select')
.first()
.evaluate((el) => {
const box = el.getBoundingClientRect();
return { right: box.right, left: box.left, vw: window.innerWidth };
});
expect(fits.right, `${route}: the language trigger runs past the viewport`).toBeLessThanOrEqual(fits.vw);
expect(fits.left, `${route}: the language trigger starts off-screen`).toBeGreaterThanOrEqual(0);
}
});

test('a satellite page offers the same menu as the homepage', async ({ page }) => {
await page.goto('/pt/open/docx');
const select = page.locator('r-select.lang-select').first();
Expand Down
16 changes: 16 additions & 0 deletions test/unit/landing-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,22 @@ describe('landing pages', () => {
}
});

/**
* /history and /404 are hand-written (an app surface and an error page, both
* outside the generator), so their language switch does not come from
* LOCALES -- it was still offering en and zh long after the site had seven
* languages. Nothing else would have noticed: neither is in the sitemap or
* the hreflang graph.
*/
it.each([
['history.html', resolve(ROOT, 'history.html')],
['404.html', resolve(PUBLIC, '404.html')],
])('%s offers every language the site has', (_label, file) => {
const html = readFileSync(file, 'utf8');
const offered = [...html.matchAll(/<r-option value="([^"]+)"/g)].map((m) => m[1]);
expect(offered).toEqual(Object.keys(LOCALES));
});

it('llms.txt states the limitations, not just the features', () => {
const llms = readFileSync(resolve(PUBLIC, 'llms.txt'), 'utf8');
expect(llms).toMatch(/^## Limitations/m);
Expand Down
Loading