refactor(settings): derive the language codes and TOC bounds from one source - #446
Merged
Merged
Conversation
… 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
force-pushed
the
fix/two-constants-with-two-copies
branch
from
August 3, 2026 12:27
c2785a4 to
376d1d9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ptreads "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.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.tsdeclared a 26-entry{ code, name, nativeName }table.i18n.ts'sgetSupportedLanguages()returns the same 26 entries. The settings copy is consumed exactly one column deep:and
isSupportedLanguagevalidates the persistededitor.languagekey. So 26namestrings and 26nativeNamestrings — 52 in total — were data nothing read.Verified rather than assumed. Over
src/,scripts/andsrc-tauri/:SUPPORTED_LANGUAGESsettings.svelte.ts:55(the declaration),settings.svelte.ts:84(.map(entry => entry.code))SUPPORTED_LANGUAGE_CODESsettings.svelte.ts:84,settings.svelte.ts:87.nativeNamereadSettings.svelte:1168— offgetSupportedLanguages(), the i18n copy.namereadThe
<select>atSettings.svelte:1167iteratesgetSupportedLanguages(), imported at line 21 from../utils/i18n.js.grep -rn "Portuguese (European)" src scripts static src-taurireturns 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 fromi18n.ts.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, andutils/recentFiles.tsalready importsstores/settings, so the reverse direction is the one with a cycle in reach. Every existing consumer ofLanguageCode(Editor.svelte,Settings.svelte,FindBar.svelte, three test files) already imports it fromutils/i18n.js; none imported it from the store. The store was the copy, in both senses.The
LanguageCodeunionAlso 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;
diffof the two spans is one line, the;that ends the union:So it collapses. Had the two sets differed, that would have been a separate defect to report, not to unify.
settings.svelte.tskeepsexport 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-268re-declared the numbers anyway. Every use found::430drag clamp insetTocWidthMath.min(TOC_MAX_WIDTH, Math.max(TOC_MIN_WIDTH, width))TOC_WIDTH_RANGE.max/.min:444HomekeysetTocWidth(TOC_MIN_WIDTH)TOC_WIDTH_RANGE.min:447EndkeysetTocWidth(TOC_MAX_WIDTH)TOC_WIDTH_RANGE.max:3468-3469aria-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, whichsettings.setTocWidthcalls immediately afterwards, clamps withMath.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/420are the same numbers on both sides.The other two fields are not interchangeable, and the component keeps what it needs:
step.TOC_WIDTH_RANGE.stepis1— the spin-button granularity of the numeric settings input. The component's arrow-key increment isTOC_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_STEPstays a local constant, and the guard rule below deliberately excludes it.default. The component has no notion of a default width; the store'stocWidth = $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.
Codes —
settingsPersistence.test.ts, executable, imports the real functions: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 —
translationsandLANGUAGE_BY_PRIMARY_SUBTAGare 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 coderesolveLanguageTagcan produce survivesisSupportedLanguage(a code it cannot is adetectSystemLanguageresult that will not persist).Display columns — codes cannot see the
ptdrift, which was inname/nativeName. That half needs the structural claim "there is one table", which is whatsingleImplementationConvention.test.tsexists for. Four rows appended to itsRULEStable, following the file's own marker discipline (a public symbol or a magic string, never a private identifier):/nativeName\s*:/utils/i18n.tsLanguageCodeunion has one definition/export type LanguageCode\s*=/utils/i18n.ts/TOC_(?:MIN|MAX)_WIDTH//aria-valuemin=/MarkdownViewer.svelte, requiringTOC_WIDTH_RANGE.min/.maxnativeNameis a cross-module field name —Settings.sveltereadslang.nativeNameoff the returned objects — not a local, so it is a legal marker; the read site spells it without the:, which the pattern excludes. Thearia-valueminrule is the one that catches a differently named copy: the name rule alone would missconst 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.
ptdrift restored as a second table insettings.svelte.tsLanguageCodere-declared insettings.svelte.tsLanguageCodeunion has one definitionconst TOC_MIN_WIDTH = 200re-added toMarkdownViewer.sveltearia-valuemin={180}pt6 injected, 6 caught. Messages name the consequence, not the line:
npm test565 → 570 passing,npm run check637 files / 0 errors,npm run buildclean.64 lines deleted from
src/, 24 added —settings.svelte.ts56 deleted / 14 added (27-line union, 28-line table, the one-line.map),MarkdownViewer.svelte8 deleted / 10 added.Not covered
tocWidth = $state(240)is a third copy ofTOC_WIDTH_RANGE.default. So iseditorMaxWidth = $state(80)againstEDITOR_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.startTocResizeand the pointer handlers live in a.sveltefile, 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.isSupportedLanguageis 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.pt/pt-BRsplit itself is untouched.resolveLanguageTag('pt-PT')→ptand'pt-BR'→pt-BRare already covered insettingsPersistence.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..ts/.svelte/.jssource; amin-width: 180pxin a stylesheet would not be seen.🤖 Generated with Claude Code