Skip to content

Ship stage/2026-08-18-heading-darklinks: entry 404s (R4), antonym links, dark-mode links, local dev API - #177

Merged
audreyt merged 13 commits into
mainfrom
ship/2026-10-16-stage-heading-darklinks
Oct 16, 2026
Merged

audreyt merged 13 commits into
mainfrom
ship/2026-10-16-stage-heading-darklinks

Conversation

@audreyt

@audreyt audreyt commented Oct 16, 2026

Copy link
Copy Markdown
Member

Ships everything on stage/2026-08-18-heading-darklinks, merged with current main (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

  • f63846b fix(layout): keep title controls on the radical's line while they fit (the heading layout is superseded by fix(layout/search): narrow entry rail + mobile back-button tracking #173, see below; the out-of-flow copy-status pill, data-state, and 44px pencil target are kept)
  • fec3ec9 chore: ignore root-level font build output
  • 3f7a56f Potential fix for pull request finding
  • f7772ab fix(dev): decode the public-dir bypass path through tryDecodeURIComponent
  • 0c3b18f fix(dark): entry-card links follow the themed text colour
  • 5481292 Fix prod-shipment regressions: heteronym antonyms, 同義/反義 links, legacy hash deep links, 404 fallback (R4), local dev dictionary APIs
  • 2c8c4fe / 3faf67d docs(agents)
  • f1a90d9 fix(routes): /privacy is a page kind; align e2e with R4

Conflict resolution (bce375e)

Follow-ups found while verifying the merge

Verification on 3ca4355: vp run typecheck clean; vp check 0 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.

audreyt and others added 13 commits August 17, 2026 14:23
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.
The 44px pencil target came from the wrapping-flex heading (f63846b), which
#173's narrow rail superseded; in the rail it grew the radical line to 63px
and rendered the pencil as an oversized block at 333px.
…rrors

Shell readiness returned before the entry rendered and before AssetLoader
injected styles.css, so the title and stylesheet assertions raced the page.
@audreyt
audreyt merged commit 4b8481a into main Oct 16, 2026
4 of 6 checks passed
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