Skip to content

refactor(settings): derive the language codes and TOC bounds from one source - #446

Merged
PathGao merged 1 commit into
masterfrom
fix/two-constants-with-two-copies
Aug 3, 2026
Merged

refactor(settings): derive the language codes and TOC bounds from one source#446
PathGao merged 1 commit into
masterfrom
fix/two-constants-with-two-copies

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

pt reads "Portuguese" in one of the two supported-language tables and "Portuguese (European)" in the other. Nothing caught it, and nothing could have: the copy that says "Portuguese (European)" is never rendered.

src/lib/utils/i18n.ts:50              name: 'Portuguese',            nativeName: 'Português'
src/lib/stores/settings.svelte.ts:71  name: 'Portuguese (European)', nativeName: 'Português (Europeu)'

That is the evidence the duplication is not theoretical. Two constants, same shape, one PR, two sections.

1. The supported-language catalogue

Why the drift was invisible

settings.svelte.ts declared a 26-entry { code, name, nativeName } table. i18n.ts's getSupportedLanguages() returns the same 26 entries. The settings copy is consumed exactly one column deep:

SUPPORTED_LANGUAGES ─map(entry => entry.code)→ SUPPORTED_LANGUAGE_CODES ─→ isSupportedLanguage()

and isSupportedLanguage validates the persisted editor.language key. So 26 name strings and 26 nativeName strings — 52 in total — were data nothing read.

Verified rather than assumed. Over src/, scripts/ and src-tauri/:

symbol reference sites
SUPPORTED_LANGUAGES settings.svelte.ts:55 (the declaration), settings.svelte.ts:84 (.map(entry => entry.code))
SUPPORTED_LANGUAGE_CODES settings.svelte.ts:84, settings.svelte.ts:87
.nativeName read Settings.svelte:1168 — off getSupportedLanguages(), the i18n copy
.name read none

The <select> at Settings.svelte:1167 iterates getSupportedLanguages(), imported at line 21 from ../utils/i18n.js. grep -rn "Portuguese (European)" src scripts static src-tauri returns three lines: the two type-union comments and the dead table entry. The string reaches no template. Deleting it changes no pixel.

Which direction the derivation goes, and why

Into settings.svelte.ts, importing from i18n.ts.

                 utils/i18n.ts   (imports nothing — leaf)
                       ▲
       ┌───────────────┼────────────────┐
 stores/settings   components/Settings   components/Editor
   (already imports utils/editorToolbar, utils/titlebarToolbar,
    utils/previewWidth)

The store→utils edge already exists three times in this file's import block. The reverse edge would make i18n.ts — currently a zero-import leaf holding every translation — depend on a runes module, and utils/recentFiles.ts already imports stores/settings, so the reverse direction is the one with a cycle in reach. Every existing consumer of LanguageCode (Editor.svelte, Settings.svelte, FindBar.svelte, three test files) already imports it from utils/i18n.js; none imported it from the store. The store was the copy, in both senses.

The LanguageCode union

Also duplicated, and genuinely the same set — checked before collapsing rather than after. Both declare the same 26 codes in the same order with the same trailing comments; diff of the two spans is one line, the ; that ends the union:

27d26
< 	| 'tr'; // Turkish

So it collapses. Had the two sets differed, that would have been a separate defect to report, not to unify. settings.svelte.ts keeps export type { LanguageCode } re-exporting i18n's, so its public surface is unchanged.

2. The TOC width range

NumericSettingRange's doc comment says the range object is the single source of truth "so the UI can never offer a value that persistence would silently shrink". MarkdownViewer.svelte:267-268 re-declared the numbers anyway. Every use found:

site before after
:430 drag clamp in setTocWidth Math.min(TOC_MAX_WIDTH, Math.max(TOC_MIN_WIDTH, width)) TOC_WIDTH_RANGE.max / .min
:444 Home key setTocWidth(TOC_MIN_WIDTH) TOC_WIDTH_RANGE.min
:447 End key setTocWidth(TOC_MAX_WIDTH) TOC_WIDTH_RANGE.max
:3468-3469 aria-valuemin / aria-valuemax {TOC_MIN_WIDTH} / {TOC_MAX_WIDTH} {TOC_WIDTH_RANGE.min} / {.max}

The semantics do match — checked, not assumed

Both bounds are inclusive on both sides in both places. The component clamps with Math.min(max, Math.max(min, …)); clampToRange, which settings.setTocWidth calls immediately afterwards, clamps with Math.min(range.max, Math.max(range.min, Math.round(…))). Same operators, same direction, and the component's clamp already ran ahead of the store's, so the two were redundant before this change and remain so after. 180/420 are the same numbers on both sides.

The other two fields are not interchangeable, and the component keeps what it needs:

  • step. TOC_WIDTH_RANGE.step is 1 — the spin-button granularity of the numeric settings input. The component's arrow-key increment is TOC_RESIZE_STEP = 16. These are different quantities that happen to sit in adjacent lines; collapsing them would turn a 16px arrow-key nudge into a 1px one. TOC_RESIZE_STEP stays a local constant, and the guard rule below deliberately excludes it.
  • default. The component has no notion of a default width; the store's tocWidth = $state(240) holds it. See Not covered.

The test

"The two tables are equal" is only checkable while both tables exist, so it cannot be the test — the collapse deletes its subject. Two complementary assertions instead, because the drift had two halves and no single assertion sees both.

CodessettingsPersistence.test.ts, executable, imports the real functions:

assert.deepEqual([...SUPPORTED_LANGUAGE_CODES], getSupportedLanguages().map(e => e.code));

True by construction today, and that is the point: it is what fails the moment someone re-forks the catalogue into the store, which is how the two copies arrived. The rest of the test is not by construction — translations and LANGUAGE_BY_PRIMARY_SUBTAG are hand-maintained beside the catalogue, so it also pins that every offered language has a dictionary, that the dictionary keys are exactly the offered set, and that every code resolveLanguageTag can produce survives isSupportedLanguage (a code it cannot is a detectSystemLanguage result that will not persist).

Display columns — codes cannot see the pt drift, which was in name/nativeName. That half needs the structural claim "there is one table", which is what singleImplementationConvention.test.ts exists for. Four rows appended to its RULES table, following the file's own marker discipline (a public symbol or a magic string, never a private identifier):

rule marker allowed
the supported-language catalogue has one definition /nativeName\s*:/ utils/i18n.ts
the LanguageCode union has one definition /export type LanguageCode\s*=/ utils/i18n.ts
the TOC width bounds have one definition /TOC_(?:MIN|MAX)_WIDTH/ (nowhere)
the TOC separator advertises the settings range /aria-valuemin=/ MarkdownViewer.svelte, requiring TOC_WIDTH_RANGE.min/.max

nativeName is a cross-module field name — Settings.svelte reads lang.nativeName off the returned objects — not a local, so it is a legal marker; the read site spells it without the :, which the pattern excludes. The aria-valuemin rule is the one that catches a differently named copy: the name rule alone would miss const tocLower = 180, but a literal reaching the attribute assistive tech reads cannot hide from it, and no visual check would have caught it either.

Mutation check

Each defect injected, the affected suite run, the source restored.

injected defect result failing test
the pt drift restored as a second table in settings.svelte.ts RED the supported-language catalogue has one definition
LanguageCode re-declared in settings.svelte.ts RED the LanguageCode union has one definition
const TOC_MIN_WIDTH = 200 re-added to MarkdownViewer.svelte RED the TOC width bounds have one definition
aria-valuemin={180} RED the TOC separator advertises the settings range
derived codes filtered to drop pt RED the validator accepts exactly the languages the dialog offers
a 27th language added to the catalogue with no dictionary RED the validator accepts exactly the languages the dialog offers

6 injected, 6 caught. Messages name the consequence, not the line:

second implementation found — … `pt` was "Portuguese" in the table the language <select>
renders and "Portuguese (European)" in the dead one. A drift no user can see is a drift
no bug report can find.

the resize separator must advertise TOC_WIDTH_RANGE.min/.max, not numbers of its own

tlh is offered by the language <select> but has no dictionary

npm test 565 → 570 passing, npm run check 637 files / 0 errors, npm run build clean.

64 lines deleted from src/, 24 addedsettings.svelte.ts 56 deleted / 14 added (27-line union, 28-line table, the one-line .map), MarkdownViewer.svelte 8 deleted / 10 added.

Not covered

  • tocWidth = $state(240) is a third copy of TOC_WIDTH_RANGE.default. So is editorMaxWidth = $state(80) against EDITOR_MAX_WIDTH_RANGE.default, and so are the three font sizes. Left alone deliberately: it is a consistent five-field pattern across the whole store, not the two-copy defect this PR is about, and collapsing one of five would make the remaining four look intentional. A single sweep, or none.
  • The drag path is still only clamped, never run. startTocResize and the pointer handlers live in a .svelte file, so nothing here executes a real drag; what is verified is that the numbers the clamp uses have one home. test(scroll-sync): cover the split-view mapping by running it #442's finding stands — 21 components have no executable coverage, and this PR does not extract another one.
  • Nothing pins that isSupportedLanguage is the only reader of the codes. If a future caller validates a language some other way, the derived list stays correct and the new path is simply unguarded.
  • The pt / pt-BR split itself is untouched. resolveLanguageTag('pt-PT')pt and 'pt-BR'pt-BR are already covered in settingsPersistence.test.ts; whether "Portuguese" is the right label for the European entry now that it is the only one is a copy decision for the maintainer, not a refactor.
  • No rule stops a third file from spelling a TOC bound in CSS. The markers scan .ts/.svelte/.js source; a min-width: 180px in a stylesheet would not be seen.

🤖 Generated with Claude Code

… source

Two constants existed twice. Neither duplicate was type-checked against its
twin, and one had already drifted.

The language catalogue. `settings.svelte.ts` carried a 26-entry
`{ code, name, nativeName }` table beside `getSupportedLanguages()` in
`i18n.ts`. Only the `code` column was ever read — `SUPPORTED_LANGUAGE_CODES`
mapped it for `isSupportedLanguage`, and nothing else in `src/` or `scripts/`
touched the table — so the 52 display strings were dead data with nothing to
compare them against. They drifted: `pt` read "Portuguese" in the catalogue
the language `<select>` renders and "Portuguese (European)" in the dead copy.
No user ever saw the second spelling, so no bug report could have found it.
The codes now derive from `getSupportedLanguages()`; the dead table and the
duplicate `LanguageCode` union (identical 26 codes, identical comments) are
deleted, and the union is re-exported so importers are unaffected.

Direction is stores -> utils, the way `settings.svelte.ts` already imports
`editorToolbar`, `titlebarToolbar` and `previewWidth`. `i18n.ts` imports
nothing, and every existing consumer of `LanguageCode` already imports it
from `utils/i18n.js`, so the leaf stays a leaf and no cycle is created.

The TOC width range. `MarkdownViewer.svelte` re-declared `TOC_MIN_WIDTH = 180`
and `TOC_MAX_WIDTH = 420` next to `TOC_WIDTH_RANGE`, the object
`NumericSettingRange`'s own doc comment calls the single source of truth and
the object `settings.setTocWidth` already clamps against. All three uses — the
drag clamp, the Home/End jumps, the `aria-valuemin`/`aria-valuemax` on the
separator — read the range now. `TOC_RESIZE_STEP` stays local: it is the 16px
arrow-key increment, not `TOC_WIDTH_RANGE.step`, which is the spin-button
granularity of 1.

Behaviour is unchanged. Both clamps were already inclusive on both bounds and
already ran ahead of `clampToRange`, which re-clamps against the same numbers.

64 lines deleted from `src/`, 24 added.

Four rules in `singleImplementationConvention.test.ts` and one behavioural
test in `settingsPersistence.test.ts` keep the copies from coming back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao force-pushed the fix/two-constants-with-two-copies branch from c2785a4 to 376d1d9 Compare August 3, 2026 12:27
@PathGao
PathGao merged commit e273f4f into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the fix/two-constants-with-two-copies branch August 3, 2026 12:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant