PAM · SUS round 3

Round 3: the quick wins, rebuilt on main.

Seven frontend-only fixes first made on 22/10, brought onto main after MR !395 merged, one commit each and re-tested. Every fix shows the problem, a recording of main next to the fixed build, and the code that caused it.

7fixed on this branch
0frontend fixes queued next
3earlier rounds
11need backend or model work, not touched

Next, easiest first

  • Nothing queued: every frontend-only gap is done, parked for a decision, or needs the backend.

Parked: needs a product decision

  • G14bRemoving the BETA badge: a test keeps it on purpose. Product decision.

Verified and earlier rounds

  • MR !399These 7 fixes, opened 23/10 for review
  • Round 110 fixes, merged into main through MR !395 on 23/10
  • Round 44 fixes next (G14a, G16, G33, G1b), after this MR merges

Backend / model, out of scope

G3G4G9G13G26G27G29G31G32G25M1–M11
G17
Getting started · severity 9/12

Vietnamese on a first visit

01
Problem
Chưa có tiếng Việt mặc định

A browser that has never chosen a language gets the computer's language, and office machines are set to English, so new Vietnamese users start on an English screen.

Fix

With no saved language, the first page is Vietnamese. Once someone uses the language switch, their choice wins. Recorded as a fresh English browser.

Before main today
After feat/rise-fe-quick-wins
Codeproblem fix
src/i18n/routing.ts
line 16
// stores that locale in the cookie below, so old links keep working.
localePrefix: "never",
// With no URL signal, the locale is decided in this order: the `NEXT_LOCALE`
// cookie, then the browser's `Accept-Language` on a first visit (a Vietnamese
// browser gets `vi`, anything else `en`), then `defaultLocale`.
// With no URL signal, the locale is decided by the `NEXT_LOCALE` cookie. A
// first visit has none, and the proxy then asks for Vietnamese whatever the
// browser's `Accept-Language` says (see `preferVietnameseOnFirstVisit`): the
// users are Vietnamese staff on English-language machines, and research found
// an English first screen is where new users stall. `defaultLocale` stays
// `en` for the sitemap and other request-less renders.
localeDetection: true,
// Remembers the choice per browser. The language switcher writes it on the
src/proxy.ts
line 1
import createMiddleware from "next-intl/middleware";
import type { NextRequest } from "next/server";
import { NextRequest } from "next/server";
import { buildReportOnlyCsp } from "@/config/csp";
line 15
const intlMiddleware = createMiddleware(routing);
const LOCALE_COOKIE = "NEXT_LOCALE";
/**
* A browser that has never chosen a language gets Vietnamese: next-intl would
* otherwise read `Accept-Language`, which is English on most office machines.
* Once the switcher (or this first response) writes the cookie, the cookie wins.
*/
export function preferVietnameseOnFirstVisit(req: NextRequest): NextRequest {
if (req.cookies.has(LOCALE_COOKIE)) {
return req;
}
const headers = new Headers(req.headers);
headers.set("accept-language", "vi");
return new NextRequest(req, { headers });
}
/**
* Resolves each request's locale from the `NEXT_LOCALE` cookie, then
* `Accept-Language`, and rewrites to `/<locale>/...` internally; a legacy
* Resolves each request's locale from the `NEXT_LOCALE` cookie, else
* Vietnamese, and rewrites to `/<locale>/...` internally; a legacy
* `/vi/...` URL redirects to the bare path and sets the cookie (see
* `i18n/routing.ts`). Also remembers the
line 28
*/
export default function proxy(req: NextRequest) {
const res = intlMiddleware(req);
const res = intlMiddleware(preferVietnameseOnFirstVisit(req));
if (!isProduction()) {
res.headers.set("X-Robots-Tag", "noindex, nofollow");
commit 1ec1c1602 source files1 tests updated
G30
Approval · severity 7/12

An Approve button that looks like one

02
Problem
Nút duyệt giống nút thường; không hiểu lựa chọn tuần tự, song song

Approve is the same black button as everything else, Reject is plain text, and the order shows as a bare "Duyệt cùng lúc".

Fix

Approve is green, Reject is outlined in red, and the order reads as a sentence: "Duyệt cùng lúc: mọi người duyệt có thể ký cùng lúc".

Before main today
After feat/rise-fe-quick-wins
Codeproblem fix
src/components/ui/button.tsx
line 29
* | Row icon action that recedes until hover | `ghostFaint` | `icon`, `icon-*` | `box` |
* | Confirm / offer a destructive action | `danger` / `dangerOutline` | `md` | `pill` |
* | Give a positive decision (Approve) | `success` | as the actions beside it | as them |
* | Quiet destructive action (row, link) | quiet variant + `tone="danger"` | | |
* | Remove badge on a thumbnail | `primary` | `icon-5` | `pill` |
line 82
ghostFaint: `${textTones.subtle} hover:bg-surface-soft hover:text-ink active:bg-ink/10 aria-pressed:bg-surface-soft aria-pressed:text-ink aria-[current=true]:bg-surface-soft aria-[current=true]:text-ink`,
// ── Decisions ────────────────────────────────────────────────────────
/** Gives a positive decision (Approve): green, so it never reads as just another ink button. */
success:
"bg-success-600 text-white hover:bg-success-700 active:bg-success-700",
// ── Destructive ──────────────────────────────────────────────────────
/** Confirms a destructive action. */
src/features/artifact/review/components/ReviewPanel.tsx
line 259
stageIndex={approval.stageIndex}
/>
<span className="text-[11px] whitespace-nowrap text-ink/45">
{/* A sentence, not a bare "Sequential": testers did not know what the mode
meant for them (RISE G30). */}
<span className="text-[11px] text-ink/45">
{approval.approvalMode === ApprovalMode.Sequential
? t("sequential")
: t("parallel")}
? t("sequentialHint")
: t("parallelHint")}
</span>
{approval.stageCount && approval.stageCount > 1 ? (
line 336
<Button
className="flex-1"
variant="success"
onClick={() => decide.mutate("approved")}
disabled={decide.isPending}
line 346
<Button
className="flex-1"
variant="ghost"
variant="dangerOutline"
onClick={() => decide.mutate("rejected")}
disabled={decide.isPending}
src/i18n/locales/vi.json
line 121
"withdraw": "Hủy yêu cầu duyệt",
"reopen": "Mở lại để duyệt",
"sequential": "Duyệt lần lượt",
"parallel": "Duyệt cùng lúc",
"stage": "Vòng {index}/{total}",
"loading": "Đang tải hàng đợi…",
line 136
"cancel": "Hủy",
"keepApprovals": "Giữ các lượt đã duyệt",
"startFresh": "Duyệt lại từ đầu"
"startFresh": "Duyệt lại từ đầu",
"sequentialHint": "Duyệt lần lượt: từng người duyệt ký theo đúng thứ tự",
"parallelHint": "Duyệt cùng lúc: mọi người duyệt có thể ký cùng lúc"
},
"submit": {
commit 30cf9b754 source files1 tests updatedEnglish copy updated too
G5
Approval · severity 6/12

No approving your own work

03
Problem
Tự gửi duyệt cho chính mình được

The person sending a creative for approval is listed first among the people who can approve it.

Fix

The approver list leaves out the person sending it. The server still accepts a self-approval sent to the API directly; that guard needs the backend.

Before main today
After feat/rise-fe-quick-wins
Codeproblem fix
src/features/artifact/review/components/SubmitForApprovalModal.tsx
line 59
import { segmentTrackClassName, Toggle } from "@/components/ui/toggle";
import { SearchField } from "@/components/common/search-field";
import { useCurrentUser } from "@/features/auth";
import { listMembers, useCan } from "@/features/brand";
import { getPathname } from "@/i18n/navigation";
line 244
});
// Owner → admin → editor → viewer, then by name; presentation only — anyone in the workspace can be asked.
const { user } = useCurrentUser();
const selfId = user?.id;
// Owner → admin → editor → viewer, then by name; presentation only — anyone in the
// workspace can be asked except the submitter: approving your own work is no review.
const approvers = useMemo(() => {
const needle = search.trim().toLowerCase();
const rows = (members?.members ?? []).filter(
(member) =>
!needle ||
(member.name ?? "").toLowerCase().includes(needle) ||
(member.email ?? "").toLowerCase().includes(needle)
member.userId !== selfId &&
(!needle ||
(member.name ?? "").toLowerCase().includes(needle) ||
(member.email ?? "").toLowerCase().includes(needle))
);
return [...rows].sort((a, b) => {
line 262
);
});
}, [members, search]);
}, [members, search, selfId]);
// An active row filtered out of the roster is no longer active.
commit 56e0a12b1 source files1 tests updated
G6
Export · severity 10/12

Download size stays where you put it

04
Problem
Bắt buộc nhập kích thước khi tải; giá trị tự nhảy về 0/1; có lúc không tải được

Clearing the width to type a new one makes the height jump to 1 and disables Download. A size typed while the picture is still loading gets reset.

Fix

A cleared side leaves the other alone, and once a size is typed, picked or set by a preset, the late image load no longer overwrites it. Same fix in the folder download.

Before main today
After feat/rise-fe-quick-wins
Codeproblem fix
src/components/common/lightbox/lightbox-download-modal.tsx
line 285
}, [overlayUrl]);
// Set once the user types a size, so the image finishing its load (often after
// they started typing) seeds the natural size without overwriting theirs.
const sizeEdited = useRef(false);
useEffect(() => {
let alive = true;
sizeEdited.current = false;
const img = new window.Image();
// `anonymous` sends no `pam_session` cookie and the media route is a different origin,
line 296
}
setNatural({ w: img.naturalWidth, h: img.naturalHeight });
if (sizeEdited.current) {
return;
}
setWidth(defaultSize?.width ?? img.naturalWidth);
setHeight(defaultSize?.height ?? img.naturalHeight);
line 389
// A locked ratio caps the derived side at the export limit and pulls the typed side
// back with it, so the shape asked for is the shape the server renders.
// back with it, so the shape asked for is the shape the server renders. A cleared
// field (0) leaves the other side alone instead of collapsing it to 1 px.
const onWidth = (v: number) => {
if (!locked || !ratio) {
sizeEdited.current = true;
if (!locked || !ratio || v === 0) {
setWidth(v);
return;
line 400
};
const onHeight = (v: number) => {
if (!locked || !ratio) {
sizeEdited.current = true;
if (!locked || !ratio || v === 0) {
setHeight(v);
return;
line 413
return;
}
sizeEdited.current = true;
setWidth(Math.round(natural.w * factor));
setHeight(Math.round(natural.h * factor));
line 454
}
markDownloadDefaultEdited();
sizeEdited.current = true;
setLocked(true);
if (next === "web") {
line 810
size="inline"
onClick={() => {
sizeEdited.current = true;
setWidth(size.width);
setHeight(size.height);
src/features/artifact/components/folder/FolderDownloadModal.tsx
line 393
};
// A locked ratio caps the derived side too, pulling the typed side back to keep the shape.
// A cleared field (0) leaves the other side alone instead of collapsing it to 1 px.
const onWidth = (v: number) => {
const w = Math.min(MAX_EXPORT_EDGE, v);
if (!locked || !ratio) {
if (!locked || !ratio || w === 0) {
setDim({ width: w });
return;
line 407
const onHeight = (v: number) => {
const h = Math.min(MAX_EXPORT_EDGE, v);
if (!locked || !ratio) {
if (!locked || !ratio || h === 0) {
setDim({ height: h });
return;
commit 78ab8ae82 source files2 tests updated
G12
Getting started · severity 6/12

The upload limit, said before uploading

05
Problem
Giới hạn upload khoảng 200 file không báo trước

Every file in a big drop starts uploading at once, and nothing says how many one upload can take, so the tail of a large batch fails part-way.

Fix

More than 500 files is refused before anything is sent, with the limit named, and uploads go 4 at a time. Where the server's rate limit is on, a big batch can still fail part-way; the existing message counts those files.

Before not recorded

Recording the old behaviour would really upload 520 files into the data.

After feat/rise-fe-quick-wins
Codeproblem fix
src/features/asset/components/AssetLibrary.tsx
line 57
import { useAssetSelection } from "../hooks/useAssetSelection";
import { useAssetFolderOrganization } from "../hooks/folder/useAssetFolderOrganization";
import { withConcurrency } from "../hooks/folder/useFolderAssetImport";
import { MAX_FOLDER_ASSET_ENTRIES } from "../utils/folder/folderImport";
import { useAssetFolderViewState } from "../hooks/folder/useAssetFolderViewState";
import { sortAssetFolderCards } from "../hooks/folder/useAssetFolders";
line 105
}
/** Uploads in flight at once from one pick or drop. */
const UPLOAD_CONCURRENCY = 4;
export function AssetLibrary({ brandId }: { brandId: string }) {
const t = useTranslations("asset.library");
line 499
return;
}
// Said BEFORE anything is sent, with the number, so a big drop is not a silent
// partial upload. Same ceiling as a folder import.
if (files.length > MAX_FOLDER_ASSET_ENTRIES) {
errorToast.show(
new Error(
t("tooManyUploadsDetail", {
count: files.length,
limit: MAX_FOLDER_ASSET_ENTRIES,
})
),
{ title: t("tooManyUploads", { limit: MAX_FOLDER_ASSET_ENTRIES }) }
);
return;
}
setUploadingFiles(files.map((f) => f.name));
const remaining = new Set(files.map((f) => f.name));
const uploadFolderId = openFolderId;
const outcomes = await Promise.allSettled(
files.map(async (file) => {
try {
// By NAME, not `file.type`: a `.mov` the OS never mapped arrives with `type
// === ""` and would otherwise be uploaded as `mode: "plain"` when it should
// declare itself a video.
const uploadMode: AssetUploadMode =
familyForName(file.name) === "video" ? "video" : "plain";
const result = await uploadBrandAsset(brandId, file, uploadMode);
if (uploadFolderId) {
await setAssetFolderId(brandId, result.assetId, uploadFolderId);
}
} finally {
remaining.delete(file.name);
setUploadingFiles([...remaining]);
const uploadOne = async (file: File): Promise<void> => {
try {
// By NAME, not `file.type`: a `.mov` the OS never mapped arrives with `type
// === ""` and would otherwise be uploaded as `mode: "plain"` when it should
// declare itself a video.
const uploadMode: AssetUploadMode =
familyForName(file.name) === "video" ? "video" : "plain";
const result = await uploadBrandAsset(brandId, file, uploadMode);
if (uploadFolderId) {
await setAssetFolderId(brandId, result.assetId, uploadFolderId);
}
})
);
} finally {
remaining.delete(file.name);
setUploadingFiles([...remaining]);
}
};
const outcomes: PromiseSettledResult<void>[] = [];
// A few at a time, not all at once, so a big drop does not open hundreds of
// requests together. Pacing does not cap requests per minute: where the API's
// rate limit is on, a large batch can still fail partway, and the toast below
// counts the files that did.
await withConcurrency(files, UPLOAD_CONCURRENCY, async (file, index) => {
outcomes[index] = await uploadOne(file).then(
(): PromiseFulfilledResult<void> => ({
status: "fulfilled",
value: undefined,
}),
(reason: unknown): PromiseRejectedResult => ({
status: "rejected",
reason,
})
);
});
setUploadingFiles([]);
const rejected = outcomes.filter(
src/features/asset/hooks/folder/useFolderAssetImport.tsx
line 116
/** Runs `worker` over `items` with at most `limit` in flight at once. */
async function withConcurrency<T>(
export async function withConcurrency<T>(
items: readonly T[],
limit: number,
src/i18n/locales/vi.json
line 1299
"uploadsFailed": "{count} lượt tải lên thất bại",
"checkGuidelinesFailed": "Không kiểm tra được nguyên tắc thương hiệu",
"subtitle": "Logo, ảnh sản phẩm, font để AI dùng làm đầu vào. Ấn phẩm AI tạo nằm ở Trang chủ."
"subtitle": "Logo, ảnh sản phẩm, font để AI dùng làm đầu vào. Ấn phẩm AI tạo nằm ở Trang chủ.",
"tooManyUploads": "Tối đa {limit} tệp mỗi lần tải lên",
"tooManyUploadsDetail": "Bạn đã chọn {count} tệp. Mỗi lần chỉ tải lên tối đa {limit} tệp. Chưa tệp nào được tải lên, hãy chia nhỏ rồi thử lại."
},
"bulkDelete": {
commit b5e822ff4 source files1 tests updatedEnglish copy updated too
G24
Playbook & library · severity 6/12

Search that matches how people type

06
Problem
Thư viện chung cả khối quá nhiều ảnh

Search needs the exact spelling: "testdrive" misses "test-drive", "ao dai" misses "Áo dài", "vf8" misses "VF 8".

Fix

Gallery search ignores case, Vietnamese accents, spaces and punctuation, for creatives and folder names alike. It still searches what is loaded, as before.

Before main today
After feat/rise-fe-quick-wins
Codeproblem fix
src/features/artifact/hooks/gallery/useDisplayedArtifacts.ts
line 6
import { artifactLabel } from "../../utils/artifactLabel";
import { searchKey } from "../../utils/searchKey";
import type { GalleryArtifact } from "../../types";
line 29
)
: artifacts;
const q = search.trim().toLowerCase();
// A query of only spaces or punctuation folds to nothing and filters nothing.
const q = searchKey(search);
if (q) {
base = base.filter((a) =>
`${artifactLabel(a)} ${a.prompt ?? ""}`.toLowerCase().includes(q)
searchKey(`${artifactLabel(a)} ${a.prompt ?? ""}`).includes(q)
);
}
src/features/artifact/utils/folder/visibleFolders.ts
line 1
import { searchKey } from "../searchKey";
import type { FolderCard } from "../../types";
import { childrenOf } from "./folderTree";
line 19
mineOnly ? list.filter((f) => f.mine) : list;
const childFolders = scopeMine(childrenOf(folders, openFolderId));
const q = search.trim().toLowerCase();
const q = searchKey(search);
if (!q) {
return childFolders;
}
return (searchEverywhere ? scopeMine(folders) : childFolders).filter((f) =>
f.name.toLowerCase().includes(q)
searchKey(f.name).includes(q)
);
}
src/features/artifact/utils/searchKey.ts
line 0
/**
* Folds a name or a query to the form the gallery compares them in: case,
* Vietnamese diacritics (đ included), spaces and punctuation dropped, so "vf8"
* finds "VF 8" and "ao dai" finds "Áo dài" (RISE G24). Creatives and folders use
* the same fold, so the two never disagree about a match.
*/
export function searchKey(text: string): string {
return text
.normalize("NFD")
.replace(/\p{M}/gu, "")
.replace(/[đĐ]/g, "d")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
}
commit 593be20d3 source files2 tests updated
G14b
Variants · severity 8/12

An empty Workflow page that explains itself

07
Problem
Workflow ở mục "beta", ít người biết, thuật ngữ khó hiểu

The empty Workflow page says "Biến kết quả tuyệt vời tiếp theo thành quy trình lặp lại được", which does not say it makes variants in bulk.

Fix

It reads "Tạo hàng loạt biến thể từ một danh sách", gives two examples and points to Workflow mới for templates. The BETA badge stays: a test keeps it on purpose.

Before main today
After feat/rise-fe-quick-wins
Codeproblem fix
src/i18n/locales/vi.json
line 5175
"clearSearch": "Xóa tìm kiếm",
"empty": {
"title": "Biến kết quả tuyệt vời tiếp theo thành quy trình lặp lại được",
"description": "Bắt đầu với một công thức đã được kiểm chứng cho chiến dịch, video hoặc bản chuyển thể, rồi tùy chỉnh theo ý bạn trên canvas."
"title": "Tạo hàng loạt biến thể từ một danh sách",
"description": "Ví dụ: một key visual thành 20 bối cảnh, hoặc mỗi tuyến xe một ảnh. Bấm Workflow mới để bắt đầu từ một mẫu hoặc tự dựng các bước."
},
"card": {
commit 4bbaa3aa2 source files2 tests updatedEnglish copy updated too

Branch feat/rise-fe-quick-wins at 4bbaa3aa, on main after MR !395. In review as MR !399. Updated 23/10/2026 17:19.