@panphora/hyper-cms 0.7.2 → 0.8.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/hyper-cms",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "Live edit-in-place CMS sidebar for HTML pages with a hyper-html-api rules tag",
5
5
  "keywords": [
6
6
  "HTML",
@@ -13,7 +13,7 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/panphora/hypercms/issues"
15
15
  },
16
- "license": "0BSD",
16
+ "license": "MIT-0",
17
17
  "author": "David Miranda",
18
18
  "type": "module",
19
19
  "main": "src/hypercms.js",
@@ -44,7 +44,7 @@
44
44
  "test:all": "npm run test:node && npm run test:browser"
45
45
  },
46
46
  "dependencies": {
47
- "hyper-html-api": "^0.6.4",
47
+ "hyper-html-api": "^0.6.5",
48
48
  "hyper-morph": "^0.5.0"
49
49
  },
50
50
  "devDependencies": {
package/src/events.js CHANGED
@@ -190,7 +190,12 @@ async function maybeCrop(file, fieldEl) {
190
190
  const crop = platform('quickcrop')
191
191
  if (typeof crop !== 'function') return { file }
192
192
  try {
193
- const r = await crop(file, { aspect: parseCropAspect(attr), ...CROP_OUTPUT })
193
+ // QuickCrop's `auto` modal adapter looks only at window.themodal. clayjs
194
+ // attaches its dialog as clay.modal and deliberately sets no global, so a
195
+ // clayjs page would silently get QuickCrop's own plain dialog. Naming it here
196
+ // keeps QuickCrop free of client knowledge and clayjs free of the global.
197
+ const modal = (typeof window !== 'undefined' && (window.clay?.modal ?? window.themodal)) || 'auto'
198
+ const r = await crop(file, { aspect: parseCropAspect(attr), modal, ...CROP_OUTPUT })
194
199
  if (r === null) return null
195
200
  return { file: blobToFile(r.blob, file.name), dataURL: r.dataURL }
196
201
  } catch (err) {
@@ -212,8 +217,8 @@ export function parseCropAspect(value) {
212
217
  return w / h
213
218
  }
214
219
 
215
- // quickcrop returns a nameless Blob; uploadFileBasic rejects raw Blobs and the
216
- // server keys content-type off the filename extension, so wrap it as a File
220
+ // quickcrop returns a nameless Blob; the legacy uploader rejects raw Blobs and a
221
+ // host keys content-type off the filename extension, so wrap it as a File
217
222
  // whose extension matches the encoded mime. Exported for tests.
218
223
  export function blobToFile(blob, originalName) {
219
224
  const ext = blob.type === 'image/webp' ? '.webp' : blob.type === 'image/jpeg' ? '.jpg' : '.png'
@@ -225,11 +230,25 @@ export function blobToFile(blob, originalName) {
225
230
  }
226
231
  }
227
232
 
228
- // A file was picked in an @file/@image widget. Optionally crop, upload via the
229
- // host's uploadFileBasic (or fall back to a local object-URL preview), write the
230
- // resulting URL into the bound img.src / a.href leaf, then commit once. The leaf
231
- // attribute change is an observed page mutation, so undo gets a clean step with
232
- // no commitWithUndo. Async; failures surface inline and reset the picker.
233
+ // A file was picked in an @file/@image widget. Optionally crop, ask the host to
234
+ // store the file, write the resulting URL into the bound img.src / a.href leaf,
235
+ // then commit once. The leaf attribute change is an observed page mutation, so
236
+ // undo gets a clean step with no commitWithUndo. Async; failures surface inline
237
+ // and reset the picker.
238
+ //
239
+ // The upload either stores the file or it does not, and those are not the same
240
+ // as success and failure. Three outcomes, deliberately spelled out:
241
+ //
242
+ // stored write the served URL. The page carries a reference.
243
+ // cannot store here embed the bytes. A document open from a plain file server
244
+ // or a desktop app has nowhere to put a file, and embedding
245
+ // is the RIGHT answer there, not a consolation prize.
246
+ // failed write nothing. Embedding a file the host refused for size
247
+ // or type is the disease this capability exists to cure: it
248
+ // is how a 2 MB photo ends up in the document, in every
249
+ // future save, and in every stored version.
250
+ const EMBED_CODES = new Set(['unsupported', 'payment-required'])
251
+
233
252
  export async function onUploadChange(inputEl, ctx) {
234
253
  const file = inputEl.files && inputEl.files[0]
235
254
  if (!file) return
@@ -246,39 +265,112 @@ export async function onUploadChange(inputEl, ctx) {
246
265
  const fileLike = cropped.file
247
266
  const previewDataUrl = cropped.dataURL || null
248
267
 
249
- const upload = platform('uploadFileBasic')
250
- let url = null
251
- if (typeof upload === 'function') {
252
- try {
253
- const res = await upload(fileLike)
254
- // uploadFileBasic resolves the full envelope { uploads:[{name,nodeId,url}], … };
255
- // the served URL is uploads[0].url, NOT res.url.
256
- url = res && res.uploads && res.uploads[0] && res.uploads[0].url
257
- } catch (err) {
258
- if (ctx.closed) { resetPicker(inputEl); return }
259
- showFieldUploadError(fieldEl, (err && err.message) || 'Upload failed')
260
- ctx.dispatch?.('hcms:error', { error: err, path: pathStr })
261
- resetPicker(inputEl)
262
- return
263
- }
268
+ const upload = platform('upload')
269
+ if (typeof upload !== 'function') {
270
+ // No client at all: a bare HTML file with the CMS dropped in. Same outcome as
271
+ // a host that answers `unsupported`, and silent for the same reason.
272
+ return finishUpload(inputEl, fieldEl, ctx, pathStr, await embedUrl(fileLike, previewDataUrl, fieldEl), fileLike)
273
+ }
274
+
275
+ // The crop, shown immediately on the thumb's own chrome and NOT on the bound
276
+ // leaf. Writing a two-megabyte data URL to the leaf makes it the field's value,
277
+ // and any commit() landing while the upload is in flight would project it
278
+ // straight into the live page.
279
+ showFieldUploadPreview(fieldEl, previewDataUrl)
280
+ setUploadProgress(fieldEl, 0)
281
+
282
+ const controller = beginUpload(ctx)
283
+ let res
284
+ try {
285
+ res = await upload(fileLike, {
286
+ signal: controller?.signal,
287
+ onProgress: ({ percent }) => setUploadProgress(fieldEl, percent),
288
+ })
289
+ } finally {
290
+ endUpload(ctx, controller)
291
+ clearUploadProgress(fieldEl)
264
292
  }
293
+
265
294
  // The editor may have been closed while the upload was in flight; bail before
266
295
  // writing the (now detached) form leaf or committing to the live page.
267
296
  if (ctx.closed) { resetPicker(inputEl); return }
268
- if (!url) {
269
- // No host uploader (or it returned nothing): local preview only, which the
270
- // commit persists into the page as a blob:/data: URL — non-persistent, by
271
- // design (documented), so the widget still works in a bare page / demo.
272
- url = previewDataUrl || makeLocalPreviewUrl(fileLike)
297
+ if (res.code === 'aborted') { resetPicker(inputEl); return }
298
+
299
+ if (res.ok) {
300
+ return finishUpload(inputEl, fieldEl, ctx, pathStr, res.uploads[0].url, fileLike)
273
301
  }
274
- if (!url) { resetPicker(inputEl); return }
275
302
 
303
+ if (!EMBED_CODES.has(res.code)) {
304
+ showFieldUploadError(fieldEl, res.msg || 'Upload failed')
305
+ ctx.dispatch?.('hcms:error', { error: new Error(res.msg || 'Upload failed'), code: res.code, path: pathStr })
306
+ resetPicker(inputEl)
307
+ return
308
+ }
309
+
310
+ // Embedded, and only `payment-required` says so. A person editing a file on
311
+ // their own desktop does not need to be told their file is a file; a person on
312
+ // a host that WOULD store it, for money, is being told why it did not.
313
+ const note = res.code === 'payment-required'
314
+ ? 'This file is stored in the page. Add a paid plan to upload files.'
315
+ : null
316
+ return finishUpload(inputEl, fieldEl, ctx, pathStr, await embedUrl(fileLike, previewDataUrl, fieldEl), fileLike, note)
317
+ }
318
+
319
+ // Write the URL into the leaf and commit once. `url` empty (no FileReader, an
320
+ // unreadable file) means there is nothing to write, and writing '' would clear a
321
+ // picture the person already had.
322
+ //
323
+ // The note goes on AFTER the commit, never before: a successful commit calls
324
+ // setError(ctx, null), which clears every inline slot in the form. Writing the
325
+ // note first leaves the person with an embedded file and no explanation of why.
326
+ function finishUpload(inputEl, fieldEl, ctx, pathStr, url, fileLike, note = null) {
327
+ if (ctx.closed) { resetPicker(inputEl); return }
328
+ showFieldUploadPreview(fieldEl, null)
276
329
  showFieldUploadError(fieldEl, null)
330
+ if (!url) { resetPicker(inputEl); return }
277
331
  writeUploadLeaf(fieldEl, url, fileLike.name)
278
332
  commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
333
+ if (note) showFieldUploadError(fieldEl, note, 'info')
279
334
  resetPicker(inputEl)
280
335
  }
281
336
 
337
+ // The crop when there was one, otherwise the file's own bytes. Always a data:
338
+ // URL and never blob:, so both paths degrade identically: a blob: URL is dead the
339
+ // moment the page reloads, so the no-crop path used to commit a picture that
340
+ // looked right until the person came back to it.
341
+ async function embedUrl(fileLike, previewDataUrl, fieldEl) {
342
+ return previewDataUrl || (await fileToDataUrl(fileLike, fieldEl))
343
+ }
344
+
345
+ // The FileReader comes from the field's own realm, never from globalThis: a form
346
+ // mounted into another document reads a File belonging to THAT window, and a
347
+ // reader from a different realm rejects it.
348
+ function fileToDataUrl(file, fieldEl) {
349
+ const Reader = fieldEl?.ownerDocument?.defaultView?.FileReader || globalThis.FileReader
350
+ if (!Reader) return Promise.resolve('')
351
+ return new Promise((resolve) => {
352
+ const reader = new Reader()
353
+ reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
354
+ reader.onerror = () => resolve('')
355
+ try { reader.readAsDataURL(file) } catch { resolve('') }
356
+ })
357
+ }
358
+
359
+ // In-flight uploads hang off the ctx, so close() tears them down the same way it
360
+ // tears down the observer and the undo subscription. Without this the request
361
+ // keeps running after the editor is gone: harmless to the page, because every
362
+ // write is behind a ctx.closed check, but it holds the connection and the bytes.
363
+ function beginUpload(ctx) {
364
+ if (typeof AbortController !== 'function') return null
365
+ const controller = new AbortController()
366
+ ;(ctx.uploads || (ctx.uploads = new Set())).add(controller)
367
+ return controller
368
+ }
369
+
370
+ function endUpload(ctx, controller) {
371
+ if (controller) ctx.uploads?.delete(controller)
372
+ }
373
+
282
374
  // The × on an upload widget. Clears the bound leaf to empty and commits, so the
283
375
  // :has()-driven chrome falls back to the empty state. Removes immediately (no
284
376
  // consent gate) per the approved design; distinct from the "remove" action,
@@ -319,15 +411,37 @@ function resetPicker(inputEl) {
319
411
  try { inputEl.value = '' } catch { /* some inputs reject programmatic clear */ }
320
412
  }
321
413
 
322
- function makeLocalPreviewUrl(file) {
323
- const U = (typeof URL !== 'undefined' && URL.createObjectURL) ? URL : null
324
- if (!U) return ''
325
- try { return U.createObjectURL(file) } catch { return '' }
414
+ // The at-upload-time picture, painted on the frame rather than assigned to the
415
+ // leaf, because the leaf IS the field's value. CSS reveals the thumb while
416
+ // [data-hcms-uploading] is set, so the preview shows even on a field that is
417
+ // still empty.
418
+ function showFieldUploadPreview(fieldEl, dataUrl) {
419
+ const frame = fieldEl.querySelector ? fieldEl.querySelector('.mirk-image__frame') : null
420
+ if (!frame) return
421
+ if (dataUrl) frame.style.backgroundImage = `url("${dataUrl.replace(/"/g, '%22')}")`
422
+ else frame.style.removeProperty('background-image')
423
+ }
424
+
425
+ // Progress where the person is looking. The percent drives a CSS custom property
426
+ // rather than any markup of its own, so the templates keep their exact shape and
427
+ // a half-finished upload cannot leave an orphan node behind in the form.
428
+ function setUploadProgress(fieldEl, percent) {
429
+ const pct = Math.max(0, Math.min(100, Number(percent) || 0))
430
+ fieldEl.setAttribute('data-hcms-uploading', '')
431
+ fieldEl.style?.setProperty?.('--hcms-upload-progress', `${pct}%`)
432
+ }
433
+
434
+ function clearUploadProgress(fieldEl) {
435
+ fieldEl.removeAttribute('data-hcms-uploading')
436
+ fieldEl.style?.removeProperty?.('--hcms-upload-progress')
326
437
  }
327
438
 
328
- function showFieldUploadError(fieldEl, message) {
439
+ // `kind` distinguishes a refusal from a note: 'info' is the file that WAS stored,
440
+ // in the page, and the slot says why rather than shouting about a failure.
441
+ function showFieldUploadError(fieldEl, message, kind = 'error') {
329
442
  const slot = fieldEl.querySelector ? fieldEl.querySelector(':scope > .hcms-error') : null
330
443
  if (!slot) return
444
+ slot.classList.toggle('hcms-error--info', !!message && kind === 'info')
331
445
  if (message) {
332
446
  slot.textContent = message
333
447
  slot.hidden = false
package/src/hypercms.js CHANGED
@@ -339,6 +339,10 @@ export function close() {
339
339
  // host uploader) bails instead of mutating the page / firing onChange after
340
340
  // teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
341
341
  ctx.closed = true
342
+ // An upload still in flight is torn down like any other subscription. The
343
+ // ctx.closed check above already stops it writing; this stops it running.
344
+ for (const controller of ctx.uploads || []) { try { controller.abort() } catch {} }
345
+ ctx.uploads?.clear()
342
346
  const previouslyFocused = ctx.previouslyFocused
343
347
  ctx.dispatch('hcms:close', null)
344
348
  toggleCloseParam()
package/src/platform.js CHANGED
@@ -4,12 +4,11 @@
4
4
  //
5
5
  // TWO clients provide this and they disagree on names. hyperclayjs owns
6
6
  // window.hyperclay; clayjs owns window.clay and renamed things on the way past
7
- // (onPrepareForSave -> addDocumentTransform, consent -> confirm). clay.RichClay exists,
8
- // but quickcrop and uploadFileBasic have no clay equivalent at all, so image crop and
9
- // file upload silently degrade under clayjs. So this is a capability table
10
- // rather than a namespace pointer: `window.clay ?? window.hyperclay` would resolve
11
- // to clay, find no onPrepareForSave, and silently stop stripping the CMS's own
12
- // chrome out of every save.
7
+ // (onPrepareForSave -> addDocumentTransform, consent -> confirm, uploadFileBasic ->
8
+ // upload). So this is a capability table rather than a namespace pointer:
9
+ // `window.clay ?? window.hyperclay` would resolve to clay, find no
10
+ // onPrepareForSave, and silently stop stripping the CMS's own chrome out of every
11
+ // save.
13
12
  //
14
13
  // Reads happen per call, never cached. A client can install a capability after
15
14
  // hypercms evaluates — that is the whole reason whenReady() below exists.
@@ -20,7 +19,59 @@ const CAPABILITIES = {
20
19
  consent: (clay, hyperclay) => clay?.confirm ?? hyperclay?.consent,
21
20
  RichClay: (clay, hyperclay) => clay?.RichClay ?? hyperclay?.RichClay,
22
21
  quickcrop: (clay, hyperclay) => clay?.quickcrop ?? hyperclay?.quickcrop,
23
- uploadFileBasic: (clay, hyperclay) => clay?.uploadFileBasic ?? hyperclay?.uploadFileBasic,
22
+ upload: (clay, hyperclay) =>
23
+ clay?.upload ?? (hyperclay?.uploadFileBasic ? adaptLegacyUpload(hyperclay.uploadFileBasic) : null),
24
+ }
25
+
26
+ // The one capability where the two clients differ in SHAPE and not just in name,
27
+ // so it is normalized here rather than at the call site. clay.upload answers one
28
+ // envelope for every outcome and never rejects; uploadFileBasic resolves on
29
+ // success and rejects on failure. A pick handler that had to know which client it
30
+ // was riding would grow two of every branch, and the branch that matters most is
31
+ // the one a rejection reads wrong: "this host does not store files" is not a
32
+ // failure, and a caller that catches it as one stops when it should have embedded.
33
+ //
34
+ // The legacy client has no cancellation, so `signal` is honored on the near side
35
+ // only: an upload already in flight runs to completion, and its result is
36
+ // discarded rather than written. That is the same outcome the caller's own
37
+ // ctx.closed check produces, one branch earlier.
38
+ const LEGACY_STATUS_CODES = {
39
+ 402: 'payment-required',
40
+ 413: 'too-large',
41
+ 415: 'unsupported-type',
42
+ 401: 'unauthorized',
43
+ 403: 'forbidden',
44
+ 404: 'not-found',
45
+ }
46
+
47
+ const aborted = () => ({ ok: false, msg: 'Upload cancelled', msgType: 'skipped', code: 'aborted', uploads: [] })
48
+
49
+ function adaptLegacyUpload(uploadFileBasic) {
50
+ return async function upload(file, { onProgress, signal } = {}) {
51
+ if (signal?.aborted) return aborted()
52
+ try {
53
+ // uploadFileBasic reports a bare percent; clay reports {loaded,total,percent}.
54
+ // Callers draw from `percent`, which is the only field both can supply.
55
+ const res = await uploadFileBasic(file, {
56
+ onProgress: (percent) => { onProgress?.({ loaded: null, total: null, percent }) },
57
+ })
58
+ if (signal?.aborted) return aborted()
59
+ const uploads = (res && res.uploads) || []
60
+ if (typeof uploads[0]?.url !== 'string') {
61
+ return { ok: false, msg: 'The host accepted the file but did not say where it put it', msgType: 'error', code: 'bad-response', uploads: [] }
62
+ }
63
+ return { ok: true, msg: res.msg || 'Uploaded', msgType: res.msgType || 'success', code: res.code || null, uploads }
64
+ } catch (err) {
65
+ if (signal?.aborted) return aborted()
66
+ // The rejection carries the HTTP status and the raw body, so the host's own
67
+ // code survives the round trip and the caller branches on the reason rather
68
+ // than pattern-matching a message.
69
+ let body = {}
70
+ try { body = JSON.parse(err?.response || '{}') } catch { body = {} }
71
+ const code = body.code || LEGACY_STATUS_CODES[err?.status] || 'error'
72
+ return { ok: false, msg: (err && err.message) || 'Upload failed', msgType: 'error', code, uploads: [] }
73
+ }
74
+ }
24
75
  }
25
76
 
26
77
  // Pass `win` when the caller already holds a realm (a doc.defaultView from a form
@@ -451,6 +451,41 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
451
451
  color: var(--mirk-placeholder-color);
452
452
  }
453
453
 
454
+ /* ---------- UPLOADING (spec §9) ----------
455
+ One attribute, [data-hcms-uploading], set on the field for as long as the host
456
+ has the bytes, plus --hcms-upload-progress carrying the percent. No markup of
457
+ its own: the templates keep their exact shape, so a half-finished upload cannot
458
+ leave an orphan node behind in the form. The at-upload-time picture is painted
459
+ on the frame as a background, never assigned to the bound <img>, because that
460
+ img IS the field's value and a commit landing mid-upload would write a
461
+ two-megabyte data URL straight into the live page. */
462
+ .hcms-upload[data-hcms-uploading] .mirk-image__frame {
463
+ background-size: cover;
464
+ background-position: center;
465
+ }
466
+ /* The empty state hides the thumb, so an upload into an empty field would have
467
+ nowhere to show. Uploading reveals it, preview or not. */
468
+ .hcms-upload--image[data-hcms-uploading] .mirk-image__thumb { display: inline-block; }
469
+ .hcms-upload--image[data-hcms-uploading] .mirk-image__upload { display: none; }
470
+ /* No × mid-flight: clearing writes the leaf empty, which says nothing about the
471
+ request still running and reads as a cancel that is not one. */
472
+ .hcms-upload[data-hcms-uploading] .hcms-upload-clear { display: none; }
473
+
474
+ .hcms-upload[data-hcms-uploading] .mirk-image__frame::after,
475
+ .hcms-upload--file[data-hcms-uploading] .mirk-file::after {
476
+ content: "";
477
+ position: absolute;
478
+ left: 0;
479
+ bottom: 0;
480
+ height: 3px;
481
+ width: var(--hcms-upload-progress, 0%);
482
+ background: var(--mirk-bevel-fg);
483
+ transition: width 120ms linear;
484
+ }
485
+ /* The @file row has no frame to hang the bar on, so it becomes the positioning
486
+ context itself. */
487
+ .hcms-upload--file[data-hcms-uploading] .mirk-file { position: relative; }
488
+
454
489
  /* clear-× (vendored-inert; data-hcms-action, never .mirk-*__remove). Bare × for
455
490
  @file, a pinned corner badge for @image — mirroring .hcms-remove / --card. */
456
491
  .hcms-upload-clear {
@@ -511,6 +546,13 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
511
546
  .hcms-shell-error[hidden],
512
547
  .hcms-error[hidden] { display: none; }
513
548
 
549
+ /* A note, not a refusal: the file WAS stored, in the page, and this says why it
550
+ is not on the host. Same slot, so there is one place a field ever speaks. */
551
+ .hcms-error--info {
552
+ color: var(--mirk-mark-fg);
553
+ border-color: var(--mirk-input-border);
554
+ }
555
+
514
556
  /* ---------- SAVE (sits at the end of the scrolling form, not pinned) ---------- */
515
557
  .hcms-shell-footer {
516
558
  display: flex;
@@ -1462,6 +1462,41 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
1462
1462
  color: var(--mirk-placeholder-color);
1463
1463
  }
1464
1464
 
1465
+ /* ---------- UPLOADING (spec §9) ----------
1466
+ One attribute, [data-hcms-uploading], set on the field for as long as the host
1467
+ has the bytes, plus --hcms-upload-progress carrying the percent. No markup of
1468
+ its own: the templates keep their exact shape, so a half-finished upload cannot
1469
+ leave an orphan node behind in the form. The at-upload-time picture is painted
1470
+ on the frame as a background, never assigned to the bound <img>, because that
1471
+ img IS the field's value and a commit landing mid-upload would write a
1472
+ two-megabyte data URL straight into the live page. */
1473
+ .hcms-upload[data-hcms-uploading] .mirk-image__frame {
1474
+ background-size: cover;
1475
+ background-position: center;
1476
+ }
1477
+ /* The empty state hides the thumb, so an upload into an empty field would have
1478
+ nowhere to show. Uploading reveals it, preview or not. */
1479
+ .hcms-upload--image[data-hcms-uploading] .mirk-image__thumb { display: inline-block; }
1480
+ .hcms-upload--image[data-hcms-uploading] .mirk-image__upload { display: none; }
1481
+ /* No × mid-flight: clearing writes the leaf empty, which says nothing about the
1482
+ request still running and reads as a cancel that is not one. */
1483
+ .hcms-upload[data-hcms-uploading] .hcms-upload-clear { display: none; }
1484
+
1485
+ .hcms-upload[data-hcms-uploading] .mirk-image__frame::after,
1486
+ .hcms-upload--file[data-hcms-uploading] .mirk-file::after {
1487
+ content: "";
1488
+ position: absolute;
1489
+ left: 0;
1490
+ bottom: 0;
1491
+ height: 3px;
1492
+ width: var(--hcms-upload-progress, 0%);
1493
+ background: var(--mirk-bevel-fg);
1494
+ transition: width 120ms linear;
1495
+ }
1496
+ /* The @file row has no frame to hang the bar on, so it becomes the positioning
1497
+ context itself. */
1498
+ .hcms-upload--file[data-hcms-uploading] .mirk-file { position: relative; }
1499
+
1465
1500
  /* clear-× (vendored-inert; data-hcms-action, never .mirk-*__remove). Bare × for
1466
1501
  @file, a pinned corner badge for @image — mirroring .hcms-remove / --card. */
1467
1502
  .hcms-upload-clear {
@@ -1522,6 +1557,13 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
1522
1557
  .hcms-shell-error[hidden],
1523
1558
  .hcms-error[hidden] { display: none; }
1524
1559
 
1560
+ /* A note, not a refusal: the file WAS stored, in the page, and this says why it
1561
+ is not on the host. Same slot, so there is one place a field ever speaks. */
1562
+ .hcms-error--info {
1563
+ color: var(--mirk-mark-fg);
1564
+ border-color: var(--mirk-input-border);
1565
+ }
1566
+
1525
1567
  /* ---------- SAVE (sits at the end of the scrolling form, not pinned) ---------- */
1526
1568
  .hcms-shell-footer {
1527
1569
  display: flex;