fix: collect alt text and a decorative flag when uploading a Studio Asset

The upload dialog sent `{ file, kind }` only, though `UploadAssetForm` and
`asset-upload-transport.ts` both carry `altText`/`decorative`. Every asset
uploaded through the flagship authoring flow therefore landed as
`altText: null, decorative: false`, and the directive `case-fields.tsx` and
`asset-picker.tsx` build from `asset.decorative ? "" : (asset.altText ?? "")`
could only ever be `alt=""`. The document parsed and previewed correctly and
then failed publish validation with EVIDENCE_ALT_REQUIRED, recoverable only
by hand-editing raw Markdown -- the exact thing the Picker exists to prevent.

The dialog now stages the file instead of uploading on selection, and carries
a decorative checkbox plus an alt-text field (focused as soon as a file is
chosen, submitting on Enter). A decorative asset never demands alt text; a
meaningful one is refused with a stated reason rather than a disabled button.
Insertion is unchanged: both call sites already read the Asset, so they now
insert what it actually carries.

Three new tests drive the whole loop -- upload through the real MOCK
composition, auto-insert, save, validate, preview -- and assert the result is
publishable. The pre-existing loop test no longer needs its hand-edit
workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 09:07:38 +09:00
co-authored by Claude Opus 5
parent f19be639a3
commit 6085af51b6
3 changed files with 329 additions and 30 deletions
@@ -8,6 +8,7 @@ import { createLocalId } from "../../../domain/studio/local-id.ts";
export type UploadState =
| { kind: "IDLE" }
| { kind: "SELECTION_FAILED"; message: string }
| { kind: "ALT_REQUIRED" }
| { kind: "UPLOADING" }
| { kind: "TRANSPORT_FAILED"; message: string }
| { kind: "TOO_LARGE" }
@@ -41,6 +42,7 @@ export function stateForError(error: unknown): UploadState {
const MESSAGES: Record<UploadState["kind"], string> = {
IDLE: "",
SELECTION_FAILED: "파일을 선택하지 못했습니다.",
ALT_REQUIRED: "대체 텍스트를 입력하거나 장식용으로 표시하세요.",
UPLOADING: "업로드 중입니다.",
TRANSPORT_FAILED: "업로드를 전송하지 못했습니다.",
TOO_LARGE: "파일 크기가 허용 범위를 넘었습니다.",
@@ -57,7 +59,11 @@ export function AssetUploadDialog(props: Readonly<{
onClose: () => void;
}>) {
const [state, setState] = useState<UploadState>({ kind: "IDLE" });
const [file, setFile] = useState<File | null>(null);
const [altText, setAltText] = useState("");
const [decorative, setDecorative] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const altRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const titleId = useId();
const descriptionId = useId();
@@ -77,7 +83,35 @@ export function AssetUploadDialog(props: Readonly<{
};
}, []);
async function submit(file: File) {
/**
* Final fix wave, item 2. Alt text is collected here because it is a
* property of the *Asset*, not of one directive: `case-fields.tsx` and
* `asset-picker.tsx` both build the inserted directive from
* `asset.decorative ? "" : (asset.altText ?? "")`, so an Asset that carries
* neither can only ever produce `alt=""`. Publish validation
* (`validate-working-copy.ts`'s EVIDENCE_ALT_REQUIRED) then rejects the
* document, and hand-editing raw Markdown was the only recovery — the exact
* thing the Picker exists to prevent.
*
* A decorative Asset is exempt (that is what `decorative` means in the
* canonical contract, and what the validation rule already honours), so the
* fast path for a purely ornamental image stays one tick of a checkbox.
*/
const trimmedAlt = altText.trim();
const missingAlt = !decorative && trimmedAlt.length === 0;
async function submit() {
if (!file) {
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
return;
}
if (missingAlt) {
// Refused here rather than by disabling the button: a disabled control
// states no reason, and this refusal has one worth reading.
setState({ kind: "ALT_REQUIRED" });
altRef.current?.focus();
return;
}
setState({ kind: "UPLOADING" });
try {
// Fix round 1 (I1). A fresh key per call, not one generated once when
@@ -86,7 +120,12 @@ export function AssetUploadDialog(props: Readonly<{
// different payloads must never share one idempotency key -- the exact
// retry scenario idempotency keys exist for.
const asset = await props.gateway.uploadAsset(
{ file, kind: props.kind },
{
file,
kind: props.kind,
decorative,
...(decorative ? {} : { altText: trimmedAlt }),
},
{ idempotencyKey: createLocalId("studio-asset-upload") },
);
const next = stateForUploaded(asset);
@@ -132,7 +171,8 @@ export function AssetUploadDialog(props: Readonly<{
<p className="studio-eyebrow">ASSET UPLOAD</p>
<h2 id={titleId}>Asset </h2>
<p id={descriptionId}>
.
.
.
</p>
<label className="studio-field">
<span>Asset </span>
@@ -142,20 +182,60 @@ export function AssetUploadDialog(props: Readonly<{
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml,application/pdf"
disabled={uploading}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (!file) {
const selected = event.currentTarget.files?.[0] ?? null;
setFile(selected);
if (!selected) {
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
return;
}
void submit(file);
// A new file clears whatever the previous attempt reported.
setState({ kind: "IDLE" });
if (!decorative) altRef.current?.focus();
}}
/>
</label>
<label className="studio-field studio-field--checkbox">
<input
type="checkbox"
checked={decorative}
disabled={uploading}
onChange={(event) => {
setDecorative(event.currentTarget.checked);
if (state.kind === "ALT_REQUIRED") setState({ kind: "IDLE" });
}}
/>
<span> ( )</span>
</label>
<label className="studio-field">
<span> </span>
<input
ref={altRef}
type="text"
value={altText}
disabled={uploading || decorative}
aria-invalid={state.kind === "ALT_REQUIRED"}
onChange={(event) => {
setAltText(event.currentTarget.value);
if (state.kind === "ALT_REQUIRED") setState({ kind: "IDLE" });
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
void submit();
}}
/>
</label>
<p className="studio-dialog-status" role="status" aria-live="polite" aria-label="업로드 상태">{MESSAGES[state.kind]}</p>
{/* `.studio-dialog-actions button:last-child` is the primary style, so
the confirming action goes last -- same order as the other Studio
dialogs. */}
<div className="studio-dialog-actions">
<button type="button" disabled={uploading} onClick={props.onClose}>
</button>
<button type="button" disabled={uploading} onClick={() => void submit()}>
</button>
</div>
</div>
</dialog>