@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/LICENSE +12 -10
- package/README.md +7 -2
- package/dist/hypercms.min.js +53 -11
- package/package.json +3 -3
- package/src/events.js +148 -34
- package/src/hypercms.js +4 -0
- package/src/platform.js +58 -7
- package/src/theme/pixel-quiet.overrides.css +42 -0
- package/src/theme.generated.css +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/hyper-cms",
|
|
3
|
-
"version": "0.
|
|
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": "
|
|
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.
|
|
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
|
-
|
|
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;
|
|
216
|
-
//
|
|
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,
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
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('
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
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;
|
package/src/theme.generated.css
CHANGED
|
@@ -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;
|