Repository navigation
Ship stage/2026-08-18-heading-darklinks: entry 404s (R4), antonym links, dark-mode links, local dev API - #177
Merged
Conversation
Mobile heading controls dropped to their own row unconditionally: the icon
row was pinned to `grid-row: 3` and `.radical` was squeezed into a
`max-width: 4.5rem` column, so /萌 at 375px burned four stacked rows
(艸 / +8 / = 12 / pencil, then copy·異體字·star) with obvious free space
next to the headword.
Root blocker was `.entry-copy-status { min-width: 8rem }`: a permanent
inline reservation for the copy feedback. 8rem plus three 44px targets can
never fit beside a title at phone widths, so the icon row could not share a
line no matter how much room the title left.
- `.entry-heading` (≤767px) is now ONE wrapping flex line: title
`flex: 1 1 auto; min-width: 0`, `justify-content: flex-end`, `order` for
visual sequence. Flex line-breaking uses max-content sizes, so the radical
box and the icon row stay on the title's line whenever they fit and wrap to
their own right-flush line only when the title needs that width.
Measured: 375px /萌 → 2 rows (heading 193px → 145px); 560px → all one line;
375px /一鼓作氣 → title line 1, controls share line 2; 1280px unchanged.
- `.entry-copy-status` becomes an absolutely positioned pill under the icon
row, visibility driven by `data-state="idle|ok|error"`. The node is never
unmounted (an aria-live region must exist before its content changes), and
out-of-flow satisfies the zero-layout-shift contract more directly than the
reservation did. Background uses --moe-surface-alt; --moe-surface equals the
entry card in dark mode and made the pill invisible.
- `.radical` is nowrap with a 0.15em column gap, so the 部首/筆畫 badge is one
line again, and its stroke-order pencil joins the toolbar at a 44px touch
target (legacy sized it 32x30).
- `row-gap: 0.5rem` gives clearance for ruby tone marks, which paint below
h1.title's own box and otherwise graze a wrapped control line.
Tests updated to the new contract: `mobile: controls share the radical's line
when the heading still has room` (560px) is the guard for the reported
behaviour; the 333px test now asserts controls clear the title and the icon
row stays right-flush; the enlarged-64px-title test asserts both groups clear
the title rather than stacking; visual-invariants R8 (320/390px) asserts the
status region is out of flow, that nothing is reserved left of the first icon,
and that the longest failure message stays inside the viewport.
Also in this commit, because it is the same in-flight narrow-heading work this
checkout carried uncommitted and my layout is verified on top of it (see
5b1a7de on origin/fix/narrow-heading-wrap): the title ruby box in index.css
(e2e blocks r2-assets styles.css, without which length-3 yin+diao cannot share
a heading) and the moe-android diao centering.
Separately, plain `vp dev` gains a dev proxy for the paths the Worker owns
(`/api`, `/assets` → VITE_DEV_API_ORIGIN, default www.moedict.tw, with
`public/` winning via bypass). Without it those paths hit Vite's SPA fallback
and returned index.html, which surfaced as `取得 ASSET_BASE_URL 失敗:
SyntaxError: JSON.parse` and `MIME 型態「text/html」不是「text/css」`.
AGENTS.md documents both dev modes and the heading-layout invariant.
Verified: chromium e2e 259 passed, webkit-romanization 15, unit 2125,
integration 141, tsc -b --noEmit clean, vp lint clean, dark-contrast audit
0 unexpected findings.
Two long-lived sources of `git status` noise in the shared checkout, both now
resolved by deleting the artefacts and closing the gap that let them appear:
- 76MB of `fonts/TW-Kai-shard-{0..7}.ttf` at the repo root — the output of
`scripts/build-tw-kai-shards.mjs --out-dir=fonts`, deterministic and
checksum-verified against `data/assets/fonts/tw-kai-shards.manifest.json`,
with the served copies already in R2 (`r2-assets.moedict.tw/fonts/...`). The
existing rule stated the intent ("Never commit untracked font binaries into
git history") but only matched `data/assets/fonts/`, so the build dir was a
commit candidate for six days. Now `/fonts/` is ignored too.
- `docs/issue-triage/` plus `docs/{materialize,verify}-open-issues-dashboard.mjs`
— a 2026-07 triage workbench for the g0v/moedict-webkit backlog. That backlog
is down to one open issue (#219), `open-issues-dashboard.data.js` was a
timestamped snapshot of the 39-issue state, and the verifier hardcodes
`EXPECTED_LIVE_OPEN = 39` in seven assertions, so committing it would have
landed a permanently failing check. Deleted, and the `!docs/issue-triage/**`
negations added for it in cc0ec62 are removed rather than left pointing at
nothing.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…nent Follow-up to 3f7a56f (Copilot Autofix), keeping its `statSync` try/catch and narrowing the remaining gaps in the same helper: - AGENTS.md makes `tryDecodeURIComponent` (src/utils/dictionary-route.ts) the single decode path for request paths — "禁止裸呼 decodeURIComponent" — so the local try/catch is replaced by that call rather than duplicating the rule at a second site. Same behaviour, one definition. - `new URL(req.url, …)` is guarded too: it is the other throw site in a hook where an exception fails the request instead of the lookup. - Exported so the contract is testable. tests/unit/dev-proxy-public-bypass.test.ts pins what the review was actually about: `/assets/%`, `/assets/%zz`, `/api/%E0%A4%A` and `/%C0%80` answer false instead of throwing; a real `public/` file (and a query-stringed one) wins over the proxy; a directory does not; `..`, `%2e%2e` and NUL-byte paths refuse, with a positive control proving the traversal target exists on disk so those assertions fail closed for the right reason. The test runs under `// @vitest-environment node` because vite.config.ts derives `projectRoot` from `import.meta.url`, which is not a file: URL in the project's happy-dom unit environment. Smoke-verified against a live `vp dev`: `/assets/%` and `/assets/%zz` return 400 from the upstream Worker, `/api/%` returns 200 JSON, `/assets/images/icon.png` is still served from public/, and the dev server stays up with no URIError.
…ue accent
On a dictionary page the auto-linked headwords, citations and 又音 are most of
the visible text — 135 anchors on /萌 alone. Legacy styles.css paints them
`#000`, slightly stronger than the `#333` body text, and signals clickability on
hover (`background: #ddd`) rather than by colour. Dark mode routed the same
anchors through `--moe-link` (#7fd0ff), so every one of them turned blue where
light mode has black, and the entry read as a wall of links.
They now take `--moe-text`, the white the rest of the card already uses, which
reproduces the light-mode relationship: 135/135 anchors go
rgb(127, 208, 255) → rgb(230, 227, 223) in dark and stay rgb(0, 0, 0) in light.
Contrast improves from 10.4:1 to 12.9:1 on the #1c1c1c card.
`--moe-link` is untouched and still paints genuinely external links — the About
page and the sidebar search hover row — where blue is the intended affordance
and about-contrast.spec.ts / dark-mode.spec.ts already pin it.
Guard tests in dark-mode.spec.ts (real legacy cascade routed in) assert the
per-theme colours, that no descendant of `.result` computes to the link blue,
and that the headword anchor still tracks the title: it inherits via
`.result .entry .title .single-char-stroke-trigger a { color: inherit }`, so it
reads #333 in light mode rather than the legacy #000 — measured, not assumed.
Verified: chromium e2e 260 passed, dark-mode + about-contrast 54 passed, unit
2149, typecheck clean, vp check 0 errors, dark-contrast audit 0 unexpected
findings.
…y hash deep links, 404 fallback, local dev dictionary APIs - R1: render t-dict heteronym-level 反義詞 (was declared in type, never rendered) - R2: re-hyperlink a-dict 同義詞/反義詞 via DOMParser anchor whitelist instead of untag() - R3: boot-time applyLegacyHashRewrite() maps legacy #a=萌 / #'月暗暝 / #dict/… hashes to path routes - R4: non-entry unknown paths return HTTP 404 (shell body + no-store kept); tri-state probeDictionaryEntry fails open for entries - L1: vite dev serves /api/<word>.json + index/xref/search-index from local data via filesystem DICTIONARY stand-in reusing prod handlers - toolchain: DOM lib in tsconfig.node.json + CacheStorage.default augmentation replaces ad-hoc globals d.ts (resolves vp check duplicates) Gates: typecheck clean; unit 2166/2166 (+17); integration 145/145 (+4); vp run build clean; vp check 0 errors (cherry picked from commit 2a14051e96adc4be2de3d4535d0fd286e54e3d3e) Conflict resolution: vite.config.ts keeps both dev tiers. serveLocalDictionaryApi (data/dictionary, registered via configureServer) answers first; the existing workerProxyConfig/VITE_DEV_API_ORIGIN proxy remains the fallback tier for routes with no local data (/api/config, /api/cns/*, /assets/*).
The cherry-picked serveLocalDictionaryApi tier serves entry/index/xref/ search-index JSON from data/dictionary before the VITE_DEV_API_ORIGIN proxy, so the old "字典內容來自正式站,不是本機 data/dictionary" caveat is no longer true. Order verified in the installed Vite bundle: configureServer hooks run (node_modules/vite/dist/vite/node/chunks/node.js) before middlewares.use(proxyMiddleware(...)), so plugin middleware precedes server.proxy. Kept explicit: dev still runs no Worker, so HTML shell, 404 fallback, edge-cache headers and /api/config remain dev:remote / serve:e2e territory.
…back worker/index.ts now answers 404 for entry-shaped bare word paths whose probeDictionaryEntry() returns a definitive false, so the neighbouring "HTML shell ... 絕不回 404" line needed scoping: asset unavailability still never produces 404; only a proven "no such headword" does. Records the tri-state fail-open rule (true/null keep 200), the bodyless-404 case when the shell itself is unavailable, and that deploy/rollback smoke routes are unaffected.
The cherry-picked R4 rule 404s any `entry` path whose probeDictionaryEntry()
is a definitive miss. classifyRoute only exempted about/about.html, so
/privacy — a real App.tsx route rendering <Privacy/> — classified as
entry("privacy"), probed as a miss, and a live page started answering 404.
Fixed in the grammar's single source of truth rather than with a second
route list in the Worker: classifyRoute gains a `privacy` kind, and the TS
exhaustive switches in resolveHeadByPath, resolveMiddlePointTarget and
MiddlePoint are completed (privacy now has its own head instead of a
"privacy" headword lookup).
Also aligns the e2e suite the cherry-picked commit never ran. A full
`vp run test:e2e` at the cherry-pick was 11 failed / 249 passed; every
failure was caused by the commit (verified: all 11 pass at 0c3b18f).
Ten were the intended R4 contract change reaching specs that still asserted
the old status — paths with no combined entry (/萌黃, /不存在的詞,
/totallymade-up-path-1234, /foo'bar) now answer 404 with the SPA shell body
intact, which is exactly the 字圖 CharacterImageView surface those specs
measure. Status expectations updated; every other assertion left alone.
The two candidate sweeps accept 200 or 404 because their samples mix real
entries with 字圖 fallbacks and they measure ruby geometry, not status.
Gates: typecheck clean; unit 2177/2177; integration 145/145; e2e 262 passed
0 failed (was 249/11); vp check 0 errors; vp run build clean.
Ships the stage branch (R4 entry 404s, heteronym antonyms, 同義/反義 links, legacy hash deep links, dark-mode entry links, local dev dictionary APIs) on top of main as of 13b1915. Conflict resolution: - Mobile entry heading (src/index.css, tests/e2e/dictionary.spec.ts, AGENTS.md layout note): keep main's narrow rail from #173, which superseded the Aug 17 wrapping-flex approach in f63846b. - vite.config.ts: keep main's pre-normalization traversal guard and the stage branch's local data/dictionary dev API. - AGENTS.md dev-mode section: stage's two-layer (local data, then proxy). - tests/unit/dev-proxy-public-bypass.test.ts: main's version, a superset.
…rrors Shell readiness returned before the entry rendered and before AssetLoader injected styles.css, so the title and stylesheet assertions raced the page.
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.
Ships everything on
stage/2026-08-18-heading-darklinks, merged with currentmain(13b1915).Why now: live moedict.tw returns 200 for any lookup path and puts the raw path into
<title>/og:title. Spammers have been seeding indexable URLs like/最新获取…YoShiE…磁力链接…视频资源合集, which has already drawn two DMCA takedowns via Google Search Console (2026-09-04, 2026-10-16, both from Erasa.ai). R4 (5481292) returns a real 404 for entry-shaped paths that match no headword, so these drop out of the index.Stage commits included
data-state, and 44px pencil target are kept)Conflict resolution (bce375e)
src/index.css,tests/e2e/dictionary.spec.ts, AGENTS.md layout note): main's narrow rail from fix(layout/search): narrow entry rail + mobile back-button tracking #173, which superseded the Aug 17 wrapping-flex layout.vite.config.ts: main's pre-normalization traversal guard plus stage's localdata/dictionarydev API.tests/unit/dev-proxy-public-bypass.test.ts: main's version (a superset).bun.lock: synced to vitest 4.1.11 from chore(deps-dev): bump vitest from 4.1.10 to 4.1.11 #171 (main's lockfile still pinned 4.1.10).Follow-ups found while verifying the merge
Verification on 3ca4355:
vp run typecheckclean;vp check0 errors (1 pre-existing no-control-regex warning in scripts/lib/r2-upload.mjs, same on main); unit 2178 passed; integration 145 passed; e2e chromium 269 passed, 2 skipped, 0 failed.