@glw907/cairn-cms 0.57.1 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,12 +24,33 @@ It is node-safe by construction: it types assets with MediaLibraryEntry from the
24
24
  projection and pulls in no editor module (the editor-boundary test bars a @codemirror leak).
25
25
  -->
26
26
  <script lang="ts">
27
- import { flushSync, tick } from 'svelte';
27
+ import { flushSync, getContext, tick } from 'svelte';
28
+ import { deserialize } from '$app/forms';
28
29
  import type { MediaLibraryEntry } from '../media/library-entry.js';
29
- import type { MediaLibraryData, ContentFormFailure } from '../sveltekit/content-routes.js';
30
+ import type {
31
+ MediaLibraryData,
32
+ ContentFormFailure,
33
+ MediaReplacePreviewPlan,
34
+ MediaReplaceFailure,
35
+ MediaReplacePreviewEntry,
36
+ MediaAltPreviewPlan,
37
+ MediaAltPropagateFailure,
38
+ } from '../sveltekit/content-routes.js';
39
+ import type { AltPlacement } from '../content/media-rewrite.js';
30
40
  import type { UsageEntry } from '../media/usage.js';
41
+ import type { MediaEntry } from '../media/manifest.js';
31
42
  import { publicPath } from '../media/naming.js';
32
43
  import { mediaToken } from '../media/reference.js';
44
+ import { CSRF_CONTEXT_KEY } from './csrf-context.js';
45
+ import {
46
+ ingestFile,
47
+ buildUploadRequest,
48
+ sendUpload,
49
+ ingestFailureKind,
50
+ failureCard,
51
+ type IngestFailureCard,
52
+ } from './client-ingest.js';
53
+ import { uploadOutcome, type UploadEnvelope } from './media-upload-outcome.js';
33
54
  import CsrfField from './CsrfField.svelte';
34
55
  import CairnLogo from './CairnLogo.svelte';
35
56
  import {
@@ -48,6 +69,10 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
48
69
  FileTextIcon,
49
70
  ClockIcon,
50
71
  Link2OffIcon,
72
+ RefreshCwIcon,
73
+ GitBranchIcon,
74
+ ArrowRightIcon,
75
+ MegaphoneIcon,
51
76
  } from './admin-icons.js';
52
77
 
53
78
  interface Props {
@@ -64,7 +89,12 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
64
89
 
65
90
  // The success flash a redirected action carried back: a safe-delete or a metadata edit. The
66
91
  // conflict error (data.flashError) renders in the inline error treatment below instead.
67
- const FLASH_MESSAGE = { deleted: 'Asset deleted.', updated: 'Changes saved.' } as const;
92
+ const FLASH_MESSAGE = {
93
+ deleted: 'Asset deleted.',
94
+ updated: 'Changes saved.',
95
+ replaced: 'Asset replaced.',
96
+ altPropagated: 'Alt text applied.',
97
+ } as const;
68
98
  const flashMessage = $derived(data.flash ? FLASH_MESSAGE[data.flash] : '');
69
99
 
70
100
  // --- the per-hash usage facts the screen joins onto each asset ---
@@ -220,7 +250,14 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
220
250
  // fires only when focus is inside the panel: an Escape in the search box clears it and leaves the
221
251
  // panel exactly as the user left it, while an Escape with focus in the panel still closes it.
222
252
  function onWindowKeydown(e: KeyboardEvent) {
223
- if (e.key === 'Escape' && selected && !deleteDialog?.open && panelEl?.contains(document.activeElement)) {
253
+ if (
254
+ e.key === 'Escape' &&
255
+ selected &&
256
+ !deleteDialog?.open &&
257
+ !replaceDialog?.open &&
258
+ !altDialog?.open &&
259
+ panelEl?.contains(document.activeElement)
260
+ ) {
224
261
  e.preventDefault();
225
262
  closePanel();
226
263
  }
@@ -248,6 +285,387 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
248
285
  }
249
286
  }
250
287
 
288
+ // --- the Replace flow: a two-step alertdialog (upload, then impact review) over the selected asset ---
289
+ // Replace uploads a new file for the selected asset; cairn is content-addressed, so the new file has a
290
+ // new hash and every published reference is repointed to it in one commit to main. The dialog opens on
291
+ // the quiet upload step, holds the server-owned record on a successful upload, fetches the preview
292
+ // (fail-closed), and renders the impact review behind a typed-slug gate. The CSRF token getter comes
293
+ // from the admin context, the same seam the insert popover reads.
294
+ const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
295
+
296
+ type ReplaceStep = 'upload' | 'review' | 'blocked';
297
+ // The transient upload status under the upload step: idle, an in-flight ingest/upload, or a typed
298
+ // ingest failure card with a retry. Mirrors the insert popover's failed-card grammar.
299
+ type ReplaceUpload =
300
+ | { kind: 'idle' }
301
+ | { kind: 'working' }
302
+ | { kind: 'failed'; card: IngestFailureCard | { status: 'failed'; message: string }; retry: () => void };
303
+
304
+ let replaceDialog = $state<HTMLDialogElement | null>(null);
305
+ // The entry-point button that opened the dialog, so focus restores to it on close (the alertdialog
306
+ // recipe, like the delete dialog's slide-over Delete button).
307
+ let replaceOrigin: HTMLElement | null = null;
308
+ // The Cancel control, the destructive-confirm initial focus.
309
+ let replaceCancelButton = $state<HTMLButtonElement | null>(null);
310
+ let replaceFileInput = $state<HTMLInputElement | null>(null);
311
+ let replaceStep = $state<ReplaceStep>('upload');
312
+ let replaceUpload = $state<ReplaceUpload>({ kind: 'idle' });
313
+ // The server-owned record the upload returned (the new asset), held for the preview and the apply.
314
+ let replaceRecord = $state<MediaEntry | null>(null);
315
+ // The resolved preview plan (the review step) or the fail-closed failure (the blocked step).
316
+ let replacePlan = $state<MediaReplacePreviewPlan | null>(null);
317
+ let replaceFailure = $state<MediaReplaceFailure | null>(null);
318
+ // The typed-slug confirm gate, echoing the delete dialog's type-to-confirm.
319
+ let replaceConfirmInput = $state('');
320
+ // The asset the Replace dialog acts on, pinned at open so a background re-render never swaps it.
321
+ let replaceAsset = $state<MediaLibraryEntry | null>(null);
322
+ const replaceConfirmMatches = $derived(replaceAsset !== null && replaceConfirmInput === replaceAsset.slug);
323
+
324
+ function openReplaceDialog(origin?: HTMLElement | null) {
325
+ if (!selected) return;
326
+ // The entry-point button passed from the click (focus restores here on close), falling back to the
327
+ // active element. A programmatic .click() does not focus its target, so the explicit origin is the
328
+ // reliable restore point.
329
+ replaceOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
330
+ replaceAsset = selected;
331
+ replaceStep = 'upload';
332
+ replaceUpload = { kind: 'idle' };
333
+ replaceRecord = null;
334
+ replacePlan = null;
335
+ replaceFailure = null;
336
+ replaceConfirmInput = '';
337
+ // Show the dialog after the step state flushes, then move focus to Cancel.
338
+ void tick().then(() => {
339
+ replaceDialog?.showModal();
340
+ replaceCancelButton?.focus();
341
+ });
342
+ }
343
+ function closeReplaceDialog() {
344
+ replaceDialog?.close();
345
+ replaceAsset = null;
346
+ replaceRecord = null;
347
+ replacePlan = null;
348
+ replaceFailure = null;
349
+ replaceConfirmInput = '';
350
+ replaceUpload = { kind: 'idle' };
351
+ // Restore focus to the entry-point button (the alertdialog focus-restore recipe).
352
+ replaceOrigin?.focus();
353
+ replaceOrigin = null;
354
+ }
355
+
356
+ // The chosen-file handler: route the file through the ingest-and-upload loop, exactly as the insert
357
+ // popover does, then fetch the preview. A file is the only path (Pass B is upload-new-only).
358
+ function onReplaceFileChosen(e: Event) {
359
+ const input = e.currentTarget as HTMLInputElement;
360
+ const file = input.files?.[0];
361
+ if (file) void runReplaceUpload(file);
362
+ }
363
+
364
+ // The upload loop for the new file. It ingests (decode/transcode), uploads through the shared
365
+ // transport, and on the success envelope holds the new record and runs the preview. A typed ingest or
366
+ // upload failure surfaces a retry card on the upload step; an expired session reads as a generic card.
367
+ // The upload posts to the media-scoped ?/mediaUpload action: the Library is not entry-scoped, so it
368
+ // overrides buildUploadRequest's entry URL while reusing its header-and-body transport verbatim.
369
+ async function runReplaceUpload(file: File) {
370
+ if (!replaceAsset) return;
371
+ replaceUpload = { kind: 'working' };
372
+ const genericFail = () =>
373
+ (replaceUpload = {
374
+ kind: 'failed',
375
+ card: { status: 'failed', message: GENERIC_UPLOAD_MESSAGE },
376
+ retry: () => void runReplaceUpload(file),
377
+ });
378
+
379
+ let ingested: Awaited<ReturnType<typeof ingestFile>>;
380
+ try {
381
+ ingested = await ingestFile(file);
382
+ } catch (err) {
383
+ replaceUpload = { kind: 'failed', card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
384
+ return;
385
+ }
386
+
387
+ const built = buildUploadRequest({
388
+ conceptId: '',
389
+ id: '',
390
+ bytes: ingested.blob,
391
+ contentType: ingested.contentType,
392
+ csrf: csrf?.() ?? '',
393
+ filename: file.name,
394
+ width: ingested.width,
395
+ height: ingested.height,
396
+ });
397
+ let res: Response;
398
+ try {
399
+ res = await sendUpload(REPLACE_UPLOAD_URL, built.init);
400
+ } catch (err) {
401
+ replaceUpload = { kind: 'failed', card: failureCard(ingestFailureKind(err)), retry: () => void runReplaceUpload(file) };
402
+ return;
403
+ }
404
+ // The guard's expired-session 303 under redirect:'manual' surfaces as an opaque, status-0 response.
405
+ if (res.type === 'opaqueredirect' || res.status === 0) {
406
+ genericFail();
407
+ return;
408
+ }
409
+ let outcome: ReturnType<typeof uploadOutcome>;
410
+ try {
411
+ outcome = uploadOutcome(deserialize(await res.text()) as UploadEnvelope);
412
+ } catch {
413
+ genericFail();
414
+ return;
415
+ }
416
+ if (outcome.kind !== 'inserted') {
417
+ genericFail();
418
+ return;
419
+ }
420
+ // Hold the server-owned record, then fetch the impact preview for (oldHash -> newHash).
421
+ replaceRecord = outcome.record;
422
+ replaceUpload = { kind: 'idle' };
423
+ await runReplacePreview();
424
+ }
425
+
426
+ // A per-call request token guards the preview fetch against a stale response landing on a closed or
427
+ // reopened dialog. Svelte reactivity does not track reads below the first `await`, so each call pins
428
+ // its own sequence at entry and bails after the await if a newer call (a reopen, or a "Check usage
429
+ // again" double-click) has since superseded it.
430
+ let replacePreviewSeq = 0;
431
+
432
+ // The preview fetch: POST the (oldHash, newHash, slug) tuple in the 2a transport (a text/plain
433
+ // body, the CSRF token in the X-Cairn-CSRF header), parse the SvelteKit ActionResult envelope, and
434
+ // route to the review step (a plan) or the fail-closed blocked step (a failure). Re-runnable from the
435
+ // blocked step's "Check usage again". The slug is the OLD asset's: a replace keeps the name and
436
+ // changes only the content hash, so the repointed token carries the existing slug, not the new file's.
437
+ async function runReplacePreview() {
438
+ if (!replaceAsset || !replaceRecord) return;
439
+ const hash = replaceAsset.hash;
440
+ const seq = ++replacePreviewSeq;
441
+ // The fail-closed landing: an unverifiable usage read, an unreachable preview, or an unparseable
442
+ // body all route to the blocked step. The passed failure carries the branch-naming error when the
443
+ // server returned one; a transport miss carries the empty error (the generic honest line stands in).
444
+ const blockClosed = (failure?: MediaReplaceFailure) => {
445
+ replaceFailure = failure ?? { error: '', hash, usage: [], foundIn: 0 };
446
+ replacePlan = null;
447
+ replaceStep = 'blocked';
448
+ };
449
+
450
+ const body = JSON.stringify({ oldHash: hash, newHash: replaceRecord.hash, slug: replaceAsset.slug });
451
+ let result: { type: string; data?: unknown };
452
+ try {
453
+ const res = await fetch(REPLACE_PREVIEW_URL, {
454
+ method: 'POST',
455
+ headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
456
+ body,
457
+ });
458
+ result = deserialize(await res.text()) as { type: string; data?: unknown };
459
+ } catch {
460
+ // Drop a stale response that lost the race to a reopen or a re-run before surfacing the block.
461
+ if (seq !== replacePreviewSeq) return;
462
+ blockClosed();
463
+ return;
464
+ }
465
+ // The dialog was closed or reopened (for another asset, or via a re-run) while this fetch was in
466
+ // flight, so this response is stale: ignore it rather than clobber the live state.
467
+ if (seq !== replacePreviewSeq) return;
468
+ if (result.type === 'success' && result.data) {
469
+ replacePlan = result.data as MediaReplacePreviewPlan;
470
+ replaceFailure = null;
471
+ replaceConfirmInput = '';
472
+ replaceStep = 'review';
473
+ } else {
474
+ blockClosed(result.data as MediaReplaceFailure | undefined);
475
+ }
476
+ }
477
+
478
+ const GENERIC_UPLOAD_MESSAGE = 'The upload could not be completed. Please try again.';
479
+ // The media-scoped upload and preview action URLs, relative to /admin/media. The upload reuses the
480
+ // shared ingest transport but the Library has no entry, so it targets ?/mediaUpload rather than the
481
+ // entry-scoped ?/upload. The apply form below posts ?/mediaReplace.
482
+ const REPLACE_UPLOAD_URL = '?/mediaUpload';
483
+ const REPLACE_PREVIEW_URL = '?/mediaReplacePreview';
484
+
485
+ // The affected-entry well caps past this many rows; "Show all N" reveals the rest into the same
486
+ // scroll container (the a11y contract: aria-expanded + aria-controls).
487
+ const REPLACE_ROW_CAP = 8;
488
+ let replaceShowAll = $state(false);
489
+ // The affected-entry list element, so "Show all" can move focus to the first newly revealed row (the
490
+ // one just past the cap) instead of dropping to <body> when the expander button unmounts.
491
+ let replaceEntriesList = $state<HTMLElement | null>(null);
492
+ $effect(() => {
493
+ // Reset the reveal whenever a fresh plan arrives, so a second preview never opens pre-expanded.
494
+ void replacePlan;
495
+ replaceShowAll = false;
496
+ });
497
+ // Reveal the capped rows, then move focus to the first newly revealed row (the rev.2 contract). The
498
+ // expander unmounts on the flag flip, so without this focus falls to <body>.
499
+ function showAllReplaceEntries() {
500
+ replaceShowAll = true;
501
+ void tick().then(() => (replaceEntriesList?.children[REPLACE_ROW_CAP] as HTMLElement | undefined)?.focus());
502
+ }
503
+ const replaceEntries = $derived(replacePlan?.entries ?? []);
504
+ const replaceVisibleEntries = $derived(
505
+ replaceShowAll ? replaceEntries : replaceEntries.slice(0, REPLACE_ROW_CAP),
506
+ );
507
+ const replaceHiddenCount = $derived(Math.max(0, replaceEntries.length - REPLACE_ROW_CAP));
508
+ // The server's distinct affected-entry count, read in several places across the review markup and
509
+ // the apply button. Coalesced once here so each read stays a plain number.
510
+ const replaceAffected = $derived(replacePlan?.affectedCount ?? 0);
511
+
512
+ // The where-used summary line for one affected entry, derived from its repointed placements: a hero
513
+ // count and a body count, folded into a plain phrase ("Hero and 2 in the body", "1 in the body").
514
+ function replaceWhereUsed(entry: MediaReplacePreviewEntry): string {
515
+ let hero = 0;
516
+ let body = 0;
517
+ for (const p of entry.placements) {
518
+ if (p.kind === 'hero') hero += 1;
519
+ else body += 1;
520
+ }
521
+ const parts: string[] = [];
522
+ if (hero > 0) parts.push(hero === 1 ? 'Hero' : `${hero} heroes`);
523
+ if (body > 0) parts.push(`${body} in the body`);
524
+ return parts.length > 0 ? parts.join(' and ') : 'Used in this entry';
525
+ }
526
+
527
+ // The specific unreadable branch named by a fail-closed failure, or null for the generic honest line.
528
+ // The current MediaReplaceFailure carries only an error string, so a cairn/* branch name is pulled
529
+ // from the message when the strict read named one; otherwise the generic variant stands in.
530
+ const replaceBlockedBranch = $derived.by(() => {
531
+ const match = replaceFailure?.error.match(/cairn\/[^\s.]+/);
532
+ return match ? match[0] : null;
533
+ });
534
+
535
+ // --- the Push-alt flow: a one-step review dialog (the everyday register) over the selected asset ---
536
+ // Alt propagation pushes the asset's default alt into published placements that lack it, with one
537
+ // bucket-level opt-in to also overwrite placements that carry a custom alt. It is reversible and
538
+ // frequent, so the dialog is role="dialog" (not alertdialog) with no typed-slug gate; apply is always
539
+ // enabled. The preview fetch reuses the 2a transport (a text/plain body, the CSRF token in the
540
+ // X-Cairn-CSRF header) and fails closed to a blocked surface when usage cannot be verified.
541
+ type AltStep = 'review' | 'blocked';
542
+ const ALT_PREVIEW_URL = '?/mediaAltPreview';
543
+
544
+ let altDialog = $state<HTMLDialogElement | null>(null);
545
+ // The entry-point button that opened the dialog, so focus restores to it on close.
546
+ let altOrigin: HTMLElement | null = null;
547
+ // The Cancel control, the initial focus on open.
548
+ let altCancelButton = $state<HTMLButtonElement | null>(null);
549
+ let altStep = $state<AltStep>('review');
550
+ // The resolved preview plan (the review step) or the fail-closed failure (the blocked step).
551
+ let altPlan = $state<MediaAltPreviewPlan | null>(null);
552
+ let altFailure = $state<MediaAltPropagateFailure | null>(null);
553
+ // The bucket-level opt-in to also overwrite customized alts. Bound to the one native checkbox.
554
+ let altOverwrite = $state(false);
555
+ // The asset the dialog acts on, pinned at open so a background re-render never swaps it. The alt it
556
+ // pushes is this asset's default alt.
557
+ let altAsset = $state<MediaLibraryEntry | null>(null);
558
+
559
+ function openAltDialog(origin?: HTMLElement | null) {
560
+ if (!selected) return;
561
+ altOrigin = origin ?? (document.activeElement as HTMLElement | null) ?? null;
562
+ altAsset = selected;
563
+ altStep = 'review';
564
+ altPlan = null;
565
+ altFailure = null;
566
+ altOverwrite = false;
567
+ void tick().then(() => {
568
+ altDialog?.showModal();
569
+ altCancelButton?.focus();
570
+ });
571
+ void runAltPreview();
572
+ }
573
+ function closeAltDialog() {
574
+ altDialog?.close();
575
+ altAsset = null;
576
+ altPlan = null;
577
+ altFailure = null;
578
+ altOverwrite = false;
579
+ altOrigin?.focus();
580
+ altOrigin = null;
581
+ }
582
+
583
+ // The per-call request token for the alt preview, mirroring the Replace guard: a stale response from
584
+ // a closed or reopened dialog (or a "Check usage again" double-click) is dropped after the await.
585
+ let altPreviewSeq = 0;
586
+
587
+ // The preview fetch: POST the hash in the 2a transport, parse the ActionResult envelope, and route to
588
+ // the review step (a plan) or the fail-closed blocked step (a failure). Re-runnable from the blocked
589
+ // step's "Check usage again".
590
+ async function runAltPreview() {
591
+ if (!altAsset) return;
592
+ const hash = altAsset.hash;
593
+ const seq = ++altPreviewSeq;
594
+ const blockClosed = (failure?: MediaAltPropagateFailure) => {
595
+ altFailure = failure ?? { error: '' };
596
+ altPlan = null;
597
+ altStep = 'blocked';
598
+ };
599
+ let result: { type: string; data?: unknown };
600
+ try {
601
+ const res = await fetch(ALT_PREVIEW_URL, {
602
+ method: 'POST',
603
+ headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
604
+ body: JSON.stringify({ hash }),
605
+ });
606
+ result = deserialize(await res.text()) as { type: string; data?: unknown };
607
+ } catch {
608
+ if (seq !== altPreviewSeq) return;
609
+ blockClosed();
610
+ return;
611
+ }
612
+ // Stale-response guard: a reopen or a re-run superseded this fetch while it was in flight.
613
+ if (seq !== altPreviewSeq) return;
614
+ if (result.type === 'success' && result.data) {
615
+ altPlan = result.data as MediaAltPreviewPlan;
616
+ altFailure = null;
617
+ altStep = 'review';
618
+ } else {
619
+ blockClosed(result.data as MediaAltPropagateFailure | undefined);
620
+ }
621
+ }
622
+
623
+ // The default alt the dialog propagates: the selected asset's stored alt. Empty is guarded by the
624
+ // entry point (an asset with no default alt cannot push one), but the dialog reads it defensively.
625
+ const altPushed = $derived(altAsset?.alt.trim() ?? '');
626
+
627
+ // The three buckets, flattened from the plan's entries: each row carries its entry title, the
628
+ // placement kind (the pill), and the placement's before/after. Grouping by bucket keeps each well
629
+ // self-contained, the way the mockup lays them out.
630
+ type AltRow = { title: string; kind: AltPlacement['kind']; before: string; after: string; key: string };
631
+ function altRows(bucket: AltPlacement['bucket']): AltRow[] {
632
+ const rows: AltRow[] = [];
633
+ for (const entry of altPlan?.entries ?? []) {
634
+ entry.placements.forEach((p, i) => {
635
+ if (p.bucket !== bucket) return;
636
+ rows.push({ title: entry.title, kind: p.kind, before: p.before, after: p.after, key: `${entry.concept}/${entry.id}/${i}` });
637
+ });
638
+ }
639
+ return rows;
640
+ }
641
+ const altFillRows = $derived(altRows('will-fill'));
642
+ const altCustomRows = $derived(altRows('customized'));
643
+ const altSkipRows = $derived(altRows('decorative-skipped'));
644
+
645
+ // The committed total: the will-fill placements always, plus the customized placements only on the
646
+ // opt-in. The footer button and the live region read this; the count moves when the opt-in toggles.
647
+ const altCounts = $derived(altPlan?.counts ?? { willFill: 0, customized: 0, decorativeSkipped: 0 });
648
+ const altTotal = $derived(altCounts.willFill + (altOverwrite ? altCounts.customized : 0));
649
+
650
+ // The will-fill bucket caps past this many rows; "Show all N" reveals the rest (aria-expanded +
651
+ // aria-controls). The customized bucket lists in full (it is the consequential one).
652
+ const ALT_ROW_CAP = 8;
653
+ let altShowAll = $state(false);
654
+ // The will-fill list element, so "Show all" can move focus to its first newly revealed row.
655
+ let altFillList = $state<HTMLElement | null>(null);
656
+ $effect(() => {
657
+ void altPlan;
658
+ altShowAll = false;
659
+ });
660
+ const altFillVisible = $derived(altShowAll ? altFillRows : altFillRows.slice(0, ALT_ROW_CAP));
661
+ const altFillHidden = $derived(Math.max(0, altFillRows.length - ALT_ROW_CAP));
662
+ // Reveal the capped will-fill rows, then move focus to the first newly revealed row (the rev.2
663
+ // contract: the expander unmounts on the flag flip, so focus would otherwise fall to <body>).
664
+ function showAllAltFill() {
665
+ altShowAll = true;
666
+ void tick().then(() => (altFillList?.children[ALT_ROW_CAP] as HTMLElement | undefined)?.focus());
667
+ }
668
+
251
669
  // --- the where-used overlay the slide-over and the dialog read, grouped published-then-branch ---
252
670
  function usageEntries(hash: string): UsageEntry[] {
253
671
  return data.usage[hash]?.entries ?? [];
@@ -555,7 +973,7 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
555
973
  aria-selected={selected?.hash === asset.hash}
556
974
  tabindex={i === activeIndex ? 0 : -1}
557
975
  aria-label="{asset.displayName}. {missing ? 'Needs alt text' : 'Described'}. {used > 0 ? `Found in ${used} ${used === 1 ? 'entry' : 'entries'}` : 'No references found'}."
558
- class="group flex cursor-pointer flex-col overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-primary/70 {selected?.hash === asset.hash ? 'ring-2 ring-primary/70' : ''}"
976
+ class="group flex cursor-pointer flex-col overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 outline-hidden transition-shadow focus-visible:ring-2 focus-visible:ring-primary/70 {selected?.hash === asset.hash ? 'ring-2 ring-primary/70' : ''}"
559
977
  onclick={(e) => openAsset(asset, e.currentTarget)}
560
978
  onkeydown={(e) => onGridKeydown(e, i)}
561
979
  >
@@ -850,9 +1268,33 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
850
1268
  </dl>
851
1269
  </div>
852
1270
 
853
- <!-- The actions. Replace is deferred (no Replace control in this slice). -->
854
- <div class="flex gap-2.5 border-t border-[var(--cairn-card-border)] pt-4">
855
- <button type="button" class="btn btn-sm flex-1 border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={openDeleteDialog}>
1271
+ <!-- The actions block (rev.2 decision 7): two quiet text-weight entry points (Replace, Push alt)
1272
+ above the existing danger-bordered Delete. The quiet controls are button:not(.btn) levelled
1273
+ rows, lighter than a bordered button; each carries aria-haspopup="dialog". Push alt's handler
1274
+ lands in Task 8; the button is placed now so the block matches the design. -->
1275
+ <div class="flex flex-col gap-1 border-t border-[var(--cairn-card-border)] pt-4">
1276
+ <span class="{headerLabel} mb-1">Actions</span>
1277
+ <button
1278
+ type="button"
1279
+ data-cairn-replace-open
1280
+ class="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-[0.8125rem] font-medium text-base-content hover:bg-base-content/[0.06]"
1281
+ aria-haspopup="dialog"
1282
+ onclick={(e) => openReplaceDialog(e.currentTarget)}
1283
+ >
1284
+ <RefreshCwIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
1285
+ Replace image
1286
+ </button>
1287
+ <button
1288
+ type="button"
1289
+ data-cairn-pushalt-open
1290
+ class="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-[0.8125rem] font-medium text-base-content hover:bg-base-content/[0.06]"
1291
+ aria-haspopup="dialog"
1292
+ onclick={(e) => openAltDialog(e.currentTarget)}
1293
+ >
1294
+ <MegaphoneIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
1295
+ Push alt to placements
1296
+ </button>
1297
+ <button type="button" class="btn btn-sm mt-1.5 border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={openDeleteDialog}>
856
1298
  <Trash2Icon class="h-4 w-4" aria-hidden="true" /> Delete
857
1299
  </button>
858
1300
  </div>
@@ -947,3 +1389,531 @@ projection and pulls in no editor module (the editor-boundary test bars a @codem
947
1389
  </div>
948
1390
  {/if}
949
1391
  </dialog>
1392
+
1393
+ <!-- The Replace alertdialog: a native modal <dialog> (native focus trap + Escape), NO light dismiss.
1394
+ A replace repoints a content hash and can break a draft, so it carries role="alertdialog", the
1395
+ danger register, and a typed-slug gate. Step one is the quiet upload; step two is the impact review
1396
+ gated behind the typed slug; the blocked step is the fail-closed surface (no apply button). -->
1397
+ <dialog
1398
+ bind:this={replaceDialog}
1399
+ data-testid="cairn-replace-dialog"
1400
+ class="modal"
1401
+ role="alertdialog"
1402
+ aria-modal="true"
1403
+ aria-labelledby="cairn-ml-replace-title"
1404
+ aria-describedby="cairn-ml-replace-sub"
1405
+ oncancel={closeReplaceDialog}
1406
+ >
1407
+ {#if replaceAsset}
1408
+ {@const asset = replaceAsset}
1409
+ <div class="modal-box max-w-xl">
1410
+ <div class="mb-3 flex items-start gap-3">
1411
+ <span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-[var(--cairn-error-tint)] text-[var(--cairn-error-ink)]" aria-hidden="true">
1412
+ {#if replaceStep === 'blocked'}<TriangleAlertIcon class="h-5 w-5" />{:else}<RefreshCwIcon class="h-5 w-5" />{/if}
1413
+ </span>
1414
+ <div class="flex-1">
1415
+ <h2 id="cairn-ml-replace-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">
1416
+ {#if replaceStep === 'review'}
1417
+ Replace {asset.slug} in {replaceAffected} published {replaceAffected === 1 ? 'entry' : 'entries'}
1418
+ {:else if replaceStep === 'blocked'}
1419
+ Replace is on hold
1420
+ {:else}
1421
+ Replace {asset.displayName}
1422
+ {/if}
1423
+ </h2>
1424
+ <p id="cairn-ml-replace-sub" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
1425
+ {#if replaceStep === 'review'}
1426
+ The new file replaces the stored image. Every published entry that uses it is repointed in one commit to main, and readers see the change once the build finishes.
1427
+ {:else if replaceStep === 'blocked'}
1428
+ cairn could not read every place this image is used, so it will not repoint references it cannot see. No file was changed.
1429
+ {:else}
1430
+ Upload a new file. Every published entry that uses this image points to the new one, in one commit to main.
1431
+ {/if}
1432
+ </p>
1433
+ </div>
1434
+ <button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Cancel" onclick={closeReplaceDialog}>
1435
+ <XIcon class="h-3.5 w-3.5" aria-hidden="true" />
1436
+ </button>
1437
+ </div>
1438
+
1439
+ {#if replaceStep === 'upload'}
1440
+ <!-- Step one: upload a new file (upload-new-only). The asset being replaced stays named above
1441
+ the dropzone, so the author never loses it. Cancel is the initial focus; no apply yet. -->
1442
+ <div class="flex flex-col gap-3">
1443
+ <div class="flex items-center gap-3 rounded-box border border-[var(--cairn-card-border)] bg-base-200/60 p-3">
1444
+ <span class="flex h-12 w-12 flex-none items-center justify-center overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100">
1445
+ {#if brokenHashes.has(asset.hash)}
1446
+ <ImageOffIcon class="h-5 w-5 text-[var(--color-subtle)]" aria-hidden="true" />
1447
+ {:else}
1448
+ <img src={thumbSrc(asset)} alt="" aria-hidden="true" class="h-full w-full object-cover" onerror={() => markBroken(asset.hash)} />
1449
+ {/if}
1450
+ </span>
1451
+ <span class="flex min-w-0 flex-col gap-0.5">
1452
+ <span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">Replacing</span>
1453
+ <span class="text-sm font-semibold">{asset.displayName}</span>
1454
+ <span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-[var(--color-muted)] tabular-nums">
1455
+ {#if dimensions(asset)}{dimensions(asset)}<span class="px-1" aria-hidden="true">&middot;</span>{/if}{formatBytes(asset.bytes)}
1456
+ </span>
1457
+ </span>
1458
+ </div>
1459
+
1460
+ {#if replaceUpload.kind === 'failed'}
1461
+ <!-- A typed ingest/upload failure: an assertive alert with the message and a Retry. -->
1462
+ <div role="alert" class="flex flex-col items-center gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-4 text-center">
1463
+ <TriangleAlertIcon class="h-6 w-6 text-[var(--cairn-error-ink)]" aria-hidden="true" />
1464
+ <span class="text-[0.8125rem] text-[var(--cairn-error-ink)]">{replaceUpload.card.message}</span>
1465
+ <button type="button" class="btn btn-sm" onclick={replaceUpload.retry}>Try another file</button>
1466
+ </div>
1467
+ {:else if replaceUpload.kind === 'working'}
1468
+ <div role="status" class="flex flex-col items-center gap-2 rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-100 p-5 text-center text-[var(--color-muted)]">
1469
+ <span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
1470
+ <span class="text-[0.8125rem]">Preparing the new file...</span>
1471
+ </div>
1472
+ {:else}
1473
+ <div class="flex flex-col items-center gap-1.5 rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-100 p-5 text-center text-[var(--color-muted)]">
1474
+ <UploadIcon class="h-6 w-6 text-primary" aria-hidden="true" />
1475
+ <span class="text-[0.875rem] font-medium text-base-content">Drop the new image, or upload</span>
1476
+ <span class="text-xs">PNG, JPEG, WebP, or HEIC. We convert HEIC for you.</span>
1477
+ <button type="button" class="btn btn-sm btn-primary mt-1.5" onclick={() => replaceFileInput?.click()}>Choose a file</button>
1478
+ <input
1479
+ bind:this={replaceFileInput}
1480
+ type="file"
1481
+ accept="image/*"
1482
+ class="sr-only"
1483
+ aria-label="Choose a new image to replace this asset"
1484
+ onchange={onReplaceFileChosen}
1485
+ />
1486
+ </div>
1487
+ {/if}
1488
+ </div>
1489
+ <div class="mt-4 flex justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
1490
+ <button bind:this={replaceCancelButton} type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
1491
+ </div>
1492
+ {:else if replaceStep === 'review'}
1493
+ {@const newRec = replaceRecord}
1494
+ <!-- Step two: the impact review. The from/to strip carries the CORRECTED content-addressed copy
1495
+ (the name stays, only the hash changes); the affected-entry well is expanded by default and
1496
+ scroll-capped; the branch-delta is a calm report-only aside; the typed-slug gates apply. -->
1497
+ <div class="flex flex-col gap-4">
1498
+ {#if newRec}
1499
+ <div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-box border border-[var(--cairn-card-border)] bg-base-200/60 p-3">
1500
+ <div class="flex min-w-0 flex-col gap-0.5">
1501
+ <span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">Current</span>
1502
+ <span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-[var(--color-muted)] tabular-nums line-through">.{asset.hash}</span>
1503
+ </div>
1504
+ <ArrowRightIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
1505
+ <div class="flex min-w-0 flex-col gap-0.5">
1506
+ <span class="text-[0.625rem] font-semibold uppercase tracking-[0.06em] text-[var(--color-muted)]">New file</span>
1507
+ <span class="font-[family-name:var(--font-editor)] text-[0.75rem] text-primary tabular-nums">.{newRec.hash}</span>
1508
+ </div>
1509
+ <div class="col-span-3 flex items-start gap-2 border-t border-[var(--cairn-card-border)] pt-2.5">
1510
+ <CheckIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
1511
+ <span class="text-[0.8125rem] leading-relaxed">The name <code class="rounded bg-[var(--cairn-code-chip)] px-1.5 py-0.5 font-[family-name:var(--font-editor)] text-[0.75rem]">{asset.slug}</code> stays the same. Only the content hash changes, so every published entry is repointed to the new file in one commit.</span>
1512
+ </div>
1513
+ </div>
1514
+ {/if}
1515
+
1516
+ <div>
1517
+ <div class="mb-2 flex items-baseline justify-between">
1518
+ <span class={headerLabel}>Published entries that will be repointed</span>
1519
+ <span class="text-xs tabular-nums text-[var(--color-muted)]">{replaceEntries.length}</span>
1520
+ </div>
1521
+ <div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100">
1522
+ <ul bind:this={replaceEntriesList} id="cairn-ml-replace-entries" class="flex max-h-56 list-none flex-col gap-1 overflow-y-auto p-2">
1523
+ {#each replaceVisibleEntries as entry, i (entry.concept + '/' + entry.id)}
1524
+ <!-- The first row past the cap is a script-only focus target for "Show all" (tabindex
1525
+ -1 keeps it out of the tab order). svelte-ignore: the rule allows a literal -1 but
1526
+ does not see through the per-row conditional that selects which row carries it. -->
1527
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
1528
+ <li class="flex items-start gap-2.5 rounded px-1.5 py-1.5" tabindex={i === REPLACE_ROW_CAP ? -1 : undefined}>
1529
+ <FileTextIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
1530
+ <span class="flex min-w-0 flex-col">
1531
+ <span class="truncate text-[0.8125rem] font-medium">{entry.title}</span>
1532
+ <span class="truncate text-[0.6875rem] text-[var(--color-muted)]">{replaceWhereUsed(entry)}</span>
1533
+ </span>
1534
+ </li>
1535
+ {/each}
1536
+ </ul>
1537
+ {#if replaceHiddenCount > 0 && !replaceShowAll}
1538
+ <div class="border-t border-[var(--cairn-card-border)] p-1.5">
1539
+ <button
1540
+ type="button"
1541
+ class="flex w-full items-center justify-center gap-1.5 rounded px-2 py-1 text-[0.75rem] font-medium text-primary hover:bg-primary/[0.08]"
1542
+ aria-expanded={replaceShowAll}
1543
+ aria-controls="cairn-ml-replace-entries"
1544
+ onclick={showAllReplaceEntries}
1545
+ >
1546
+ Show the other {replaceHiddenCount} {replaceHiddenCount === 1 ? 'entry' : 'entries'}
1547
+ </button>
1548
+ </div>
1549
+ {/if}
1550
+ </div>
1551
+ </div>
1552
+
1553
+ {#if (replacePlan?.branchDelta?.length ?? 0) > 0}
1554
+ <!-- The report-only branch delta: open cairn/* edits keep the old file until they publish.
1555
+ Calm dashed base-200, never the danger register. -->
1556
+ <div class="rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200/40 p-3">
1557
+ <div class="mb-1.5 flex items-center gap-2">
1558
+ <GitBranchIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
1559
+ <span class="text-[0.8125rem] font-semibold">Open edits still on the old file</span>
1560
+ <span class="text-xs tabular-nums text-[var(--color-muted)]">{replacePlan?.branchDelta.length ?? 0}</span>
1561
+ </div>
1562
+ <p class="mb-2 text-[0.75rem] leading-relaxed text-[var(--color-muted)]">These edits are on their own branches and are not touched. Each keeps the old file until it is published again.</p>
1563
+ <ul class="flex list-none flex-col gap-1 p-0">
1564
+ {#each replacePlan?.branchDelta ?? [] as delta (delta.branch)}
1565
+ <li class="font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--cairn-warning-ink)]">{delta.branch}</li>
1566
+ {/each}
1567
+ </ul>
1568
+ </div>
1569
+ {/if}
1570
+
1571
+ <div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
1572
+ <ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
1573
+ <span>The old file stays in git history. A developer can bring it back. The alt text on each placement is left exactly as it is.</span>
1574
+ </div>
1575
+
1576
+ <div class="flex flex-col gap-1.5">
1577
+ <label class="text-[0.875rem]" for="cairn-ml-replace-confirm">Type <code class="rounded bg-[var(--cairn-code-chip)] px-1.5 py-0.5 font-[family-name:var(--font-editor)] text-[0.8125rem] font-semibold">{asset.slug}</code> to replace the file in all {replaceAffected} {replaceAffected === 1 ? 'entry' : 'entries'}.</label>
1578
+ <input id="cairn-ml-replace-confirm" data-cairn-replace-confirm class="input input-sm border-[var(--cairn-error-border)] font-[family-name:var(--font-editor)]" autocomplete="off" placeholder="Type the asset slug" bind:value={replaceConfirmInput} />
1579
+ </div>
1580
+ </div>
1581
+
1582
+ <!-- A polite live region mirrors the footer impact for a screen reader on the review step. The
1583
+ role="status" matches the Push-alt live region: the stronger, more portable form. -->
1584
+ <div class="sr-only" role="status" aria-live="polite">
1585
+ Replace {asset.slug} in {replaceAffected} published {replaceAffected === 1 ? 'entry' : 'entries'}.{(replacePlan?.branchDelta?.length ?? 0) > 0 ? ` ${replacePlan?.branchDelta.length} open ${(replacePlan?.branchDelta?.length ?? 0) === 1 ? 'edit is' : 'edits are'} not touched.` : ''}
1586
+ </div>
1587
+
1588
+ <form method="POST" action="?/mediaReplace" class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
1589
+ <CsrfField />
1590
+ <input type="hidden" name="oldHash" value={asset.hash} />
1591
+ <input type="hidden" name="newHash" value={replaceRecord?.hash ?? ''} />
1592
+ <input type="hidden" name="confirmSlug" value={replaceConfirmInput} />
1593
+ <input type="hidden" name="media" value={replaceRecord ? JSON.stringify([replaceRecord]) : '[]'} />
1594
+ <span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
1595
+ <GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> One commit to main
1596
+ </span>
1597
+ <button type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
1598
+ <button type="submit" class="btn btn-sm btn-error" disabled={!replaceConfirmMatches}>
1599
+ <RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Replace in {replaceAffected} {replaceAffected === 1 ? 'entry' : 'entries'}
1600
+ </button>
1601
+ </form>
1602
+ {:else}
1603
+ <!-- The fail-closed surface: usage could not be fully verified, so the replace refuses rather
1604
+ than guess. NO apply button (not even disabled), and no typed gate. A quiet "Check usage
1605
+ again" re-runs the scan; the held upload stays ready. -->
1606
+ <div class="flex flex-col gap-3">
1607
+ <div role="status" class="flex flex-col gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3.5">
1608
+ <span class="inline-flex items-center gap-2 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
1609
+ <TriangleAlertIcon class="h-4 w-4 flex-none" aria-hidden="true" /> Usage could not be fully verified
1610
+ </span>
1611
+ <p class="text-[0.8125rem] leading-relaxed">
1612
+ {#if replaceBlockedBranch}
1613
+ The published site read cleanly. One edit branch would not load, so cairn cannot tell whether it uses the image too. Replacing now could leave that branch pointing at the old file with no record of it.
1614
+ {:else}
1615
+ The published site could not be fully read, so cairn cannot tell every place this image is used. Replacing now could leave a reference pointing at the old file with no record of it.
1616
+ {/if}
1617
+ </p>
1618
+ {#if replaceBlockedBranch}
1619
+ <p class="inline-flex items-center gap-1.5 text-[0.8125rem]">
1620
+ <XIcon class="h-3.5 w-3.5 flex-none text-[var(--cairn-error-ink)]" aria-hidden="true" />
1621
+ Could not read <code class="font-[family-name:var(--font-editor)] text-[0.75rem]">{replaceBlockedBranch}</code>
1622
+ </p>
1623
+ {:else}
1624
+ <p class="inline-flex items-center gap-1.5 text-[0.8125rem]">
1625
+ <XIcon class="h-3.5 w-3.5 flex-none text-[var(--cairn-error-ink)]" aria-hidden="true" />
1626
+ An edit branch would not load.
1627
+ </p>
1628
+ {/if}
1629
+ <button type="button" class="btn btn-sm self-start border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={runReplacePreview}>
1630
+ <RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Check usage again
1631
+ </button>
1632
+ </div>
1633
+ <div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
1634
+ <ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
1635
+ <span>Your uploaded file is held and ready. Once the scan completes, the review opens with the full impact.</span>
1636
+ </div>
1637
+ </div>
1638
+ <div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
1639
+ <span class="mr-auto text-[0.75rem] text-[var(--color-muted)]">No file was changed.</span>
1640
+ <button type="button" class="btn btn-sm" onclick={closeReplaceDialog}>Cancel</button>
1641
+ </div>
1642
+ {/if}
1643
+ </div>
1644
+ {/if}
1645
+ </dialog>
1646
+
1647
+ <!-- The Push-alt review dialog: a native modal <dialog> (native focus trap + Escape), NO light dismiss.
1648
+ Alt fill is reversible and frequent, so it carries role="dialog" (the everyday register, never
1649
+ alertdialog) with NO typed-slug gate; apply is always enabled. The review step lists three buckets
1650
+ (will-fill always applied, customized behind one opt-in, decorative-skipped reported); the blocked
1651
+ step is the fail-closed surface (no apply form). -->
1652
+ <!-- svelte-ignore a11y_no_redundant_roles -->
1653
+ <!-- The explicit role="dialog" is the native <dialog> default, but it is stated to mark the everyday
1654
+ register against the Replace dialog's role="alertdialog" sibling, and the component test reads it. -->
1655
+ <dialog
1656
+ bind:this={altDialog}
1657
+ data-testid="cairn-alt-dialog"
1658
+ class="modal"
1659
+ role="dialog"
1660
+ aria-modal="true"
1661
+ aria-labelledby="cairn-ml-alt-title"
1662
+ aria-describedby="cairn-ml-alt-sub"
1663
+ oncancel={closeAltDialog}
1664
+ >
1665
+ {#if altAsset}
1666
+ {@const asset = altAsset}
1667
+ <div class="modal-box max-w-xl">
1668
+ <div class="mb-3 flex items-start gap-3">
1669
+ <span class="flex h-9 w-9 flex-none items-center justify-center rounded-box bg-primary/10 text-primary" aria-hidden="true">
1670
+ <MegaphoneIcon class="h-5 w-5" />
1671
+ </span>
1672
+ <div class="flex-1">
1673
+ <h2 id="cairn-ml-alt-title" class="text-lg font-bold tracking-tight font-[family-name:var(--font-display)]">
1674
+ {#if altStep === 'blocked'}
1675
+ Push alt is on hold
1676
+ {:else}
1677
+ Fill alt on {altCounts.willFill} {altCounts.willFill === 1 ? 'placement' : 'placements'}
1678
+ {/if}
1679
+ </h2>
1680
+ <p id="cairn-ml-alt-sub" class="mt-1 text-[0.8125rem] leading-relaxed text-[var(--color-muted)]">
1681
+ {#if altStep === 'blocked'}
1682
+ cairn could not read every place this image is used, so it will not write alt where it cannot see. Nothing was changed.
1683
+ {:else}
1684
+ This writes the default alt for {asset.displayName} into the published placements that have none. One commit to main. Placements that already have their own alt stay as they are, unless you choose to overwrite them below.
1685
+ {/if}
1686
+ </p>
1687
+ </div>
1688
+ <button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Cancel" onclick={closeAltDialog}>
1689
+ <XIcon class="h-3.5 w-3.5" aria-hidden="true" />
1690
+ </button>
1691
+ </div>
1692
+
1693
+ {#if altStep === 'review'}
1694
+ <div class="flex flex-col gap-4">
1695
+ <!-- The alt being pushed, shown once so the author confirms the text before applying. -->
1696
+ <div class="flex items-start gap-2.5 rounded-box border border-primary/25 bg-primary/[0.05] p-3 text-[0.8125rem] leading-relaxed">
1697
+ <MegaphoneIcon class="mt-0.5 h-4 w-4 flex-none text-primary" aria-hidden="true" />
1698
+ <span>The alt being pushed: <strong class="font-semibold">{altPushed ? `“${altPushed}”` : '(no default alt set)'}</strong>. Edit it in the panel first if it is not right.</span>
1699
+ </div>
1700
+
1701
+ <div class="flex flex-col gap-3">
1702
+ <!-- WILL FILL: every row's honest (no alt) -> default alt, always applied. -->
1703
+ {#if altFillRows.length > 0}
1704
+ <div class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100">
1705
+ <div class="flex items-center gap-2.5 p-3">
1706
+ <span class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-md bg-primary/10 text-primary" aria-hidden="true">
1707
+ <CheckIcon class="h-3.5 w-3.5" />
1708
+ </span>
1709
+ <div class="min-w-0 flex-1">
1710
+ <div class="text-[0.8125rem] font-semibold">Will fill the gap</div>
1711
+ <div class="mt-px text-[0.6875rem] leading-snug text-[var(--color-muted)]">These placements have no alt today. The default alt is written in.</div>
1712
+ </div>
1713
+ <span class="flex-none text-[0.8125rem] font-bold tabular-nums text-primary">{altFillRows.length}</span>
1714
+ </div>
1715
+ <ul bind:this={altFillList} id="cairn-ml-alt-fill" class="flex max-h-44 list-none flex-col overflow-y-auto border-t border-[var(--cairn-card-border)] p-0">
1716
+ {#each altFillVisible as row, i (row.key)}
1717
+ <!-- The first row past the cap is the script-only focus target for "Show all"
1718
+ (tabindex -1). svelte-ignore: as above, the conditional hides the literal -1. -->
1719
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
1720
+ <li class="flex items-start gap-2.5 border-t border-[var(--cairn-card-border)]/70 px-3 py-2.5 first:border-t-0" tabindex={i === ALT_ROW_CAP ? -1 : undefined}>
1721
+ <FileTextIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
1722
+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
1723
+ <div class="flex items-center gap-1.5">
1724
+ <span class="truncate text-[0.8125rem] font-semibold">{row.title}</span>
1725
+ <span class="flex-none rounded-full bg-base-content/[0.06] px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">{row.kind}</span>
1726
+ </div>
1727
+ <div class="flex flex-wrap items-baseline gap-1.5 text-[0.75rem] leading-snug">
1728
+ <span class="italic text-[var(--color-muted)]">(no alt)</span>
1729
+ <ArrowRightIcon class="h-3 w-3 flex-none text-[var(--color-muted)] opacity-65" aria-hidden="true" />
1730
+ <span class="font-medium text-primary">{row.after}</span>
1731
+ </div>
1732
+ </div>
1733
+ </li>
1734
+ {/each}
1735
+ </ul>
1736
+ {#if altFillHidden > 0 && !altShowAll}
1737
+ <div class="border-t border-[var(--cairn-card-border)] p-1.5">
1738
+ <button
1739
+ type="button"
1740
+ class="flex w-full items-center justify-center gap-1.5 rounded px-2 py-1 text-[0.75rem] font-medium text-primary hover:bg-primary/[0.08]"
1741
+ aria-expanded={altShowAll}
1742
+ aria-controls="cairn-ml-alt-fill"
1743
+ onclick={showAllAltFill}
1744
+ >
1745
+ Show the other {altFillHidden} {altFillHidden === 1 ? 'placement' : 'placements'}, all gaining the same alt
1746
+ </button>
1747
+ </div>
1748
+ {/if}
1749
+ </div>
1750
+
1751
+ <!-- The body-vs-hero caveat, anchored beside will-fill where the surprised author looks. -->
1752
+ <div class="flex items-start gap-2 px-0.5 text-[0.75rem] leading-relaxed">
1753
+ <TriangleAlertIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--cairn-warning-ink)]" aria-hidden="true" />
1754
+ <span>A body image has no place to record decorative, so an empty body image always reads as a gap to fill. Only a hero can be skipped as decorative.</span>
1755
+ </div>
1756
+ {/if}
1757
+
1758
+ <!-- HAS CUSTOM ALT: one bucket-level opt-in (a real native checkbox). Before it is checked,
1759
+ each row shows its existing alt plain and "kept"; checking flips to was -> default. -->
1760
+ {#if altCustomRows.length > 0}
1761
+ <div data-cairn-alt-custom class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100">
1762
+ <div class="flex items-center gap-2.5 p-3">
1763
+ <span class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-md bg-[var(--cairn-warning-ink)]/10 text-[var(--cairn-warning-ink)]" aria-hidden="true">
1764
+ <MegaphoneIcon class="h-3.5 w-3.5" />
1765
+ </span>
1766
+ <div class="min-w-0 flex-1">
1767
+ <div class="text-[0.8125rem] font-semibold">Already has custom alt</div>
1768
+ <div class="mt-px text-[0.6875rem] leading-snug text-[var(--color-muted)]">
1769
+ {altOverwrite ? 'You chose to overwrite these.' : 'Left alone by default. You can overwrite these too.'}
1770
+ </div>
1771
+ </div>
1772
+ <span class="flex-none text-[0.8125rem] font-bold tabular-nums text-[var(--cairn-warning-ink)]">{altCustomRows.length}</span>
1773
+ </div>
1774
+ <!-- The opt-in band, styled in the danger family: overwriting an editor's words is the
1775
+ destructive choice. The checkbox is a REAL native input in the a11y tree. -->
1776
+ <div class="border-t border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3">
1777
+ <label class="flex cursor-pointer items-start gap-2.5">
1778
+ <input
1779
+ type="checkbox"
1780
+ data-cairn-alt-optin
1781
+ class="checkbox checkbox-sm mt-px border-[var(--cairn-error-border)] checked:border-[var(--cairn-error-ink)] checked:bg-[var(--cairn-error-ink)]"
1782
+ aria-describedby="cairn-ml-alt-optin-hint"
1783
+ bind:checked={altOverwrite}
1784
+ />
1785
+ <span class="text-[0.8125rem] leading-snug text-[var(--cairn-error-ink)]">
1786
+ <span class="font-semibold">Also overwrite {altCustomRows.length === 1 ? 'this 1 placement' : `these ${altCustomRows.length} placements`} with the default alt.</span>
1787
+ <span id="cairn-ml-alt-optin-hint" class="mt-0.5 block">Overwrites the alt these entries already have. Git keeps the old version.</span>
1788
+ </span>
1789
+ </label>
1790
+ </div>
1791
+ <ul class="flex max-h-44 list-none flex-col overflow-y-auto p-0">
1792
+ {#each altCustomRows as row (row.key)}
1793
+ <li class="flex items-start gap-2.5 border-t border-[var(--cairn-card-border)]/70 px-3 py-2.5 first:border-t-0">
1794
+ <FileTextIcon class="mt-0.5 h-3.5 w-3.5 flex-none text-[var(--color-muted)]" aria-hidden="true" />
1795
+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
1796
+ <div class="flex items-center gap-1.5">
1797
+ <span class="truncate text-[0.8125rem] font-semibold">{row.title}</span>
1798
+ <span class="flex-none rounded-full bg-base-content/[0.06] px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-[var(--color-muted)]">{row.kind}</span>
1799
+ </div>
1800
+ <div class="flex flex-wrap items-baseline gap-1.5 text-[0.75rem] leading-snug">
1801
+ {#if altOverwrite}
1802
+ <span data-cairn-alt-was class="text-base-content line-through decoration-[var(--color-muted)]/55">{`“${row.before}”`}</span>
1803
+ <ArrowRightIcon class="h-3 w-3 flex-none text-[var(--color-muted)] opacity-65" aria-hidden="true" />
1804
+ <span class="font-medium text-primary">{altPushed}</span>
1805
+ {:else}
1806
+ <span class="text-base-content">{`“${row.before}”`}</span>
1807
+ <span class="text-[var(--color-muted)] opacity-65" aria-hidden="true">&middot;</span>
1808
+ <span class="text-[var(--color-muted)]">kept</span>
1809
+ {/if}
1810
+ </div>
1811
+ </div>
1812
+ </li>
1813
+ {/each}
1814
+ </ul>
1815
+ </div>
1816
+ {/if}
1817
+
1818
+ <!-- DECORATIVE HERO, SKIPPED: listed, muted, never an input. -->
1819
+ {#if altSkipRows.length > 0}
1820
+ <div data-cairn-alt-skip class="overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 opacity-90">
1821
+ <div class="flex items-center gap-2.5 p-3">
1822
+ <span class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-md bg-base-content/[0.07] text-[var(--color-muted)]" aria-hidden="true">
1823
+ <ImageOffIcon class="h-3.5 w-3.5" />
1824
+ </span>
1825
+ <div class="min-w-0 flex-1">
1826
+ <div class="text-[0.8125rem] font-semibold">Marked decorative, skipped</div>
1827
+ <div class="mt-px text-[0.6875rem] leading-snug text-[var(--color-muted)]">A hero set as decorative on purpose. It is left without alt.</div>
1828
+ </div>
1829
+ <span class="flex-none text-[0.8125rem] font-bold tabular-nums text-[var(--color-muted)]">{altSkipRows.length}</span>
1830
+ </div>
1831
+ <ul class="flex list-none flex-col border-t border-[var(--cairn-card-border)] p-0">
1832
+ {#each altSkipRows as row (row.key)}
1833
+ <li class="flex items-center gap-2.5 border-t border-[var(--cairn-card-border)]/70 px-3 py-2 text-[0.75rem] text-[var(--color-muted)] first:border-t-0">
1834
+ <span class="truncate">{row.title}</span>
1835
+ <span class="flex-none rounded-full bg-base-content/[0.06] px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide">{row.kind}</span>
1836
+ </li>
1837
+ {/each}
1838
+ </ul>
1839
+ </div>
1840
+ {/if}
1841
+ </div>
1842
+
1843
+ {#if (altPlan?.branchDelta?.length ?? 0) > 0}
1844
+ <!-- The report-only branch delta: open cairn/* edits keep their own alt until they publish. -->
1845
+ <div class="rounded-box border border-dashed border-[var(--cairn-card-border)] bg-base-200/40 p-3">
1846
+ <div class="mb-1.5 flex items-center gap-2">
1847
+ <GitBranchIcon class="h-4 w-4 flex-none text-[var(--color-muted)]" aria-hidden="true" />
1848
+ <span class="text-[0.8125rem] font-semibold">Open edits not touched</span>
1849
+ <span class="text-xs tabular-nums text-[var(--color-muted)]">{altPlan?.branchDelta.length ?? 0}</span>
1850
+ </div>
1851
+ <p class="mb-2 text-[0.75rem] leading-relaxed text-[var(--color-muted)]">These edits are on their own branches and are not changed. Each keeps its alt as the author has it there.</p>
1852
+ <ul class="flex list-none flex-col gap-1 p-0">
1853
+ {#each altPlan?.branchDelta ?? [] as delta (delta.branch)}
1854
+ <li class="font-[family-name:var(--font-editor)] text-[0.6875rem] text-[var(--cairn-warning-ink)]">{delta.branch}</li>
1855
+ {/each}
1856
+ </ul>
1857
+ </div>
1858
+ {/if}
1859
+
1860
+ <div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
1861
+ <ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
1862
+ <span>Every version stays in git history, so any overwrite can be undone.</span>
1863
+ </div>
1864
+ </div>
1865
+
1866
+ <!-- The polite live region announces the moving committed total when the opt-in toggles. -->
1867
+ <div class="sr-only" role="status" aria-live="polite">
1868
+ Now writing alt to {altTotal} {altTotal === 1 ? 'placement' : 'placements'}.{altOverwrite && altCounts.customized > 0 ? ` ${altCounts.willFill} filled, ${altCounts.customized} overwritten.` : ''}
1869
+ </div>
1870
+
1871
+ <form method="POST" action="?/mediaAltPropagate" class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
1872
+ <CsrfField />
1873
+ <input type="hidden" name="hash" value={asset.hash} />
1874
+ <!-- The opt-in checkbox lives beside the customized rows (outside the form), so its bound
1875
+ state is mirrored here as the posted flag. The server reads form.get('overwrite') === 'on'. -->
1876
+ <input type="hidden" name="overwrite" value={altOverwrite ? 'on' : ''} />
1877
+ <span class="mr-auto inline-flex items-center gap-1.5 text-[0.75rem] text-[var(--color-muted)]">
1878
+ <GitBranchIcon class="h-3.5 w-3.5" aria-hidden="true" /> One commit to main
1879
+ </span>
1880
+ <button type="button" class="btn btn-sm" onclick={closeAltDialog}>Cancel</button>
1881
+ <button type="submit" class="btn btn-sm btn-primary">
1882
+ <CheckIcon class="h-4 w-4" aria-hidden="true" />
1883
+ {#if altOverwrite && altCounts.customized > 0}
1884
+ Update {altTotal} {altTotal === 1 ? 'placement' : 'placements'}
1885
+ {:else}
1886
+ Fill {altTotal} {altTotal === 1 ? 'placement' : 'placements'}
1887
+ {/if}
1888
+ </button>
1889
+ </form>
1890
+ {:else}
1891
+ <!-- The fail-closed surface: usage could not be fully verified, so the push refuses rather than
1892
+ guess. NO apply form. A quiet "Check usage again" re-runs the scan. The banner on open is
1893
+ role="status" (not alert): no action was attempted yet. MediaAltPropagateFailure carries
1894
+ only `error`, so the generic honest line stands in. -->
1895
+ <div class="flex flex-col gap-3">
1896
+ <div role="status" class="flex flex-col gap-2.5 rounded-box border border-[var(--cairn-error-border)] bg-[var(--cairn-error-tint)] p-3.5">
1897
+ <span class="inline-flex items-center gap-2 text-[0.8125rem] font-semibold text-[var(--cairn-error-ink)]">
1898
+ <TriangleAlertIcon class="h-4 w-4 flex-none" aria-hidden="true" /> Usage could not be fully verified
1899
+ </span>
1900
+ <p class="text-[0.8125rem] leading-relaxed">
1901
+ cairn could not read every place this image is used, so it cannot tell which placements need alt. Writing now could miss a placement or write over one with no record of it.
1902
+ </p>
1903
+ <button type="button" class="btn btn-sm self-start border-[var(--cairn-error-border)] text-[var(--cairn-error-ink)]" onclick={runAltPreview}>
1904
+ <RefreshCwIcon class="h-4 w-4" aria-hidden="true" /> Check usage again
1905
+ </button>
1906
+ </div>
1907
+ <div class="flex items-start gap-2.5 rounded-box border border-[var(--cairn-card-border)] bg-base-200/50 p-3 text-[0.8125rem] leading-relaxed">
1908
+ <ClockIcon class="mt-0.5 h-4 w-4 flex-none text-[var(--color-positive-ink)]" aria-hidden="true" />
1909
+ <span>Nothing was changed. Once the scan completes, the review opens with every placement.</span>
1910
+ </div>
1911
+ </div>
1912
+ <div class="mt-4 flex items-center justify-end gap-2.5 border-t border-[var(--cairn-card-border)] pt-3.5">
1913
+ <span class="mr-auto text-[0.75rem] text-[var(--color-muted)]">No alt was changed.</span>
1914
+ <button type="button" class="btn btn-sm" onclick={closeAltDialog}>Cancel</button>
1915
+ </div>
1916
+ {/if}
1917
+ </div>
1918
+ {/if}
1919
+ </dialog>