Switching locale (e.g. cn -> ms or cn -> vi) read as a ~220ms flicker
because the <AnimatePresence> wrapping <main> was keyed on the full
pathname. Changing the language prefix changed the key, so AnimatePresence
treated the swap as a route change and re-ran the page fade transition.
Strip the language prefix when computing the key so only real route
changes drive AnimatePresence. Same translations render in place, no
opacity dip. Measured opacity stays at 1 across a cn -> ms switch in
the browser instead of dipping to 0 and back.
Vietnamese text rendered with split diacritics — "Tất cả tài liệu" came
out as "Tâ´t ca' tài liêụ" because the previous font stack led with
six CJK-only families (Noto Sans SC/TC, PingFang SC/TC, Microsoft
YaHei/JhengHei) that have zero Vietnamese coverage. The browser still
matched the Latin base letters in those fonts and dropped the combining
tone marks onto the next available slot, breaking shaping for every
precomposed Vietnamese codepoint (U+1EA5, U+1EC7, …).
Reorder the stack so ui-sans-serif / system-ui / -apple-system / Segoe UI
lead and the CJK families follow as fallbacks. Browsers do per-glyph
font selection so Chinese characters still hit PingFang SC / Noto Sans
SC etc.; the zh-CN and zh-TW pages render unchanged. The fix needs no
extra network requests — every modern OS bundles a Latin font with
Vietnamese support.
Verified in browser at /vi/browse ("Tất cả tài liệu" / "Liên kết" /
"Tệp nén" all compose correctly) and /cn/browse (still PingFang SC).
Each translation gives the nav a different natural width: Malay's 6 labels
total ~594px while Chinese is ~508px. With a fixed Tailwind breakpoint
(`xl:flex`) some languages either clipped at viewports where the nav
technically didn't fit, or collapsed too early to a burger on wide screens.
Drive the toggle from runtime measurement instead:
- Always render a hidden ghost nav (`absolute invisible`) so the browser
can report the inline nav's true scrollWidth for the active locale,
even while we're showing the burger.
- Add refs on the header row, brand block, and right-side actions; a
useLayoutEffect + ResizeObserver compares ghost.scrollWidth against
(rowWidth - brandWidth - rightWidth - 2*rowGap).
- 60px hysteresis on burger -> inline so the layout doesn't oscillate
when the favorites label / burger button swap changes right-side width.
- Drop the now-unused `xl:flex`, `xl:hidden`, `xl:flex-none`,
`xl:inline` classes from the affected elements.
- Close the burger drawer automatically when the row grows wide enough
to show the inline nav again, so the menu doesn't get stuck without
a toggle.
Verified via puppeteer/eval across en / zh-CN / zh-TW / ms / ru / de at
800-2000px: each language switches to burger at its own threshold and
the inline nav never overflows or clips its labels.
The previous run still hit ENOSPC, this time during `npm ci` while
extracting node_modules. The earlier cleanup left the just-failed act
workspace on disk (mtime < 10min threshold), and its half-extracted
node_modules took the runner past the limit before `npm ci` finished.
- Drop the mtime threshold for act workspaces; instead detect the
currently-running job's directory and rm -rf every sibling. The
current job is preserved by path comparison so we never delete files
the running step needs.
- Blow away ~/.npm/_cacache, ~/.npm/_logs, ~/.cache/setup-node entirely.
`npm ci` re-populates what it needs and the cache is the easiest GB
to reclaim on a tight runner.
- Tighten actions-runner workspace retention from 24h to 30min.
- Drop the docker prune --filter; use `docker system prune -af --volumes`
to reclaim builder cache and volumes too.
- Hard-fail with a clear error if <3.5GB free after cleanup, instead of
letting `npm ci` half-write an unusable node_modules and failing
obscurely. Codebase needs ~3GB for hoisted deps.
Backend zh-CN /api/categories now returns two new categories:
id=17 slug=contract-address (合约地址)
id=18 slug=data-records (数据收录)
Export the matching gold-tone PNGs from Figma and wire them into
slugToAsset so CategoryIcon serves the branded artwork instead of
falling back to the lucide iconKey (folder / play). en locale still
returns 13 categories, so English users will pick this up once the
backend ships translations for the two new entries.
A previous deploy failed at the vite chunk-writing stage with
"ENOSPC: no space left on device". The cleanup step ran at the start
of the job but left enough stale data behind that the runner filled up
before `npm run build` could finish.
- Drop the act workspace retention from 60min to 10min. Closely-spaced
pushes used to keep multiple stale jobs around; 10min still preserves
any currently-running job because its mtime keeps advancing.
- Drop _work / setup-node / npm cacache retention from 24h to 60min.
- Drop the `until=24h` filter on docker prune so dangling images,
containers, and builder cache get reclaimed every run.
- Add a second "Ensure free space before build" guard right before the
Build step. If <3GB is free, aggressively prune act caches, npm
cacache, and docker volumes before vite starts writing chunks.
Add a hard-stop linear-gradient background to the volume <input> so the
portion of the track left of the thumb is solid white while the right
portion stays at the previous translucent white. The user can now see
at a glance how loud the level is set, instead of only inferring it
from the thumb position.
- Drop max-w-[1280px] on the desktop header inner row so the header
expands with wider viewports instead of staying capped.
- Move the inline nav / burger toggle from min-[1000px] to xl (1280px)
so the nav only appears when all six items have enough room. The old
threshold relied on overflow-x-auto, which clipped the first/last
characters ("ll assets", "Popula").
- Remove the now-unused .header-nav-scroll CSS along with the scroll
fallback wiring on the nav element.
Mid-width viewports (1000–1280px) now show the burger drawer instead
of a horizontally-scrolled nav. main content stays at max-w-[1280px]
on purpose.
- Add mute toggle button (Volume2/VolumeX icons) to the custom control bar.
- Add an always-visible inline straight-line volume slider on desktop;
mobile keeps mute toggle only and relies on system volume keys.
- Slider at 0 auto-mutes; unmuting from zero restores volume to 1.
- Sync isMuted/volume state via the video volumechange event.
Verified in browser at /browse?type=video: drag slider updates
video.volume, mute toggle preserves volume across on/off.
When `linkToResource` is set, PopularRankRow now navigates to /resource/<id>?single=1 and PostRedirect propagates the `single=1` flag to the /browse?post=... target. MessageStream already supports singlePostMode (no filter bar, no sentinel, only the target post in the list), so favorites cards open the post in isolation instead of dropping the user into the surrounding stream.
Favorites are stored regardless of language but the card click was navigating to /browse?post=<id>&single=1 in the current UI language. When the user switched languages (e.g. zh→en) and tapped a zh-only favorite, the /browse stream couldn't find the post in its English view and showed "Couldn't find this post in the current view".
Make Favorites navigate via /resource/<id> instead. PostRedirect already tries the current UI language first, then falls back to the post's source language and shows a toast. Renamed PopularRankRow's seldom-used `singlePostLink` to `linkToResource` to make the intent obvious; Favorites passes the new prop.
After dropping the fixed width, the language pill still got truncated because its flex parent could shrink it below content size. Add `shrink-0` to the wrapper and switch the trigger button from `w-full` to `w-auto` with `whitespace-nowrap`, so the button always sizes to fit the longest label like "Bahasa Indonesia".
The desktop language pill had a fixed width (md:w-36 lg:w-40) that truncated long labels like "Bahasa Indonesia". Drop the fixed width so the button sizes to content, remove the truncate on the label, and let the dropdown menu match parent width with a sensible min-width. Same `truncate` was hiding the full label inside the mobile drawer dropdown — bumped its width to fit too.
Drop the visible URL box from the modal — only the Copy link button remains. Copy still writes the absolute file download URL to the clipboard so the user can paste it into Chrome/Safari to trigger the download.
On compact cards the user cannot see which file is attached, especially for multi-attachment posts. Make the small download button on PopularRankRow, RecommendedCard, and LatestUpdateCard navigate to the post detail page instead of triggering an immediate download, so the user lands in the full post and picks the exact attachment to download.
Stop exposing the internal /apnew/api/.../download URL in the guide. The dialog no longer renders the file URL or copies it to the clipboard; instead the user copies the current page link (window.location.href) and opens it in their system browser, then taps download again — which uses the real fetch+blob path. Updated step copy and intro in all 7 locales to match the new flow.
Production builds the frontend with VITE_API_URL="" so attachmentDownloadUrl() returns a relative path like /apnew/api/.../download. Pasting that into Safari from another origin fails. Convert the URL to an absolute one (window.location.origin + path) before showing it in the in-app browser guide, and update the modal text in all 7 locales to make clear the copied link is the direct file download URL that, when opened in the system browser, triggers the download automatically.
- Detect in-app WebViews (WeChat / TokenPocket / imToken / Telegram / iOS WKWebView, etc.) and show a guide modal asking the user to open the link in their system browser, with a copy-link action.
- For normal browsers, fetch the attachment as a Blob and trigger download from a same-origin object URL so the file always lands in the user's Downloads folder with the original filename, even when the browser would otherwise inline-preview the response.
- Fall back to the anchor download for files larger than 50MB (avoid loading them entirely into memory) or when fetch fails.
- Pass `sizeBytes` from known call sites so the threshold actually applies.
- Add localized strings for the guide modal in all 7 locales.
See .unipi/docs/debug/2026-06-05-in-app-browser-download-debug.md.
Disconnected state (Figma 4476-15287): bump the compact CTA to the
spec'd dimensions — h-12 (48px), text-[15px], font-medium — so the
yellow 链接钱包 button matches the Figma pill exactly while leaving
the desktop header pill (compact=false) untouched.
Connected state (Figma 4476-15669/16024): rebuild the compact branch as
the spec'd info card + danger button:
- A transparent 登录地址 label (text-[13px] font-bold #E5E5E5) with an
8px gap to the full 0x… address, which uses Figma's character-level
styling: first 5 and last 5 chars rendered bold white, middle 32
chars rendered #A8A9AE / font-medium, replicating Figma's
characterStyleOverrides.
- A full-width 48px disconnect pill at bg-[#2A1B20] with #F36161 text
and the LogOut glyph on the right at 15px font-medium.
Add a new walletLoginAddress i18n key across all 7 locales (en, zh-CN,
ja, ko, vi, id, ms) for the new 登录地址 label.
Both the categories page grid and the home categories carousel now
filter out the 'general' slug before sorting. The full backend list is
still kept in state so posts that resolve their label via the
categories lookup don't lose their name. Backend will follow up by
dropping the slug from /api/categories; this client-side filter then
becomes a defensive no-op.