PAM · SUS fixes

Fixing the RISE findings, easiest first.

Wording and layout problems from the usability study, fixed in the frontend only, one commit each. Every fix shows the problem, a recording of today's app next to the fixed one, and the code that caused it.

10fixed on this branch
0frontend fixes queued next
5fixed earlier (feat/rise-quick-wins)
16need backend or model work, not touched

Next, easiest first

    Parked: needs a product decision

    • G16One name for Keep / Like: the chat says "Giữ", the gallery says "Thích". Product naming decision, not a wording fix.
    • G14bThe BETA badge on Workflow stays: a test pins it deliberately. Only the description changed.

    Already fixed earlier

    • G17aVietnamese by default
    • G30Prominent Approve button
    • G5No self-approval (UI)
    • G6Download size no longer jumps to 1
    • G12Upload limit stated before upload

    Backend / model, out of scope

    G3G4G9G13G26G32G33G7G1bG25G27G28G29G31G24M1–M11
    G11
    Getting started · severity 10/12

    Two ways to create a brand, explained side by side

    01
    Problem
    Không có onboarding; không hiểu hai lựa chọn tạo workspace (từ website hoặc tạo trống)

    Only the selected tab's explanation was visible, so the two options could not be compared. "Bắt đầu từ trống" (start from blank) did not say what you give up.

    Fix

    Each option keeps a one-line hint under its own pill, and the labels say what they do: "Tự động từ website" / "Tự thiết lập".

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/config/env.ts
    line 80
    // Face Hub and ONNX Runtime from jsDelivr. Off, neither is requested.
    smartSelect: isOn(process.env.FEATURE_SMART_SELECT),
    // Frontend-only: the "From website" tab of brand creation. Off, only
    // "Start from blank" is offered.
    // Frontend-only: the "From your website" tab of brand creation. Off, only
    // "Set up manually" is offered.
    brandWebsiteExtract: isOn(process.env.FEATURE_BRAND_WEBSITE_EXTRACT),
    },
    src/features/onboarding/components/BrandCreateFlow.tsx
    line 80
    // No brand role to consult: the brand does not exist yet and its creator becomes its owner.
    const [step, setStep] = useState<Step>("source");
    // Off, "Start from blank" is the only source and the tabs are hidden.
    // Off, "Set up manually" is the only source and the tabs are hidden.
    const websiteExtractEnabled = isEnabled("brandWebsiteExtract");
    const [mode, setMode] = useState<SourceMode>(
    src/features/onboarding/components/SourceStep.tsx
    line 36
    heading: string;
    mode: SourceMode;
    /** False hides the From website / Start from blank switch (`FEATURE_BRAND_WEBSITE_EXTRACT` off). */
    /** False hides the From your website / Set up manually switch (`FEATURE_BRAND_WEBSITE_EXTRACT` off). */
    showModeTabs?: boolean;
    onModeChange: (mode: SourceMode) => void;
    line 92
    </Heading>
    <Text tone="muted" size="body-sm" className="mt-0.5">
    {mode === "url" ? t("source.urlBlurb") : t("source.blankBlurb")}
    {showModeTabs ? t("source.chooseBlurb") : t("source.blankBlurb")}
    </Text>
    </div>
    {/* Each option keeps its one-line explanation under its own pill, so the two
    can be compared before picking (RISE G11: users didn't tell them apart). */}
    {showModeTabs && (
    <SegmentedControl<SourceMode>
    options={sourceModeTabs}
    value={mode}
    onChange={onModeChange}
    size="sm"
    fullWidth
    />
    <div className="flex flex-col gap-2">
    <SegmentedControl<SourceMode>
    options={sourceModeTabs}
    value={mode}
    onChange={onModeChange}
    size="sm"
    fullWidth
    />
    <div className="grid grid-cols-2 gap-3 px-1">
    {sourceModeTabs.map((option) => (
    <Text
    key={option.id}
    size="body-xs"
    tone={option.id === mode ? "muted" : "subtle"}
    >
    {option.id === "url"
    ? t("source.urlHint")
    : t("source.blankHint")}
    </Text>
    ))}
    </div>
    </div>
    )}
    src/i18n/locales/vi.json
    line 3822
    },
    "source": {
    "fromWebsite": "Từ website",
    "startBlank": "Bắt đầu từ trống",
    "urlBlurb": "Nhập website thương hiệu của bạn, chúng tôi sẽ tự động trích xuất nhận diện.",
    "fromWebsite": "Tự động từ website",
    "startBlank": "Tự thiết lập",
    "chooseBlurb": "Chọn cách bắt đầu. Bạn có thể sửa mọi thứ sau.",
    "urlHint": "Chúng tôi đọc website và tự điền logo, màu, font, giọng văn giúp bạn (khoảng 1 phút).",
    "blankHint": "Không có website? Đặt tên thương hiệu, rồi tự thêm logo và màu sau.",
    "blankBlurb": "Đặt tên thương hiệu và bắt đầu với không gian trống — bạn có thể bổ sung nhận diện sau.",
    "websiteUrl": "URL website",
    commit 9e6502db5 source files3 tests updatedEnglish copy updated too4 help pages
    G17
    Approval · severity 9/12

    Approval wording people understand

    02
    Problem
    Nhãn dịch khó hiểu ("Withdraw")

    "Rút lại" (withdraw), "Tuần tự / Song song" (sequential / parallel) and "Giai đoạn 1/3" (stage) are translated jargon.

    Fix

    "Hủy yêu cầu duyệt", "Duyệt lần lượt / Duyệt cùng lúc", "Vòng 1/3"; the resubmit choice reads "Giữ các lượt đã duyệt / Duyệt lại từ đầu".

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/i18n/locales/vi.json
    line 66
    "approved": "Đã duyệt",
    "rejected": "Bị từ chối",
    "withdrawn": "Đã rút",
    "withdrawn": "Đã hủy yêu cầu",
    "pending": "{approved}/{required} đã duyệt"
    },
    line 85
    "justNow": "vừa xong",
    "rowMeta": "{who} · đã yêu cầu {when} · cần {count} lượt duyệt",
    "rowMetaStage": "{who} · đã yêu cầu {when} · cần {count} lượt duyệt · Giai đoạn {index}/{total}",
    "withdrawn": "Đã rút"
    "rowMetaStage": "{who} · đã yêu cầu {when} · cần {count} lượt duyệt · Vòng {index}/{total}",
    "withdrawn": "Đã hủy yêu cầu"
    },
    "thread": {
    line 98
    "approved": "<name>{who}</name> đã duyệt",
    "rejected": "<name>{who}</name> đã từ chối yêu cầu",
    "withdrawn": "<name>{who}</name> đã rút yêu cầu",
    "withdrawn": "<name>{who}</name> đã hủy yêu cầu",
    "reassigned": "<name>{who}</name> đã giao lại",
    "reopened": "<name>{who}</name> đã mở lại để duyệt"
    line 109
    "rejectFailed": "Không từ chối được",
    "commentFailed": "Không đăng được bình luận đó",
    "withdrawn": "Đã rút yêu cầu",
    "withdrawFailed": "Không rút được",
    "withdrawn": "Đã hủy yêu cầu duyệt",
    "withdrawFailed": "Không hủy được yêu cầu",
    "resubmitted": "Đã gửi lại yêu cầu",
    "resubmitFailed": "Không gửi lại được",
    line 119
    "edit": "Chỉnh sửa",
    "resubmit": "Gửi lại",
    "withdraw": "Rút lại",
    "reopen": "Mở lại",
    "sequential": "Tuần tự",
    "parallel": "Song song",
    "stage": "Giai đoạn {index}/{total}",
    "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…",
    "emptyTitle": "Chưa có lượt duyệt nào",
    line 135
    "resubmitDescription": "Thông báo lại cho người duyệt. Giữ các lượt duyệt đã có, hoặc xóa chúng để bắt đầu vòng mới.",
    "cancel": "Hủy",
    "keepApprovals": "Giữ lượt duyệt",
    "startFresh": "Bắt đầu lại"
    "keepApprovals": "Giữ các lượt đã duyệt",
    "startFresh": "Duyệt lại từ đầu"
    },
    "submit": {
    line 159
    },
    "done": "Xong",
    "howTheyDecide": "Cách duyệt",
    "parallel": "Song song",
    "sequential": "Tuần tự",
    "howTheyDecide": "Thứ tự duyệt",
    "parallel": "Cùng lúc",
    "sequential": "Lần lượt",
    "parallelHint": "Ai cũng có thể duyệt, theo thứ tự bất kỳ.",
    "sequentialHint": "Người duyệt lần lượt quyết định theo thứ tự.",
    line 659
    "download": "Tải xuống",
    "requestReview": "Yêu cầu duyệt",
    "withdrawReview": "Rút yêu cầu duyệt",
    "withdrawReview": "Hủy yêu cầu duyệt",
    "unarchive": "Bỏ lưu trữ",
    "archive": "Lưu trữ",
    line 746
    },
    "withdraw": {
    "title": "Rút yêu cầu duyệt",
    "message": "Rút các yêu cầu duyệt đang mở của bạn trên {count} creative đã chọn? Người duyệt sẽ không còn thấy chúng nữa.",
    "confirm": "Rút lại",
    "partialFailure": "{withdrawn, plural, =0 {} other {Đã rút # yêu cầu. }}{failed} yêu cầu không rút được.{skipped, plural, =0 {} other { # creative không có yêu cầu đang mở nào của bạn.}}",
    "title": "Hủy yêu cầu duyệt",
    "message": "Hủy các yêu cầu duyệt đang mở của bạn trên {count} creative đã chọn? Người duyệt sẽ không còn thấy chúng nữa.",
    "confirm": "Hủy yêu cầu",
    "partialFailure": "{withdrawn, plural, =0 {} other {Đã hủy # yêu cầu. }}{failed} yêu cầu không hủy được.{skipped, plural, =0 {} other { # creative không có yêu cầu đang mở nào của bạn.}}",
    "noneOpen": "Không creative nào đã chọn có yêu cầu đang mở của bạn",
    "success": "Đã rút {withdrawn} yêu cầu.{skipped, plural, =0 {} other { # creative không có yêu cầu đang mở nào của bạn.}}"
    "success": "Đã hủy {withdrawn} yêu cầu.{skipped, plural, =0 {} other { # creative không có yêu cầu đang mở nào của bạn.}}"
    }
    },
    commit 4b3edfca2 source files8 tests updatedEnglish copy updated too
    G14b
    Variants · severity 8/12

    Say what Workflow is for

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

    The page subtitle "Quy trình sáng tạo tốt nhất của bạn, sẵn sàng chạy lại" never says this is where bulk variants are made.

    Fix

    The subtitle now says it: save the steps once, re-run them for many products, sizes or languages. The BETA badge stays: a test pins it on purpose.

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/i18n/locales/vi.json
    line 5137
    "title": "Workflow",
    "beta": "BETA",
    "subtitle": "Quy trình sáng tạo tốt nhất của bạn, sẵn sàng chạy lại.",
    "subtitle": "Lưu lại các bước đã làm ra một ấn phẩm tốt, rồi chạy lại cho nhiều sản phẩm, kích thước hoặc ngôn ngữ chỉ với một lần bấm.",
    "newWorkflow": "Workflow mới",
    "tabs": {
    commit a763bf8b2 source filesEnglish copy updated too
    G10
    Getting started · severity 9/12

    Inputs and outputs are named as such

    04
    Problem
    Nhầm folder dự án với kho brand asset

    Brand Assets (inputs) and Home's creatives (outputs) use identical folder bands, and the asset empty state calls uploads "Artifact tham khảo", the word used for outputs.

    Fix

    Brand Assets gets a subtitle saying it holds what the AI works from (outputs are on Home), the empty state asks for a logo or product photo, and Home's section reads "Ấn phẩm AI đã tạo".

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/features/asset/components/AssetLibrary.tsx
    line 20
    import { SegmentedControl } from "@/components/ui/segmented-control";
    import { SearchField } from "@/components/common/search-field";
    import { Heading } from "@/components/ui/typography";
    import { Heading, Text } from "@/components/ui/typography";
    import { FilePickerInput } from "@/components/ui/file-dropzone";
    import {
    line 697
    />
    ) : (
    <Heading level={1} size="page">
    {t("title")}
    </Heading>
    <div>
    <Heading level={1} size="page">
    {t("title")}
    </Heading>
    {/* Says which side of the input/output split this page is: testers
    mixed it up with the creatives folders on Home (RISE G10). */}
    <Text
    tone="muted"
    size="body-sm"
    className="mt-1 max-w-xl"
    >
    {t("subtitle")}
    </Text>
    </div>
    )}
    <div className="flex items-center gap-2">
    src/i18n/locales/vi.json
    line 737
    },
    "folders": {
    "artifactsTitle": "Artifact",
    "artifactsTitle": "Ấn phẩm AI đã tạo",
    "create": "Tạo",
    "createAndMove": "Tạo & chuyển",
    line 1285
    "downloadFolderFailed": "Không tải xuống được thư mục",
    "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"
    "checkGuidelinesFailed": "Không kiểm tra được nguyên tắc thương hiệu",
    "subtitle": "Logo, ảnh sản phẩm, font và thiết kế cũ để AI dùng làm đầu vào. Ấn phẩm AI tạo ra nằm ở Trang chủ."
    },
    "bulkDelete": {
    line 1341
    "noMatchHint": "Hãy thử tên hoặc từ khóa khác.",
    "clearSearch": "Xóa tìm kiếm",
    "emptyDropLabel": "Tải lên Artifact tham khảo đầu tiên của bạn",
    "emptyDropLabel": "Tải lên tư liệu thương hiệu đầu tiên",
    "emptyTitle": "Chưa có thiết kế nào được tải lên.",
    "emptyHint": "Nhấp hoặc kéo thả để thêm Artifact tham khảo đầu tiên của bạn."
    "emptyHint": "Nhấp hoặc kéo thả để thêm logo, ảnh sản phẩm hoặc thiết kế tham khảo đầu tiên."
    },
    "archived": {
    commit 378a6a3a3 source files3 tests updatedEnglish copy updated too
    G19
    Production · severity 5/12

    A visible way back into the open chat

    05
    Problem
    Nút quay lại phiên đang làm khó tìm

    From the gallery, the only control that reopens the current session is an unlabelled 18px glyph in the composer's corner.

    Fix

    It is now a labelled "Mở khung chat" button above the composer.

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/features/artifact/components/chat/ComposerDock.tsx
    line 7
    import { cn } from "@/utils/cn";
    import {
    Tooltip,
    TooltipContent,
    TooltipTrigger,
    } from "@/components/ui/tooltip";
    import { MessagesSquare } from "@/components/icons";
    import { Button } from "@/components/ui/button";
    const ENTER = { opacity: [0, 1], y: [16, 0], scale: [0.99, 1] };
    line 63
    >
    <div className="pointer-events-auto relative mx-auto flex w-full max-w-3xl flex-col gap-1.5">
    <Tooltip>
    <TooltipTrigger
    type="button"
    onClick={onExpand}
    aria-label={t("openAsPanel")}
    className="absolute top-4 right-4 z-20 text-ink/60 transition-colors hover:text-ink/70 active:text-ink"
    >
    <svg
    width="18"
    height="18"
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth="2.25"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    >
    <path d="M15 4h5v5" />
    <path d="M9 20H4v-5" />
    </svg>
    </TooltipTrigger>
    <TooltipContent side="bottom">{t("openAsPanel")}</TooltipContent>
    </Tooltip>
    {/* A labelled button above the composer, not a bare 18px corner glyph: it is
    the way back into the open session, and testers could not find it (RISE G19). */}
    <Button
    type="button"
    variant="surface"
    size="sm"
    shape="pill"
    onClick={onExpand}
    className="self-end shadow-sm"
    >
    <MessagesSquare className="size-3.5" />
    {t("openAsPanel")}
    </Button>
    {children}
    </div>
    src/i18n/locales/vi.json
    line 345
    },
    "dock": {
    "openAsPanel": "Mở dạng bảng chat"
    "openAsPanel": "Mở khung chat"
    },
    "media": {
    commit 9d69c8933 source filesEnglish copy updated too
    G23
    Export · severity 7/12

    Say who can open a copied link

    06
    Problem
    Chia sẻ link khó hiểu; người ngoài workspace không mở được

    "Chia sẻ" silently copies a link and the toast only says "Đã sao chép liên kết vào bộ nhớ tạm". People found out later that colleagues outside the workspace could not open it. The chat's share menu offers "Chỉnh sửa / Chỉ xem" without saying for whom.

    Fix

    The toast says it: "Người ngoài workspace sẽ không mở được." The chat share modes read "Cho đồng nghiệp làm tiếp" and "Bản xem lại (không sửa được)".

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/i18n/locales/vi.json
    line 396
    "shareSessionLink": "Chia sẻ liên kết phiên",
    "shareWithLink": "Chia sẻ bằng liên kết",
    "shareEditing": "Chỉnh sửa",
    "shareEditing": "Cho đồng nghiệp làm tiếp",
    "shareEditingHint": "Thành viên không gian làm việc có thể tiếp tục cuộc trò chuyện này.",
    "shareViewOnly": "Chỉ xem",
    "shareViewOnly": "Bản xem lại (không sửa được)",
    "shareViewOnlyHint": "Bản phát lại cố định mà ai đã đăng nhập cũng xem được.",
    "publishToInspiration": "Đăng lên Cảm hứng",
    line 786
    "referenceCopiedHint": "Dán vào khung chat để nhắc tới artifact này",
    "clipboardFailed": "Không sao chép được vào bộ nhớ tạm",
    "linkCopied": "Đã sao chép liên kết vào bộ nhớ tạm",
    "linkCopied": "Đã sao chép liên kết. Người ngoài workspace sẽ không mở được.",
    "linkFailed": "Không sao chép được liên kết",
    "restored": "Đã khôi phục artifact",
    line 839
    "versionRestored": "Đã khôi phục phiên bản",
    "versionRestoreFailed": "Không khôi phục được phiên bản này",
    "linkCopied": "Đã sao chép liên kết vào bộ nhớ tạm",
    "linkCopied": "Đã sao chép liên kết. Người ngoài workspace sẽ không mở được.",
    "linkFailed": "Không sao chép được liên kết",
    "openInEditor": "Mở trong trình chỉnh sửa",
    commit 1fada0042 source files4 tests updatedEnglish copy updated too
    G22b
    Export · severity 7/12

    Download presets: web or print

    07
    Problem
    Chưa chọn được bản nhẹ hay bản nét; không biết đã có upscale

    The download dialog asks for width, height, AI upscale, a size limit and a format separately. Nothing tells a marketer which combination makes a light social post or a sharp print file, and AI upscale is off and unlabelled.

    Fix

    Two presets at the top, "Đăng mạng" and "In ấn, LED", fill the existing fields in one click and say what they did. Editing any field by hand releases the preset.

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/components/common/download/download-form.tsx
    line 221
    }
    export type DownloadPreset = "web" | "print";
    /** "Use for" presets that fill the fields below in one click: web keeps the original size
    * as a light JPG; print enlarges with AI (when offered) into an unlimited PNG. `value` is
    * read off the fields, so editing any of them simply leaves both pills unselected. */
    export function UsePresetField({
    value,
    printFactor,
    onPreset,
    }: {
    value: DownloadPreset | null;
    /** The AI enlargement the print preset applies; 1 when upscale is not offered. */
    printFactor: number;
    onPreset: (preset: DownloadPreset) => void;
    }) {
    const t = useTranslations("commonDownload.preset");
    const tabs: { id: DownloadPreset; label: string }[] = [
    { id: "web", label: t("web") },
    { id: "print", label: t("print") },
    ];
    return (
    <div className="flex flex-col gap-2">
    <Label size="xs" tone="secondary">
    {t("label")}
    </Label>
    <SegmentedControl<DownloadPreset | "">
    options={tabs}
    value={value ?? ""}
    onChange={(id) => id && onPreset(id)}
    fullWidth
    deferAnimation
    />
    {value && (
    <p className="text-[11px] text-ink/50">
    {value === "web"
    ? t("webHint")
    : printFactor > 1
    ? t("printHint", { factor: printFactor })
    : t("printHintNoUpscale")}
    </p>
    )}
    </div>
    );
    }
    /** "AI upscale" switch plus a factor slider. `scale` is read off the dimensions, so a typed
    * size moves the slider too; `maxFactor` is the largest factor the size limits allow. With
    src/components/common/lightbox/lightbox-download-modal.tsx
    line 51
    SizeBudgetField,
    UpscaleField,
    UsePresetField,
    } from "../download/download-form";
    import type { DownloadPreset } from "../download/download-form";
    import { useOverlayUploader } from "../download/overlay-upload";
    import { SegmentedControl } from "@/components/ui/segmented-control";
    line 428
    };
    // RISE G22b: testers could not tell a light web file from a sharp print file, nor that AI
    // upscale existed. A preset only fills the existing fields; it is read back off them.
    const printFactor = showUpscale && canUpscale ? maxFactor : 1;
    const naturalW = natural?.w ?? 0;
    const naturalH = natural?.h ?? 0;
    const preset: DownloadPreset | null =
    format === "jpg" &&
    !upscale &&
    width === naturalW &&
    height === naturalH &&
    (hideSizeBudget || (sizeMode === "specific" && maxKb === QUICK_MAX_KB))
    ? "web"
    : format === "png" &&
    sizeMode === "none" &&
    upscale === printFactor > 1 &&
    width === Math.round(naturalW * printFactor) &&
    height === Math.round(naturalH * printFactor)
    ? "print"
    : null;
    const onPreset = (next: DownloadPreset) => {
    if (!natural) {
    return;
    }
    markDownloadDefaultEdited();
    setLocked(true);
    if (next === "web") {
    setUpscale(false);
    setWidth(natural.w);
    setHeight(natural.h);
    setFormat("jpg");
    if (!hideSizeBudget) {
    setSizeMode("specific");
    setMaxKb(QUICK_MAX_KB);
    }
    } else {
    setUpscale(printFactor > 1);
    onScale(printFactor);
    setFormat("png");
    setSizeMode("none");
    setMaxKb(null);
    }
    };
    const onSizeMode = (mode: SizeMode) => {
    setSizeMode(mode);
    line 738
    <div className="flex flex-col gap-4">
    {extraFields}
    {!isVideo && !isAudio && !callerOwned && natural && (
    <UsePresetField
    value={preset}
    printFactor={printFactor}
    onPreset={onPreset}
    />
    )}
    <DimensionsField
    width={width}
    src/i18n/locales/vi.json
    line 2941
    "capped": "AI phóng to tối đa 4×; phần còn lại là đổi kích thước thông thường.",
    "hint": "Làm nét chi tiết bằng AI khi phóng to. Chữ nhỏ và khuôn mặt có thể thay đổi đôi chút."
    },
    "preset": {
    "label": "Dùng để",
    "web": "Đăng mạng",
    "print": "In ấn, LED",
    "webHint": "JPG nhẹ, giữ kích thước gốc, tối đa 500 KB.",
    "printHint": "PNG nét nhất, phóng to {factor}× bằng AI, không giới hạn dung lượng.",
    "printHintNoUpscale": "PNG nét nhất ở kích thước gốc, không giới hạn dung lượng."
    }
    },
    commit f50d00e04 source files1 tests updatedEnglish copy updated too
    G22a
    Export · severity 10/12

    Plainer download wording

    08
    Problem
    Tải nhiều bước, hướng dẫn khó hiểu

    The dialog's controls are named after how they work, not what they do: "Nâng cấp AI", "Cụ thể", "Nhãn miễn trừ AI", "Cấu hình từng ảnh", and a size-limit hint about budgets and PNG re-encoding.

    Fix

    "Làm nét & phóng to bằng AI", "Đặt mức", "Nhãn AI trên ảnh", "Chỉnh riêng từng ảnh", and a plain size-limit hint. Folding the rarely used fields away is a bigger redesign, left for design review.

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/components/common/download/download-form.tsx
    line 266
    }
    /** "AI upscale" switch plus a factor slider. `scale` is read off the dimensions, so a typed
    /** "AI sharpen & enlarge" switch plus a factor slider. `scale` is read off the dimensions, so a typed
    * size moves the slider too; `maxFactor` is the largest factor the size limits allow. With
    * `available` false the On tab is disabled and the helper says why. */
    src/components/common/lightbox/lightbox-download-modal.tsx
    line 91
    /**
    * A confirm dialog for choosing the download file name, resolution, file-size budget and
    * the AI disclaimer. Max file size defaults to "No limit", with a "Specific" KB budget
    * the AI disclaimer. Max file size defaults to "No limit", with a "Set a limit" KB budget
    * seeded at 500 (the common ad-platform cap); an emptied Specific budget DISABLES Download
    * rather than silently exporting uncapped. The AI disclaimer is on by default and turning
    line 140
    /** Extra form section above the dimensions (a page picker). */
    extraFields?: ReactNode;
    /** Offers "AI upscale" (an image downloaded through the URL). */
    /** Offers "AI sharpen & enlarge" (an image downloaded through the URL). */
    allowUpscale?: boolean;
    }) {
    src/components/common/lightbox/lightbox.tsx
    line 195
    downloadDefaultSize?: { width: number; height: number };
    downloadNaturalSize?: { width: number; height: number } | null;
    /** Offers "AI upscale" in the download dialog. */
    /** Offers "AI sharpen & enlarge" in the download dialog. */
    allowUpscale?: boolean;
    /** Opened from inside a dialog: on the `raised` tier, over that dialog's popup. */
    src/features/artifact/utils/folder/folderDownloadBudget.ts
    line 5
    /**
    * Valid when the mode is "No limit", or "Specific" with a positive number of
    * kilobytes. A "Specific" look whose field was emptied must hold the download: the
    * Valid when the mode is "No limit", or "Set a limit" with a positive number of
    * kilobytes. A "Set a limit" look whose field was emptied must hold the download: the
    * server drops a non-positive budget, so it would silently export unlimited.
    */
    src/i18n/locales/vi.json
    line 574
    },
    "download": {
    "discDefaultText": "Văn bản mặc định",
    "discDefaultText": "Chữ mặc định",
    "discHide": "Ẩn",
    "scopeAll": "Tất cả ảnh",
    "scopeEach": "Cấu hình từng ảnh",
    "scopeEach": "Chỉnh riêng từng ảnh",
    "tooMany": "Chỉ chọn tối đa {max} tệp để tải xuống cùng lúc",
    "preparing": "Đang chuẩn bị tải xuống…",
    line 590
    "shipsAsIs": "Loại tệp này được giữ nguyên.",
    "selectFile": "Chọn một tệp.",
    "aiDisclaimer": "Nhãn miễn trừ AI",
    "aiDisclaimer": "Nhãn AI trên ảnh",
    "brandDefaultImage": "Nhãn miễn trừ mặc định của thương hiệu bạn là hình ảnh. Hiện tại tải thư mục chỉ dùng văn bản.",
    "disclaimerText": "Nội dung miễn trừ",
    "resetOverride": "Đặt lại ảnh này về giao diện chung",
    "cannotResize": "Loại tệp này không thể đổi kích thước hay gắn nhãn — tệp được đưa vào zip nguyên trạng.",
    "limitsHint": "Giới hạn sẽ nén ở kích thước đã chọn — PNG vượt hạn mức sẽ được xuất thành .jpg.",
    "limitsHint": "Ảnh vượt dung lượng sẽ được nén; PNG vẫn quá nặng sẽ được lưu thành JPG.",
    "limitInvalid": "Giới hạn dung lượng phải ít nhất 1 KB — chuyển lại Không giới hạn để không giới hạn ảnh.",
    "downloadCsv": "Tải CSV",
    line 2928
    "maxFileSize": "Dung lượng tối đa",
    "noLimit": "Không giới hạn",
    "specific": "Cụ thể",
    "specific": "Đặt mức",
    "maxKbLabel": "Dung lượng tối đa tùy chỉnh (kilobyte)",
    "format": "Định dạng",
    "upscale": {
    "label": "Nâng cấp AI",
    "label": "Làm nét & phóng to bằng AI",
    "off": "Tắt",
    "on": "Bật",
    line 3111
    "audioNote": "Tệp âm thanh gốc sẽ được tải xuống nguyên trạng.",
    "videoNote": "Đổi kích thước, giới hạn dung lượng và nhãn miễn trừ AI chưa hỗ trợ cho video — tệp gốc sẽ được tải xuống nguyên trạng.",
    "suggestedResolution": "Độ phân giải đề xuất",
    "suggestedResolution": "Kích thước gợi ý",
    "or": "hoặc",
    "aiDisclaimer": "Nhãn miễn trừ AI",
    "aiDisclaimer": "Nhãn AI trên ảnh",
    "discTabs": {
    "text": "Văn bản mặc định",
    "text": "Chữ mặc định",
    "hide": "Ẩn",
    "image": "Tải ảnh lên"
    "image": "Dùng ảnh riêng"
    },
    "uploadOverlay": "Tải ảnh miễn trừ lên",
    line 3132
    "fallbackName": "ảnh",
    "upscalingToast": "Đang nâng cấp {name}… Tệp sẽ tự tải xuống khi sẵn sàng.",
    "upscaleFailed": "Không thể nâng cấp ảnh này. Hãy thử lại hoặc tắt nâng cấp AI.",
    "upscaleFailed": "Không thể nâng cấp ảnh này. Hãy thử lại hoặc tắt Làm nét & phóng to bằng AI.",
    "upscaleDone": "Đã nâng cấp — đang tải xuống."
    }
    commit 5bff88c46 source files4 tests updatedEnglish copy updated too2 help pages
    G8
    Production · severity 7/12

    Progress stays in the chat while images render

    09
    Problem
    Không biết lệnh đang chạy, đã xong hay đang hỏi thêm

    Once the agent hands the render job off, its Thinking line disappears and the only sign of work is a small pill in the top-right corner.

    Fix

    The thread keeps a line, "Đang tạo kết quả… 1:05", saying where results will appear, until the renders land. Proven by tests: a live render can't run locally (the model key is dead), so there is no recording.

    Codeproblem fix
    src/features/artifact/components/chat/ChatPanel.tsx
    line 53
    import { MentionResolutionProvider } from "./MentionResolutionProvider";
    import { MessageErrorBoundary } from "./MessageErrorBoundary";
    import { RenderingProgress } from "./RenderingProgress";
    import { SandboxIcon } from "../sandbox/SandboxIcon";
    import { SandboxFsModal } from "../sandbox/SandboxModal";
    line 92
    children,
    progress,
    rendering = false,
    onReplay,
    replaying = false,
    line 145
    /** Newest live status line; it labels the Thinking shimmer when there is one. */
    progress?: string | null;
    /** A render job is in flight (the header pill's signal); the thread then says so too. */
    rendering?: boolean;
    /** Toggles in-page session replay; omitting it hides the button entirely. */
    onReplay?: () => void;
    line 268
    !hasOptionsCard &&
    !spawnedGeneration;
    // Once the agent hands the job off its Thinking line stops; keep a line in the thread
    // until the renders land, not only the header pill.
    const showRendering =
    !replaying && !showThinking && !busy && spawnedGeneration && rendering;
    // A workflow-kind session shows its canvas in the body (tabs and header stay, so a
    line 499
    </div>
    )}
    {showRendering && <RenderingProgress />}
    {error &&
    !operatorBlocked &&
    src/features/artifact/components/chat/RenderingProgress.tsx
    line 0
    "use client";
    import { useTranslations } from "next-intl";
    import { useEffect, useState } from "react";
    import { ThinkingText } from "@/components/common/chat/thinking-text";
    /** m:ss, so a long render reads 1:05 rather than 65s. */
    function clock(seconds: number): string {
    const m = Math.floor(seconds / 60);
    const s = seconds % 60;
    return `${m}:${String(s).padStart(2, "0")}`;
    }
    /**
    * The chat's "still working" line while the turn's renders are out. The agent's own
    * Thinking line stops once it has handed the job off, and the only other sign was the
    * small header pill, so testers could not tell running from finished (RISE G8). Counts
    * from when it mounts: the panel mounts it when rendering starts.
    */
    export function RenderingProgress() {
    const t = useTranslations("artifactChat.panel");
    const [elapsed, setElapsed] = useState(0);
    useEffect(() => {
    const id = setInterval(() => setElapsed((seconds) => seconds + 1), 1000);
    return () => clearInterval(id);
    }, []);
    return (
    <div role="status" className="flex flex-col gap-0.5 pt-1">
    <ThinkingText label={t("rendering", { elapsed: clock(elapsed) })} />
    <span className="text-xs text-ink/45">{t("renderingHint")}</span>
    </div>
    );
    }
    src/features/artifact/components/studio/StudioClient.tsx
    line 1044
    // Feeds the shimmer's label; a replayed past turn has no live status line.
    progress={replaying ? null : chat.progress}
    rendering={feed.live}
    onViewBrief={setViewedBrief}
    onShareSessionLink={() => void sessionLink.shareSessionLink()}
    src/i18n/locales/vi.json
    line 408
    "retry": "Thử lại",
    "details": "Chi tiết",
    "copied": "Đã sao chép"
    "copied": "Đã sao chép",
    "rendering": "Đang tạo kết quả… {elapsed}",
    "renderingHint": "Từng kết quả sẽ hiện trong thư viện ngay khi xong."
    },
    "message": {
    commit 89ce94fd5 source files1 tests updatedEnglish copy updated too
    G14c
    Playbook · severity 9/12

    Offer "Save as Playbook" when it would help

    10
    Problem
    Không biết có Playbook; người dùng lâu năm vẫn hỏi "nó là cái gì"

    Nothing in the chat points to Playbooks, so people who would benefit never find them.

    Fix

    A finished turn that produced results shows "Ưng kết quả này? Lưu cách làm thành Playbook…" with a button to a new Playbook; "Để sau" hides it for that session. Only shown to people who can create content.

    Before main today
    After feat/sus-ux-polish
    Codeproblem fix
    src/features/artifact/components/chat/ChatPanel.tsx
    line 54
    import { MessageErrorBoundary } from "./MessageErrorBoundary";
    import { RenderingProgress } from "./RenderingProgress";
    import { SavePlaybookPrompt } from "./SavePlaybookPrompt";
    import { SandboxIcon } from "../sandbox/SandboxIcon";
    import { SandboxFsModal } from "../sandbox/SandboxModal";
    line 272
    !hasOptionsCard &&
    !spawnedGeneration;
    // A turn that finished well and left results is the moment a Playbook would help;
    // never under a failure or a question still waiting for the person.
    const showPlaybookPrompt =
    !replaying &&
    !turnRunning &&
    !error &&
    !hasDurableError &&
    lastMsg?.role === "assistant" &&
    Boolean(currentId && artifactsBySession?.[currentId]?.length);
    // Once the agent hands the job off its Thinking line stops; keep a line in the thread
    // until the renders land, not only the header pill.
    line 508
    )}
    {showRendering && <RenderingProgress />}
    {showPlaybookPrompt && currentId && (
    <SavePlaybookPrompt
    key={currentId}
    brandId={brandId}
    sessionId={currentId}
    />
    )}
    {error &&
    !operatorBlocked &&
    src/features/artifact/components/chat/SavePlaybookPrompt.tsx
    line 0
    "use client";
    import { useTranslations } from "next-intl";
    import { useState } from "react";
    import { X } from "@/components/icons";
    import { Button } from "@/components/ui/button";
    import { useCan } from "@/features/brand";
    import { useRouter } from "@/i18n/navigation";
    const dismissKey = (sessionId: string) =>
    `pam:playbookPrompt:dismissed:${sessionId}`;
    function readDismissed(sessionId: string): boolean {
    try {
    return localStorage.getItem(dismissKey(sessionId)) === "1";
    } catch {
    return false;
    }
    }
    /**
    * Offered under a finished turn that produced results: long-time users still asked
    * what a Playbook is, because nothing points to it at the moment one would help
    * (RISE G14c). "Not now" hides it for this session only.
    */
    export function SavePlaybookPrompt({
    brandId,
    sessionId,
    }: {
    brandId: string;
    sessionId: string;
    }) {
    const t = useTranslations("artifactChat.playbookPrompt");
    const router = useRouter();
    // Keyed by session in the parent, so a new session starts from its own stored answer.
    const [dismissed, setDismissed] = useState(() => readDismissed(sessionId));
    // Hidden below `content.write`; `useCan` (not `<Gate>`) because a shared or replayed
    // chat renders outside the brand shell, where there is no role to ask.
    const canWrite = useCan("content.write");
    if (dismissed || !canWrite) {
    return null;
    }
    const dismiss = () => {
    setDismissed(true);
    try {
    localStorage.setItem(dismissKey(sessionId), "1");
    } catch {
    // Storage blocked: it stays hidden until the panel remounts.
    }
    };
    return (
    <div className="flex items-center gap-3 rounded-xl bg-surface-soft py-2.5 pr-2 pl-3.5">
    <p className="min-w-0 flex-1 text-xs text-ink/70">{t("message")}</p>
    <Button
    type="button"
    variant="primary"
    size="sm"
    shape="pill"
    onClick={() => router.push(`/brands/${brandId}/playbooks/new`)}
    >
    {t("save")}
    </Button>
    <Button
    type="button"
    variant="ghostMuted"
    size="icon-6"
    onClick={dismiss}
    aria-label={t("notNow")}
    >
    <X className="size-3.5" />
    </Button>
    </div>
    );
    }
    src/i18n/locales/vi.json
    line 457
    "attach": "Đính kèm",
    "fromLibrary": "Từ thư viện"
    },
    "playbookPrompt": {
    "message": "Ưng kết quả này? Lưu cách làm thành Playbook để lần sau dùng lại.",
    "save": "Lưu thành Playbook",
    "notNow": "Để sau"
    }
    },
    commit 72ad71c94 source files1 tests updatedEnglish copy updated too

    Branch feat/sus-ux-polish at 72ad71c9, on latest main. Not pushed. Updated 23/10/2026 15:18.