Skip to content

i18n: add Portuguese (pt-BR) to the bundled core - #79

Merged
nyblnet merged 2 commits into
mainfrom
claude/i18n-portuguese
Jul 26, 2026
Merged

i18n: add Portuguese (pt-BR) to the bundled core#79
nyblnet merged 2 commits into
mainfrom
claude/i18n-portuguese

Conversation

@nyblnet

@nyblnet nyblnet commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Adds Portuguese to the bundled core, per docs/i18n-packs.md (#77).

Based on #75, not main — it regenerates packed.ts, so it needs the packing infrastructure. Merge #75 first; this PR targets that branch and will retarget to main once it lands.

Why Portuguese, and only Portuguese

Brazil has a genuine English-proficiency gap — unlike the markets where a localised UI mostly goes unused — and pt sits in the cheapest cost tier. It's the only language joining the core; everything else becomes a pack.

Brazilian terminology throughout: arquivo (not ficheiro), tela (not ecrã), salvar (not guardar), compartilhar (not partilhar).

pt-PT aliases here for now. European Portuguese diverges enough to deserve its own catalog, and it's a natural first pack once that machinery exists — noted in the file header.

Cost

+11,832 B (+2.2%) — close to the ~14 KB per-language rule established in #77, and in the cheapest tier as predicted.

With #75's key-once packing underneath, the shell is still 44,695 B (−7.5%) smaller than main today.

Verification

  • All 650 strings resolve through the real lookup path — zero fell back to English, zero mismatched against the source catalog
  • Picker shows 9 locales; pt, pt-BR and pt-PT all resolve
  • {placeholder} interpolation and inline HTML survive
  • Confirmed visually in the running editor
  • build-i18n.mjs --check current · splice conformance gate passes · tsc -b clean

One thing worth reviewing

Five strings were generated programmatically from the canonical keys rather than hand-typed. The markdown-help and embed-size confirms carry backslash and newline escaping, and two pairs of them differ from each other only in that escaping. A mistyped escape yields a key that silently never matches — so those were the one part worth not doing by hand.

Machine-drafted, native review welcome — as the header says, and as every other catalog here says.

nyblnet added 2 commits July 26, 2026 01:19
Portuguese joins the bundled core per docs/i18n-packs.md: Brazil has a
genuine English-proficiency gap — unlike the markets where a localized UI
mostly goes unused — and pt sits in the cheapest cost tier. It is the only
language being added to the core; everything else becomes a pack.

Brazilian terminology throughout: arquivo (not ficheiro), tela (not ecrã),
salvar (not guardar), compartilhar (not partilhar). pt-PT aliases here for
now — European Portuguese diverges enough to deserve its own catalog, and it
is a natural first PACK once that machinery exists (noted in the file header).

Five strings were generated programmatically from the canonical keys rather
than hand-typed: the markdown-help and embed-size confirms carry backslash and
newline escaping, and two PAIRS of them differ from each other ONLY in that
escaping. A mistyped escape produces a key that silently never matches, so
hand-copying them was the one part worth not doing by hand.

COST: +11,832 B (+2.2%), close to the ~14 KB per-language rule and in the
cheapest tier as predicted. With the key-once packing this branch builds on,
the shell is still 44,695 B (-7.5%) SMALLER than main today.

VERIFIED: all 650 strings resolve through the REAL lookup path — zero fell
back to English, zero mismatched against the source catalog. Picker shows 9
locales; pt, pt-BR and pt-PT all resolve. Interpolation and inline HTML
survive. Confirmed visually in the running editor. build-i18n.mjs --check
current, splice conformance gate passes, tsc -b clean.

Machine-drafted, native review welcome — as the file header says, and as every
other catalog here says.
…open

#72, #73, #74 and #82 each landed new UI strings after this catalog was
written. Without these twelve, Portuguese would have silently fallen back to
English on the save-capability wording, the release-notes link, Start from
scratch and the morph unpair option. 662/662 now, verified against the packed
table.
@nyblnet
nyblnet force-pushed the claude/i18n-portuguese branch from f3038cb to 877993c Compare July 26, 2026 00:20
@nyblnet
nyblnet changed the base branch from claude/i18n-compact to main July 26, 2026 00:20
@nyblnet
nyblnet merged commit 6646528 into main Jul 26, 2026
1 check passed
nyblnet added a commit that referenced this pull request Jul 26, 2026
Builds the machinery from docs/i18n-packs.md so that adding a language stops
costing shell bytes. Korean is the first pack and the proof it works.

WHY THIS BEFORE MORE CONTENT. Every i18n change in this series was verified
the same way: call t() through the REAL lookup path and confirm it returns a
translation rather than the English fallback. Without a loader that check is
impossible, so generating catalogs first would mean thousands of machine-
drafted strings with ZERO evidence any of them resolve — and a mistyped key
falls back to English silently, forever. Pipeline first makes every language
after this verifiable.

KERNEL. addPack() layers a pack over the bundled core, keyed by language.
  - Packs are consulted BEFORE the core, so a pack can also CORRECT a bundled
    language without cutting an app release — which is what turns native
    review into a continuous process instead of a release blocker.
  - Deliberately tolerant: a pack built against an older app simply lacks the
    newer keys, and each miss falls through to the core and then to English.
    A stale pack degrades string by string instead of failing to load.
  - Rejects a pack built for a DIFFERENT app: its keys would be wrong, not
    merely missing, which is the one case worth refusing outright.
  - registerI18n re-appends pack languages to the picker, because block order
    in the shell isn't guaranteed and a pack loaded BEFORE registration would
    otherwise lose its entry.

BUILD. `build-i18n.mjs --packs <dir>` emits one JSON per language from
slides/src/i18n/packs/*.ts — authored exactly like a core catalog, but nothing
imports that directory so it never enters the module graph. It errors if a
language is listed as both bundled and packed.

COST: the pipeline adds 200 B to the shell. The Korean pack is 7,207 B and
lives entirely OUTSIDE it.

VERIFIED end-to-end in the browser:
  before loading   ko absent from the picker; t('Delete slide') = English
  after addPack()  ko in the picker labelled 한국어; '슬라이드 삭제', '저장',
                   '공유'; interpolation intact ('슬라이드 4')
  degradation      'Version history…' — a key this partial pack does NOT
                   carry — correctly returns English while carried keys
                   resolve in Korean
  wrong app        a pack marked app:'spaces' is rejected
Korean is absent from packed.ts (not bundled). Core --check current, splice
conformance gate passes, app + kernel typecheck clean.

KOREAN IS PARTIAL: 262 of 650 strings, covering the most visible UI. That is
a VALID pack by design — the rest fall back per string — and completing it is
the obvious next step. Issue #17 is a volunteer offering to write this
catalog; they should be offered review and ownership of it.

Stacked on #79 (Portuguese) -> #75 (key-once packing).
YishenTu pushed a commit to YishenTu/bento that referenced this pull request Jul 26, 2026
Next item on the i18n-packs checklist: packs now ship. release.mjs emits
every non-core language beside the shell, and each pack's sha256 goes INTO
the signed manifest payload.

NO SECOND KEY, NO SECOND SIGNATURE. The manifest signature already covers the
whole payload string, so listing pack hashes there means they are signed too.
A client verifies the manifest once, then checks a downloaded pack against its
signed hash — the same two-step the shell itself goes through. A separate
pack-signing path would be a new trust root to get wrong for no gain.

VERIFIED end-to-end with a THROWAWAY key (the real key at
~/.bento/release-key.json was never touched, and the test key is deleted):
  1. manifest signature valid
  2. packs present in the signed payload — ko (한국어), 60,114 B
  3. signed hash matches the emitted pack file
  4. pack tampered -> hash mismatch, so a client rejects the download
  5. manifest tampered to swap that hash -> signature fails
That is the whole chain: you cannot substitute a pack without breaking the
hash, and you cannot fix the hash without breaking the signature.

Also recorded in docs/i18n-packs.md, measured earlier and previously unwritten:
a pack must be COMPRESSED when spliced into a file — 56.8 KB raw against
26.4 KB through the shell's existing deflate+base64 blocks. The CDN artifact
stays uncompressed (Pages gzips it in transit, and a readable .json is worth
keeping for translators), but a spliced pack lives in every saved copy of that
file forever, so 30 KB per file per language would be permanent and
unfixable once files are in the wild.

Status list refreshed — it still showed nyblnet#75/nyblnet#79/nyblnet#81 as unbuilt — and the note
about shell-gate.mjs corrected: it deliberately checks only the splice
contract, and passed a build with a silently corrupted stylesheet, so
pack-carrying coverage belongs in a separate smoke check, not there.
nyblnet added a commit that referenced this pull request Jul 27, 2026
i18n: add Portuguese (pt-BR) to the bundled core
nyblnet added a commit that referenced this pull request Jul 27, 2026
Builds the machinery from docs/i18n-packs.md so that adding a language stops
costing shell bytes. Korean is the first pack and the proof it works.

WHY THIS BEFORE MORE CONTENT. Every i18n change in this series was verified
the same way: call t() through the REAL lookup path and confirm it returns a
translation rather than the English fallback. Without a loader that check is
impossible, so generating catalogs first would mean thousands of machine-
drafted strings with ZERO evidence any of them resolve — and a mistyped key
falls back to English silently, forever. Pipeline first makes every language
after this verifiable.

KERNEL. addPack() layers a pack over the bundled core, keyed by language.
  - Packs are consulted BEFORE the core, so a pack can also CORRECT a bundled
    language without cutting an app release — which is what turns native
    review into a continuous process instead of a release blocker.
  - Deliberately tolerant: a pack built against an older app simply lacks the
    newer keys, and each miss falls through to the core and then to English.
    A stale pack degrades string by string instead of failing to load.
  - Rejects a pack built for a DIFFERENT app: its keys would be wrong, not
    merely missing, which is the one case worth refusing outright.
  - registerI18n re-appends pack languages to the picker, because block order
    in the shell isn't guaranteed and a pack loaded BEFORE registration would
    otherwise lose its entry.

BUILD. `build-i18n.mjs --packs <dir>` emits one JSON per language from
slides/src/i18n/packs/*.ts — authored exactly like a core catalog, but nothing
imports that directory so it never enters the module graph. It errors if a
language is listed as both bundled and packed.

COST: the pipeline adds 200 B to the shell. The Korean pack is 7,207 B and
lives entirely OUTSIDE it.

VERIFIED end-to-end in the browser:
  before loading   ko absent from the picker; t('Delete slide') = English
  after addPack()  ko in the picker labelled 한국어; '슬라이드 삭제', '저장',
                   '공유'; interpolation intact ('슬라이드 4')
  degradation      'Version history…' — a key this partial pack does NOT
                   carry — correctly returns English while carried keys
                   resolve in Korean
  wrong app        a pack marked app:'spaces' is rejected
Korean is absent from packed.ts (not bundled). Core --check current, splice
conformance gate passes, app + kernel typecheck clean.

KOREAN IS PARTIAL: 262 of 650 strings, covering the most visible UI. That is
a VALID pack by design — the rest fall back per string — and completing it is
the obvious next step. Issue #17 is a volunteer offering to write this
catalog; they should be offered review and ownership of it.

Stacked on #79 (Portuguese) -> #75 (key-once packing).
nyblnet added a commit that referenced this pull request Jul 27, 2026
Next item on the i18n-packs checklist: packs now ship. release.mjs emits
every non-core language beside the shell, and each pack's sha256 goes INTO
the signed manifest payload.

NO SECOND KEY, NO SECOND SIGNATURE. The manifest signature already covers the
whole payload string, so listing pack hashes there means they are signed too.
A client verifies the manifest once, then checks a downloaded pack against its
signed hash — the same two-step the shell itself goes through. A separate
pack-signing path would be a new trust root to get wrong for no gain.

VERIFIED end-to-end with a THROWAWAY key (the real key at
~/.bento/release-key.json was never touched, and the test key is deleted):
  1. manifest signature valid
  2. packs present in the signed payload — ko (한국어), 60,114 B
  3. signed hash matches the emitted pack file
  4. pack tampered -> hash mismatch, so a client rejects the download
  5. manifest tampered to swap that hash -> signature fails
That is the whole chain: you cannot substitute a pack without breaking the
hash, and you cannot fix the hash without breaking the signature.

Also recorded in docs/i18n-packs.md, measured earlier and previously unwritten:
a pack must be COMPRESSED when spliced into a file — 56.8 KB raw against
26.4 KB through the shell's existing deflate+base64 blocks. The CDN artifact
stays uncompressed (Pages gzips it in transit, and a readable .json is worth
keeping for translators), but a spliced pack lives in every saved copy of that
file forever, so 30 KB per file per language would be permanent and
unfixable once files are in the wild.

Status list refreshed — it still showed #75/#79/#81 as unbuilt — and the note
about shell-gate.mjs corrected: it deliberately checks only the splice
contract, and passed a build with a silently corrupted stylesheet, so
pack-carrying coverage belongs in a separate smoke check, not there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant