diff --git a/public/banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png b/public/banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png
new file mode 100644
index 0000000..86d18f4
Binary files /dev/null and b/public/banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png differ
diff --git a/public/og/two-ways-a-javascript-file-stops-working-and-looks-fine.png b/public/og/two-ways-a-javascript-file-stops-working-and-looks-fine.png
new file mode 100644
index 0000000..e62b8e5
Binary files /dev/null and b/public/og/two-ways-a-javascript-file-stops-working-and-looks-fine.png differ
diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs
index c69efe0..0b453fd 100644
--- a/scripts/banner-gen/generate.mjs
+++ b/scripts/banner-gen/generate.mjs
@@ -1271,6 +1271,23 @@ BANNERS['authentik-css-greater-than-bug'] = {
],
};
+BANNERS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = {
+ titlebar: "rt - app.js, silently dead",
+ lines: [
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "rtCheckHandlers() -> []" },
+ { t: 'dim', text: "page renders zh-CN exactly as before - that is the problem" }, { t: 'err', text: "TypeError: t is not a function (applyTheme(t) shadowed t())" },
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "?lang=pseudo strings stretched 1.6x" },
+ { t: 'ok', text: "no horizontal overflow at 1440px" }, { t: 'hl', text: "assert effects, not intent: every hook really swaps" },
+ { t: 'dim', text: "second cause: let LANG declared below its first call (TDZ)" }, { t: 'dim', text: "3 packs from one table - key drift cannot merge" },
+ ],
+ flow: [
+ { n: '1', label: "load the page" },
+ { n: '2', label: "assert typeof t" },
+ { n: '3', label: "switch to pseudo" },
+ { n: '4', label: "assert no overflow" },
+ ],
+};
+
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs
index f26e3da..00a4c22 100644
--- a/scripts/og-gen/generate.mjs
+++ b/scripts/og-gen/generate.mjs
@@ -387,6 +387,11 @@ TERMINALS['authentik-css-greater-than-bug'] = `
selector matches, computed display: list-item
$ read the sheet back out of the CSS parser -> u003e, not >
`;
+TERMINALS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = `
+ $ node --check app.js -> valid, no output
+ TypeError: t is not a function (a parameter named t)
+ $ typeof t === 'function' ?lang=pseudo clean -> script reaches the end
`;
+
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
diff --git a/src/content/posts/two-ways-a-javascript-file-stops-working-and-looks-fine.md b/src/content/posts/two-ways-a-javascript-file-stops-working-and-looks-fine.md
new file mode 100644
index 0000000..ca89c72
--- /dev/null
+++ b/src/content/posts/two-ways-a-javascript-file-stops-working-and-looks-fine.md
@@ -0,0 +1,175 @@
+---
+title: "Two Ways a JavaScript File Stops Working and Looks Fine"
+description: "A shadowed function name and a let declared below its first call both killed a whole script — and the page rendered perfectly, because nothing had changed on screen."
+pubDate: 2025-02-20
+updatedDate: 2026-10-16
+category: engineering
+tags: ["javascript", "i18n", "debugging", "frontend"]
+ogImage: /og/two-ways-a-javascript-file-stops-working-and-looks-fine.png
+banner: /banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png
+draft: false
+---
+
+## The change that did nothing at all
+
+I added a language switcher to a dashboard I run for a media-conversion
+queue. Three language packs, a `t(key, vars)` function, `data-i18n`
+attributes on the chrome, a `` in the top bar.
+
+After deploying it, the page looked exactly as it had before. Not broken —
+*unchanged*. Same layout, same Chinese labels, no missing panels, no error
+card, no console banner. The switcher was simply not there, and neither was
+anything else the new code was supposed to do.
+
+That is the shape of bug I want to write down, because the page was not
+"mostly working". **The script had stopped executing partway through —
+twice, for two unrelated one-line reasons — and the page rendered perfectly
+anyway.**
+
+## Why nothing looked broken
+
+Two design decisions I would make again, and both of them hid the failure.
+
+The first is that the default language lives in the markup. The Chinese text
+is written inside the elements; `data-i18n` is only a hook that *overrides*
+it when you switch. That is deliberate — the page needs no fetch to render,
+and if a language pack goes missing you get Chinese instead of a row of
+`top.theme_to_dark` keys. It also means a completely dead i18n layer
+produces a page that is, visually, correct.
+
+The second is that the buttons are wired with inline handlers
+(`onclick="rtFoo()"`), which after a script abort point at functions that
+were never defined. Nothing warns you. The page only reveals the truth when
+a human clicks something.
+
+So the symptom was "my change had no effect", which is the least informative
+symptom a web page can give you.
+
+## Failure one: a parameter that swallowed a function
+
+The file has a theme block and an i18n block. The theme block had this
+signature:
+
+```js
+function applyTheme(t) { // t = the theme
+ label.textContent = t('top.theme_to_dark'); // TypeError: t is not a function
+}
+```
+
+`applyTheme` was written before the i18n layer existed, and `t` was a
+perfectly reasonable name for "theme". When the i18n layer arrived, `t` in
+that scope stopped meaning the theme and started meaning *the function I
+wanted to call* — except the parameter shadows it, so the call became
+"invoke a string".
+
+A `TypeError` at the top level of a classic script does not just fail one
+line. Everything after it never runs: the language-pack loader, the
+`data-i18n` sweep, the switcher rendering. One wrong letter removed an
+entire feature, silently.
+
+The fix is one character — rename the parameter to `th` — plus a comment
+where the next person will look:
+
+```js
+function applyTheme(th) { // NOT `t`: t() is the i18n function in this file
+```
+
+## Failure two: a declaration that arrived too late
+
+The second cause was in the same file, and it is a JavaScript rule people
+know and still trip over. The theme block runs at load and calls `t('…')`.
+The i18n block, further down the file, began with:
+
+```js
+let LANG = localStorage.getItem('rt_lang') || 'zh-CN';
+```
+
+`let` and `const` are hoisted but **not initialised**: between the start of
+the scope and that line they live in the temporal dead zone, and any access
+throws `Cannot access 'LANG' before initialization`. Because the theme block
+sits *above* the i18n block, the failure was not a race — it was guaranteed
+on every single load.
+
+Note the distinction from `var`, which would have quietly given me
+`undefined` and a working page. The TDZ is the language protecting me from
+exactly the bug I was about to write; the only reason it looked like
+"nothing happened" is the markup fallback again.
+
+The fix is ordering, and ordering here is a *dependency*, not a style
+choice: if a block calls `t()`, the i18n declarations have to be above it.
+The file had been organised by topic ("theme, then i18n") because that is
+how it grew.
+
+## Why my checks did not catch either one
+
+I had tests. They checked intent, not effect:
+
+- the three language packs exist and parse;
+- every key used in the markup exists in every pack;
+- no pack contains a dead key (a typo detector);
+- the `{var}` placeholder sets in the three packs match.
+
+All true. All green. None of them executes the page, so none of them can see
+a script that aborts on line 12 of 900. Static checks are structurally
+unable to catch "the file is valid and the program is dead".
+
+## Assert on effects, in a browser
+
+What found both bugs was asking the live page questions that only a running
+program can answer:
+
+```js
+typeof t === 'function' // is the entry point still callable?
+document.querySelectorAll('[data-i18n]').length // hooks present
+rtCheckHandlers() // do the inline handlers still resolve?
+```
+
+That last one is worth more than it looks. The page carries **219 inline
+`onclick` attributes naming 217 distinct functions**; the check enumerates
+them and asserts each name is defined on `window`. It turns a hand-clicked
+smoke test into a two-second automated one, and it is the check that turns
+"the page looks right" into a measurable property.
+
+The second tool is cheaper and nastier: a **pseudo-language**.
+`?lang=pseudo` takes every string in the pack, prefixes it with `[~~` and
+stretches it 1.6×. Then assert two things — no horizontal overflow at
+1440px, and no element in the nav still lacks the `[~~` prefix. The first
+catches layout that only breaks in a longer language; the second catches
+nodes that never got a translation hook at all. It found a real one in the
+same run.
+
+Honest scope note: a pseudo-locale does not prove your Malay translation is
+*good*. It proves the *plumbing* is complete — every string flows through
+the mechanism, and the layout survives length. Judgement about the words
+stays with a human.
+
+## What I would do differently
+
+1. **Load the page in a real browser and assert `typeof` on the entry points
+ before believing a client-side refactor worked.** A dead script is
+ invisible to every static check you own.
+2. **Never let a parameter name shadow a function the same file calls.** One
+ letter bought me an entire afternoon; the comment is the part that stops
+ it recurring.
+3. **Treat declaration order as a dependency, not aesthetics.** If a block
+ calls a function or reads a constant, that declaration goes above it —
+ especially in one long script.
+4. **Generate the language packs from one table.** Then "the keys match" is
+ a test rather than a hope, and drift cannot be merged.
+5. **When a change does nothing, suspect the script before the change.**
+ "Nothing happened" is the default failure mode of a rendered page, not
+ evidence that your code was inert.
+
+## The result
+
+Two one-line causes, both in the same file, both found by browser-side
+assertions rather than by reading code again. The dashboard switches
+language at runtime now, the default render is byte-for-byte what it was
+before the change, and the three packs come from a single generated table —
+so a missing key shows up as a red test instead of as a word that quietly
+stays Chinese.
+
+The generalisation is the part I keep: **a crash tells you where to look. A
+silent stop tells you nothing, and looks like success.** If a feature can
+fail without moving a single pixel, then "the page still looks right" is not
+a check.
diff --git a/src/content/posts/zh/two-ways-a-javascript-file-stops-working-and-looks-fine.md b/src/content/posts/zh/two-ways-a-javascript-file-stops-working-and-looks-fine.md
new file mode 100644
index 0000000..cc8596a
--- /dev/null
+++ b/src/content/posts/zh/two-ways-a-javascript-file-stops-working-and-looks-fine.md
@@ -0,0 +1,104 @@
+---
+title: "JavaScript 文件静默失效的两种方式:而页面看上去一切正常"
+description: "一个被参数名遮住的函数名,加一个声明在首次调用之后的 let,让整段脚本停摆——而页面渲染得完美好看,因为屏幕上什么都没变。"
+pubDate: 2025-02-20
+updatedDate: 2026-10-16
+category: engineering
+tags: ["javascript", "i18n", "debugging", "frontend"]
+ogImage: /og/two-ways-a-javascript-file-stops-working-and-looks-fine.png
+banner: /banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png
+draft: false
+---
+
+## 一次「什么都没发生」的改动
+
+我给一个自己用的媒体转码队列看板加了语言切换:三份语言包、一个 `t(key, vars)` 函数、界面 chrome 上的 `data-i18n`、顶栏一个 ``。
+
+部署上去之后,页面和之前一模一样。不是坏了——是**毫无变化**。同样的版式、同样的中文标签,没有缺失的面板、没有错误卡片、控制台也没有告警。语言选择器就是不在那里,而新代码本该做的其它事也一件都没发生。
+
+这正是我想写下来的那种 bug:因为页面并不是「大体能用」。**脚本在中途停止执行了——两次,两个互不相关的一行原因——而页面照样渲染得完美好看。**
+
+## 为什么什么都看不出来
+
+有两个设计决定我还会再做一遍,而它们各自都替我把故障藏了起来。
+
+第一个是默认语言写在标记里。中文原文就写在元素内部,`data-i18n` 只是一个「切换语言时覆盖它」的钩子。这是刻意的——页面不依赖任何 fetch 就能正确渲染,语言包丢了你会看到中文,而不是一排 `top.theme_to_dark` 这样的键名。代价是:**一层彻底死掉的 i18n,产出的仍然是视觉上完全正确的页面。**
+
+第二个是按钮用内联处理器接线(`onclick="rtFoo()"`),脚本中断之后它们指向从未被定义的函数。没有任何提示。页面只在有人真的点了某个东西时才吐露真相。
+
+所以症状是「我的改动没有效果」——这是一个网页能给出的信息量最低的症状。
+
+## 故障一:一个参数吞掉了一个函数
+
+这个文件里有一个主题块和一个 i18n 块。主题块原本长这样:
+
+```js
+function applyTheme(t) { // t = 主题(theme)
+ label.textContent = t('top.theme_to_dark'); // TypeError: t is not a function
+}
+```
+
+`applyTheme` 写在 i18n 层存在之前,那时 `t` 作为「theme」的缩写完全合理。等 i18n 来了,那个作用域里的 `t` 就不再是主题,而变成了**我想调用的那个函数**——但参数把它遮住了,于是这次调用成了「把一个字符串当函数调」。
+
+经典脚本顶层抛出的 `TypeError` 不只让一行失败。它后面的一切都不再运行:语言包加载、`data-i18n` 扫描、切换器渲染。**一个错字母,静默地删掉了一整块功能。**
+
+修法是一个字符——把参数改名成 `th`——外加一行留在原地、给下一个人的注释:
+
+```js
+function applyTheme(th) { // 不要叫 t:这个文件里 t() 是 i18n 函数
+```
+
+## 故障二:一个来晚了的声明
+
+第二个原因在同一个文件里,而且是个人人都知道、却照样会踩的 JavaScript 规则。主题块在加载时运行并调用 `t('…')`;而 i18n 块在文件更靠下的位置,以这一行开始:
+
+```js
+let LANG = localStorage.getItem('rt_lang') || 'zh-CN';
+```
+
+`let` 和 `const` 会被提升,但**不会初始化**:从作用域开始到那一行之间,它们待在暂时性死区(TDZ)里,任何访问都会抛 `Cannot access 'LANG' before initialization`。因为主题块就在 i18n 块**上面**,这不是竞态——是每次加载都必然发生。
+
+注意它与 `var` 的区别:`var` 会安静地给我一个 `undefined`,然后页面继续工作。TDZ 是这门语言在阻止我写出即将写出的那个 bug;而它之所以看起来像「什么都没发生」,还是因为标记里那份回退文案。
+
+修法是排序,而这里的排序是**依赖**,不是风格偏好:如果一个块调用了 `t()`,i18n 的声明就必须在它上面。这个文件按主题排布(「先是主题,后是 i18n」),因为它就是这么长出来的。
+
+## 为什么我的检查一个都没抓到
+
+我有测试。它们检查的是**意图**,不是**效果**:
+
+- 三份语言包存在且能解析;
+- 标记里用到的每个键,在每份包里都有;
+- 每份包里没有死键(拼写错误探测器);
+- 三份包的 `{var}` 占位符集合一致。
+
+全部为真,全部为绿。而它们**没有一条会执行页面**,所以没有一条能看见一个在 900 行的第 12 行停摆的脚本。静态检查在结构上就无法捕捉「文件合法、程序已死」这类故障。
+
+## 在浏览器里,断言效果
+
+真正找出这两个 bug 的,是向线上页面问一些只有运行中的程序才能回答的问题:
+
+```js
+typeof t === 'function' // 入口点还调得动吗?
+document.querySelectorAll('[data-i18n]').length // 钩子还在吗?
+rtCheckHandlers() // 内联处理器还解析得到吗?
+```
+
+最后那条比看起来更值钱。这个页面带着 **219 个内联 `onclick` 属性,指向 217 个不同的函数**;这条检查把它们逐个枚举,并断言每个名字都在 `window` 上有定义。它把「手点一遍的冒烟测试」变成一个两秒的自动检查,也正是它把「页面看起来是对的」变成了一条可测量的性质。
+
+第二个工具更便宜、也更刻薄:**伪语言**。`?lang=pseudo` 把语言包里每一条文案加上 `[~~` 前缀并拉长 1.6 倍。然后断言两件事——在 1440px 宽下没有横向溢出,以及导航里没有任何元素缺少 `[~~` 前缀(也就是没有任何一处漏翻)。前者抓住只在更长语言里才破的版式;后者抓住根本没接上翻译钩子的节点。它在同一轮里就抓到了一个真的。
+
+诚实的范围说明:伪语言**不能**证明你的马来文翻译好不好。它证明的是**管线完整**——每条文案都流过了机制,版式也扛得住长度。对文字的判断仍然留给人。
+
+## 如果重来一次
+
+1. **在真实浏览器里加载页面,并在相信一个客户端重构成功之前,先对入口点断言 `typeof`。** 一个死掉的脚本,你手上任何静态检查都看不见。
+2. **永远不要让参数名遮住同一个文件里会调用的函数。** 一个字母换掉了我整个下午;而那行注释才是防止它重演的部分。
+3. **把声明顺序当成依赖,而不是审美。** 如果一个块要调用某个函数或读取某个常量,那个声明就放到它上面——在一段长脚本里尤其如此。
+4. **用一张表生成三份语言包。** 这样「键集合一致」是测试而不是希望,漂移根本合不进来。
+5. **当一次改动「什么都没做」时,先怀疑脚本,再怀疑改动。** 「什么都没发生」是渲染型页面的默认失败形态,不是你代码没有生效的证据。
+
+## 结果
+
+两个一行原因,在同一个文件里,都是靠浏览器侧的断言找出来的,而不是靠把代码再读一遍。看板现在能在运行时切换语言,默认渲染与改动前逐字节一致,三份语言包出自同一张生成的表——于是少一个键会表现为一条红色的测试,而不是一个安静地保持中文的词。
+
+可以推广的那句话是我一直记着的:**崩溃会告诉你该往哪里看,而静默停摆什么都不告诉你,还长得像成功。** 如果一个功能可以在一个像素都不动的情况下失效,那么「页面看起来还是对的」就不算一条检查。