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.
Rename the localized URL prefixes from full English names to short
ISO-style codes:
/chinese -> /cn
/japanese -> /ja
/korean -> /ko
/vietnamese -> /vi
/indonesian -> /id
/malay -> /ms
Add legacyLanguageRedirects mapping and a LegacyLangRedirect component
in App.tsx so links shared on WeChat (and elsewhere) that still use the
long-form paths keep landing on the right page. The redirect preserves
the sub-path, query string, and hash, e.g.
/malay/browse?post=42#x -> /ms/browse?post=42#x
Also refresh doc-comment examples in i18n.tsx, FigmaBanner.tsx,
PublicLayout.tsx, and useLocalizedPath.ts so future readers see the new
prefixes.