@masters-union/union-stack 0.3.9 → 0.3.11

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/dist/node.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkZOOUTUWU_cjs = require('./chunk-ZOOUTUWU.cjs');
3
+ var chunkNOAB2Q4B_cjs = require('./chunk-NOAB2Q4B.cjs');
4
4
  var path = require('path');
5
5
  var promises = require('fs/promises');
6
6
  var fs = require('fs');
@@ -97,7 +97,7 @@ async function resolveSource(source, opts = {}) {
97
97
  const blob = openAsBlob2 ? await openAsBlob2(source, { type: mimetype }) : new Blob([toBlobPart(await promises.readFile(source))], { type: mimetype });
98
98
  return { blob, filename, mimetype };
99
99
  } catch (cause) {
100
- throw chunkZOOUTUWU_cjs.makeError("VALIDATION", `Could not read file at "${source}".`, {
100
+ throw chunkNOAB2Q4B_cjs.makeError("VALIDATION", `Could not read file at "${source}".`, {
101
101
  retryable: false,
102
102
  cause
103
103
  });
@@ -120,7 +120,7 @@ async function resolveSource(source, opts = {}) {
120
120
  const mimetype = opts.mimeType || mimeFromName(filename);
121
121
  return { blob: new Blob([toBlobPart(source)], { type: mimetype }), filename, mimetype };
122
122
  }
123
- throw chunkZOOUTUWU_cjs.makeError(
123
+ throw chunkNOAB2Q4B_cjs.makeError(
124
124
  "VALIDATION",
125
125
  "Unsupported upload source. Pass a file path, Buffer, Uint8Array, ArrayBuffer, Blob, or Readable stream.",
126
126
  { retryable: false }
@@ -133,7 +133,7 @@ var DEFAULT_FILE_CONCURRENCY = 5;
133
133
  var UnionStackNodeClient = class {
134
134
  constructor(cfg) {
135
135
  if (!cfg?.apiKey) {
136
- throw chunkZOOUTUWU_cjs.makeError("CONFIG", 'apiKey is required. Pass { apiKey: "unionstack_live_\u2026" }.', {
136
+ throw chunkNOAB2Q4B_cjs.makeError("CONFIG", 'apiKey is required. Pass { apiKey: "unionstack_live_\u2026" }.', {
137
137
  retryable: false
138
138
  });
139
139
  }
@@ -144,9 +144,9 @@ var UnionStackNodeClient = class {
144
144
  skipConfigPrefetch: true,
145
145
  // Tag the traffic as server-to-server ("node/…") so cdn-be can tell it
146
146
  // apart from browser SDK calls ("js/…").
147
- sdkHeader: chunkZOOUTUWU_cjs.NODE_SDK_VERSION_HEADER
147
+ sdkHeader: chunkNOAB2Q4B_cjs.NODE_SDK_VERSION_HEADER
148
148
  };
149
- this.uploader = new chunkZOOUTUWU_cjs.Uploader(this.cfg);
149
+ this.uploader = new chunkNOAB2Q4B_cjs.Uploader(this.cfg);
150
150
  }
151
151
  /**
152
152
  * Upload one file. Accepts a path on disk, a Buffer/Uint8Array/ArrayBuffer, a
@@ -168,7 +168,7 @@ var UnionStackNodeClient = class {
168
168
  */
169
169
  async uploadMany(sources, opts = {}) {
170
170
  if (!Array.isArray(sources) || sources.length === 0) {
171
- const err = chunkZOOUTUWU_cjs.makeError("VALIDATION", "uploadMany requires a non-empty array of sources.", {
171
+ const err = chunkNOAB2Q4B_cjs.makeError("VALIDATION", "uploadMany requires a non-empty array of sources.", {
172
172
  retryable: false
173
173
  });
174
174
  opts.onError?.(err);
package/dist/node.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Readable } from 'node:stream';
2
- import { C as ClientConfig, d as UploadOptions, e as UploadedFile, B as BatchUploadOptions, P as PickResponse, R as ResolvedClientConfig } from './types-50o0pbff.cjs';
3
- export { a as PickedFile, b as ProgressEvent, S as Source, U as UploadError, c as UploadErrorCode } from './types-50o0pbff.cjs';
2
+ import { C as ClientConfig, d as UploadOptions, e as UploadedFile, B as BatchUploadOptions, P as PickResponse, R as ResolvedClientConfig } from './types-ZCfjNZls.cjs';
3
+ export { a as PickedFile, b as ProgressEvent, S as Source, U as UploadError, c as UploadErrorCode } from './types-ZCfjNZls.cjs';
4
4
 
5
5
  /**
6
6
  * Anything the Node client knows how to turn into an upload.
package/dist/node.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Readable } from 'node:stream';
2
- import { C as ClientConfig, d as UploadOptions, e as UploadedFile, B as BatchUploadOptions, P as PickResponse, R as ResolvedClientConfig } from './types-50o0pbff.js';
3
- export { a as PickedFile, b as ProgressEvent, S as Source, U as UploadError, c as UploadErrorCode } from './types-50o0pbff.js';
2
+ import { C as ClientConfig, d as UploadOptions, e as UploadedFile, B as BatchUploadOptions, P as PickResponse, R as ResolvedClientConfig } from './types-ZCfjNZls.js';
3
+ export { a as PickedFile, b as ProgressEvent, S as Source, U as UploadError, c as UploadErrorCode } from './types-ZCfjNZls.js';
4
4
 
5
5
  /**
6
6
  * Anything the Node client knows how to turn into an upload.
package/dist/node.js CHANGED
@@ -1,4 +1,4 @@
1
- import { makeError, NODE_SDK_VERSION_HEADER, Uploader } from './chunk-YNWENXR2.js';
1
+ import { makeError, NODE_SDK_VERSION_HEADER, Uploader } from './chunk-Z5PZQVST.js';
2
2
  import { basename, extname } from 'path';
3
3
  import { readFile } from 'fs/promises';
4
4
  import * as fs from 'fs';
package/dist/picker.cjs CHANGED
@@ -107,6 +107,10 @@ var BASE_CSS = `
107
107
  .us-picker-backdrop {
108
108
  --us-font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
109
109
  --us-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
110
+ /* Default to a light color-scheme so native controls never inherit the
111
+ visitor's OS dark mode; picker.ts overrides this to match the resolved
112
+ theme mode on mount. */
113
+ color-scheme: light;
110
114
  position: fixed; inset: 0; z-index: 2147483000;
111
115
  background: rgba(2, 6, 23, 0.4);
112
116
  -webkit-backdrop-filter: blur(8px);
@@ -251,6 +255,8 @@ var BASE_CSS = `
251
255
  }
252
256
  .us-file[data-state="done"] { border-color: color-mix(in srgb, var(--us-success) 35%, var(--us-border)); }
253
257
  .us-file[data-state="failed"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
258
+ /* Force-crop: image queued but not yet cropped to the required ratio. */
259
+ .us-file[data-crop-required="true"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
254
260
 
255
261
  .us-file-thumb {
256
262
  width: 40px; height: 40px; flex-shrink: 0;
@@ -277,6 +283,14 @@ var BASE_CSS = `
277
283
  color: var(--us-muted); font-size: 11px; flex-shrink: 0;
278
284
  font-family: var(--us-mono); letter-spacing: 0;
279
285
  }
286
+ /* Failure reason \u2014 full-width, danger-colored, wraps. Collapses entirely when
287
+ empty so non-failed rows keep their original height. */
288
+ .us-file-error {
289
+ color: var(--us-danger); font-size: 12px; line-height: 1.35;
290
+ margin-top: 4px; letter-spacing: -0.006em;
291
+ overflow-wrap: anywhere;
292
+ }
293
+ .us-file-error:empty { display: none; }
280
294
 
281
295
  .us-file-progress {
282
296
  height: 3px; background: var(--us-border); border-radius: 999px; overflow: hidden;
@@ -615,6 +629,7 @@ var BASE_CSS = `
615
629
 
616
630
  // src/picker/imageEditor.ts
617
631
  var MAX_DISPLAY = 720;
632
+ var MIN_CROP = 24;
618
633
  var ICON = {
619
634
  back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`,
620
635
  crop: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>`,
@@ -645,7 +660,31 @@ var ImageEditor = class {
645
660
  const start = this.dragStart.rect;
646
661
  const maxW = this.displayCanvas.width;
647
662
  const maxH = this.displayCanvas.height;
648
- const min = 24;
663
+ const min = MIN_CROP;
664
+ if (this.aspect && this.dragKind !== "move") {
665
+ switch (this.dragKind) {
666
+ case "se":
667
+ this.cropRect = this.lockedRectFromDrag(start.x, start.y, start.w + dx, 1, 1);
668
+ break;
669
+ case "nw": {
670
+ const ax = start.x + start.w, ay = start.y + start.h;
671
+ this.cropRect = this.lockedRectFromDrag(ax, ay, ax - (start.x + dx), -1, -1);
672
+ break;
673
+ }
674
+ case "ne": {
675
+ const ay = start.y + start.h;
676
+ this.cropRect = this.lockedRectFromDrag(start.x, ay, start.w + dx, 1, -1);
677
+ break;
678
+ }
679
+ case "sw": {
680
+ const ax = start.x + start.w;
681
+ this.cropRect = this.lockedRectFromDrag(ax, start.y, ax - (start.x + dx), -1, 1);
682
+ break;
683
+ }
684
+ }
685
+ this.updateCropBox();
686
+ return;
687
+ }
649
688
  let { x, y, w, h } = start;
650
689
  switch (this.dragKind) {
651
690
  case "move":
@@ -692,6 +731,12 @@ var ImageEditor = class {
692
731
  document.removeEventListener("pointerup", this.onPointerUp);
693
732
  };
694
733
  }
734
+ // Locked crop ratio (width / height), or null for free-form. Derived from
735
+ // opts so there's a single source of truth.
736
+ get aspect() {
737
+ const a = this.opts.lockedAspect;
738
+ return a && a.width > 0 && a.height > 0 ? a.width / a.height : null;
739
+ }
695
740
  async open() {
696
741
  this.mount();
697
742
  try {
@@ -822,8 +867,42 @@ var ImageEditor = class {
822
867
  this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
823
868
  const dw = this.displayCanvas.width;
824
869
  const dh = this.displayCanvas.height;
825
- const inset = Math.round(Math.min(dw, dh) * 0.1);
826
- this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
870
+ if (this.aspect) {
871
+ const a = this.aspect;
872
+ let w = dw;
873
+ let h = w / a;
874
+ if (h > dh) {
875
+ h = dh;
876
+ w = h * a;
877
+ }
878
+ w = w * 0.9;
879
+ h = w / a;
880
+ if (w < MIN_CROP) {
881
+ w = MIN_CROP;
882
+ h = w / a;
883
+ }
884
+ if (h < MIN_CROP) {
885
+ h = MIN_CROP;
886
+ w = h * a;
887
+ }
888
+ if (w > dw) {
889
+ w = dw;
890
+ h = w / a;
891
+ }
892
+ if (h > dh) {
893
+ h = dh;
894
+ w = h * a;
895
+ }
896
+ this.cropRect = {
897
+ x: Math.round((dw - w) / 2),
898
+ y: Math.round((dh - h) / 2),
899
+ w,
900
+ h
901
+ };
902
+ } else {
903
+ const inset = Math.round(Math.min(dw, dh) * 0.1);
904
+ this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
905
+ }
827
906
  this.renderCropBox();
828
907
  }
829
908
  exitCropMode() {
@@ -875,6 +954,28 @@ var ImageEditor = class {
875
954
  document.addEventListener("pointermove", this.onPointerMove);
876
955
  document.addEventListener("pointerup", this.onPointerUp);
877
956
  }
957
+ // Build a ratio-locked rect from a fixed anchor corner and a desired width.
958
+ // signX/signY say whether the dragged corner sits to the right/below the
959
+ // anchor (+1) or left/above (-1). Height is always derived from width to keep
960
+ // the ratio exact; both are clamped so the rect stays on-canvas.
961
+ lockedRectFromDrag(anchorX, anchorY, desiredW, signX, signY) {
962
+ const a = this.aspect;
963
+ const maxW = this.displayCanvas.width;
964
+ const maxH = this.displayCanvas.height;
965
+ let w = Math.max(MIN_CROP, desiredW);
966
+ let h = w / a;
967
+ if (h < MIN_CROP) {
968
+ h = MIN_CROP;
969
+ w = h * a;
970
+ }
971
+ const roomX = signX > 0 ? maxW - anchorX : anchorX;
972
+ const roomY = signY > 0 ? maxH - anchorY : anchorY;
973
+ w = Math.min(w, roomX, roomY * a);
974
+ h = w / a;
975
+ const x = signX > 0 ? anchorX : anchorX - w;
976
+ const y = signY > 0 ? anchorY : anchorY - h;
977
+ return { x, y, w, h };
978
+ }
878
979
  // ---- operations (mutate working canvas) --------------------------------
879
980
  applyCrop() {
880
981
  if (!this.cropRect) {
@@ -895,6 +996,7 @@ var ImageEditor = class {
895
996
  this.working = next;
896
997
  this.exitCropMode();
897
998
  this.markEdited(true);
999
+ this.opts.onCropApplied?.();
898
1000
  this.draw();
899
1001
  }
900
1002
  applyRotate() {
@@ -910,6 +1012,7 @@ var ImageEditor = class {
910
1012
  this.working = next;
911
1013
  this.exitCropMode();
912
1014
  this.markEdited(true);
1015
+ this.opts.onAspectReset?.();
913
1016
  this.draw();
914
1017
  }
915
1018
  applyCircle() {
@@ -933,11 +1036,13 @@ var ImageEditor = class {
933
1036
  this.hasAlpha = true;
934
1037
  this.exitCropMode();
935
1038
  this.markEdited(true);
1039
+ this.opts.onAspectReset?.();
936
1040
  this.draw();
937
1041
  }
938
1042
  async revert() {
939
1043
  await this.loadTrueOriginalIntoWorking();
940
1044
  this.exitCropMode();
1045
+ this.opts.onAspectReset?.();
941
1046
  this.draw();
942
1047
  }
943
1048
  markEdited(edited) {
@@ -1041,6 +1146,12 @@ function mergeConfig(server, runtime) {
1041
1146
  const types = server.constraints.allowedMimeTypes.filter((t) => t !== "*/*");
1042
1147
  if (types.length > 0) merged.accept = types.join(",");
1043
1148
  }
1149
+ const srvImg = server.image ?? null;
1150
+ const ratio = runtime.cropAspectRatio ?? (srvImg && srvImg.cropRatioW && srvImg.cropRatioH ? { width: srvImg.cropRatioW, height: srvImg.cropRatioH } : void 0);
1151
+ merged.cropAspectRatio = ratio;
1152
+ merged.forceCrop = (runtime.forceCrop ?? srvImg?.forceCrop ?? false) && !!ratio && runtime.imageEditing !== false;
1153
+ merged.minImageWidth = runtime.minImageWidth ?? srvImg?.minWidth ?? void 0;
1154
+ merged.minImageHeight = runtime.minImageHeight ?? srvImg?.minHeight ?? void 0;
1044
1155
  return merged;
1045
1156
  }
1046
1157
  var DEFAULT_TITLE = "Upload files";
@@ -1095,6 +1206,10 @@ var Picker = class {
1095
1206
  this.editor = null;
1096
1207
  this.abortCtrl = new AbortController();
1097
1208
  this.uploadStarted = false;
1209
+ // Monotonic counter so a stale image-dimension decode (e.g. one started before
1210
+ // the user edited the file) can't clobber the row's state — only the latest
1211
+ // validation per item is allowed to mutate it.
1212
+ this.validateSeqCounter = 0;
1098
1213
  this.resolved = false;
1099
1214
  // Element focused before the modal opened, restored on close (a11y).
1100
1215
  this.previouslyFocused = null;
@@ -1142,6 +1257,7 @@ var Picker = class {
1142
1257
  Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
1143
1258
  root.style.setProperty(k, v);
1144
1259
  });
1260
+ root.style.colorScheme = this.opts.theme?.mode === "dark" ? "dark" : "light";
1145
1261
  root.addEventListener("click", (e) => {
1146
1262
  if (e.target === root && !this.uploadStarted) this.cancel();
1147
1263
  });
@@ -1513,15 +1629,13 @@ var Picker = class {
1513
1629
  };
1514
1630
  this.items.push(item);
1515
1631
  this.renderItem(item);
1632
+ if (this.shouldCheckDimensions(file)) {
1633
+ item.validating = true;
1634
+ this.validateDimensions(item);
1635
+ }
1516
1636
  }
1517
1637
  this.refreshConfirm();
1518
- const autoUpload = this.opts.autoUpload === true;
1519
- const hasQueued = this.items.some((i) => i.state === "queued");
1520
- if (autoUpload && hasQueued && !this.uploadStarted) {
1521
- requestAnimationFrame(() => {
1522
- if (!this.uploadStarted) this.startUpload();
1523
- });
1524
- }
1638
+ this.maybeAutoUpload();
1525
1639
  }
1526
1640
  renderItem(item) {
1527
1641
  if (!this.$list) return;
@@ -1554,6 +1668,9 @@ var Picker = class {
1554
1668
  const bar = el2("div", "us-file-progress-bar");
1555
1669
  progress.appendChild(bar);
1556
1670
  main.appendChild(progress);
1671
+ const error = el2("div", "us-file-error");
1672
+ if (item.error) error.textContent = item.error;
1673
+ main.appendChild(error);
1557
1674
  row.appendChild(main);
1558
1675
  const isImage = item.file.type.startsWith("image/");
1559
1676
  const editingEnabled = this.opts.imageEditing !== false;
@@ -1577,9 +1694,11 @@ var Picker = class {
1577
1694
  item.$bar = bar;
1578
1695
  item.$status = status;
1579
1696
  item.$meta = meta;
1697
+ item.$error = error;
1580
1698
  item.$thumb = thumb;
1581
1699
  this.$list.appendChild(row);
1582
1700
  this.updateSummary();
1701
+ this.updateItemBadge(item);
1583
1702
  }
1584
1703
  // Swap a queued item's file with an edited version. Refreshes thumbnail and
1585
1704
  // meta in place so the row identity is preserved (uploadId, position).
@@ -1617,15 +1736,32 @@ var Picker = class {
1617
1736
  // nothing (Cancel/Back).
1618
1737
  openEditor(item) {
1619
1738
  if (!this.$panel || this.editor || item.state !== "queued") return;
1739
+ let didRatioCrop = false;
1620
1740
  this.editor = new ImageEditor({
1621
1741
  host: this.$panel,
1622
1742
  file: item.file,
1623
1743
  originalFile: item.originalFile,
1624
1744
  title: item.originalFile.name,
1745
+ lockedAspect: this.opts.cropAspectRatio,
1746
+ onCropApplied: () => {
1747
+ didRatioCrop = true;
1748
+ },
1749
+ // Rotating/circling/reverting after a crop changes the working aspect, so
1750
+ // the image is no longer guaranteed to match the locked ratio.
1751
+ onAspectReset: () => {
1752
+ didRatioCrop = false;
1753
+ },
1625
1754
  onApply: (edited) => {
1626
1755
  const wasEdited = edited !== item.originalFile;
1627
1756
  this.replaceItemFile(item, edited, wasEdited);
1757
+ if (didRatioCrop) item.ratioCropped = true;
1628
1758
  this.editor = null;
1759
+ if (this.shouldCheckDimensions(item.file)) {
1760
+ item.validating = true;
1761
+ this.validateDimensions(item);
1762
+ }
1763
+ this.updateItemBadge(item);
1764
+ this.refreshConfirm();
1629
1765
  },
1630
1766
  onCancel: () => {
1631
1767
  this.editor = null;
@@ -1679,16 +1815,13 @@ var Picker = class {
1679
1815
  case "uploading":
1680
1816
  item.$meta.textContent = `${size} \xB7 ${Math.round(item.progress)}%`;
1681
1817
  break;
1682
- case "done":
1683
- item.$meta.textContent = size;
1684
- break;
1685
- case "failed":
1686
- item.$meta.textContent = item.error || "Failed";
1687
- break;
1688
1818
  default:
1689
1819
  item.$meta.textContent = size;
1690
1820
  }
1691
1821
  }
1822
+ if (item.$error) {
1823
+ item.$error.textContent = state === "failed" ? item.error || "Upload failed" : "";
1824
+ }
1692
1825
  this.updateSummary();
1693
1826
  }
1694
1827
  updateSummary() {
@@ -1714,10 +1847,79 @@ var Picker = class {
1714
1847
  this.$summary.textContent = `${total} file${total === 1 ? "" : "s"} ready`;
1715
1848
  }
1716
1849
  }
1850
+ // ---- image rules (force-crop + min dimensions) --------------------------
1851
+ // True when force-crop is on and this queued raster image hasn't been cropped
1852
+ // to the locked ratio yet. SVGs are exempt (vector→raster crop is lossy).
1853
+ needsCrop(item) {
1854
+ return this.opts.forceCrop === true && !!this.opts.cropAspectRatio && item.state === "queued" && item.file.type.startsWith("image/") && item.file.type !== "image/svg+xml" && !item.ratioCropped;
1855
+ }
1856
+ hasUncroppedImages() {
1857
+ return this.items.some((i) => this.needsCrop(i));
1858
+ }
1859
+ anyValidating() {
1860
+ return this.items.some((i) => i.validating === true);
1861
+ }
1862
+ // Whether a min-dimension rule applies to this file (image, non-SVG, rule set).
1863
+ shouldCheckDimensions(file) {
1864
+ const minConfigured = !!(this.opts.minImageWidth || this.opts.minImageHeight);
1865
+ return minConfigured && file.type.startsWith("image/") && file.type !== "image/svg+xml";
1866
+ }
1867
+ // Toggle the per-row "crop required" cue. Only touches the meta line for
1868
+ // queued rows (other states own their meta via setItemState).
1869
+ updateItemBadge(item) {
1870
+ if (!item.$row) return;
1871
+ if (this.needsCrop(item)) {
1872
+ item.$row.dataset.cropRequired = "true";
1873
+ if (item.$meta) item.$meta.textContent = "Crop to ratio required";
1874
+ } else {
1875
+ delete item.$row.dataset.cropRequired;
1876
+ if (item.state === "queued" && item.$meta && !item.validating) {
1877
+ item.$meta.textContent = formatBytes(item.file.size);
1878
+ }
1879
+ }
1880
+ }
1881
+ // Decode an image and fail the row if it's below the configured minimum.
1882
+ // Non-decodable inputs are left queued — we can't prove they're too small.
1883
+ async validateDimensions(item) {
1884
+ const seq = ++this.validateSeqCounter;
1885
+ item.validateSeq = seq;
1886
+ const minW = this.opts.minImageWidth ?? 0;
1887
+ const minH = this.opts.minImageHeight ?? 0;
1888
+ const file = item.file;
1889
+ let dim = null;
1890
+ try {
1891
+ dim = await readImageSize(file);
1892
+ } catch {
1893
+ dim = null;
1894
+ }
1895
+ if (item.validateSeq !== seq) return;
1896
+ item.validating = false;
1897
+ if (item.state === "queued") {
1898
+ if (dim && dim.w > 0 && dim.h > 0 && (dim.w < minW || dim.h < minH)) {
1899
+ const need = `${minW || "?"}\xD7${minH || "?"}`;
1900
+ item.error = `Image too small \u2014 needs at least ${need}px (got ${dim.w}\xD7${dim.h})`;
1901
+ this.setItemState(item, "failed");
1902
+ } else {
1903
+ this.updateItemBadge(item);
1904
+ }
1905
+ }
1906
+ this.refreshConfirm();
1907
+ this.maybeAutoUpload();
1908
+ }
1909
+ // Fire auto-upload once nothing is blocking it (no pending validation, no
1910
+ // pending force-crops). Re-checked whenever a validation settles.
1911
+ maybeAutoUpload() {
1912
+ if (this.opts.autoUpload !== true || this.uploadStarted) return;
1913
+ if (this.anyValidating() || this.hasUncroppedImages()) return;
1914
+ if (!this.items.some((i) => i.state === "queued")) return;
1915
+ requestAnimationFrame(() => {
1916
+ if (!this.uploadStarted) this.startUpload();
1917
+ });
1918
+ }
1717
1919
  refreshConfirm() {
1718
1920
  if (!this.$confirm) return;
1719
1921
  const queued = this.items.filter((i) => i.state === "queued").length;
1720
- this.$confirm.disabled = queued === 0 || this.uploadStarted;
1922
+ this.$confirm.disabled = queued === 0 || this.uploadStarted || this.hasUncroppedImages() || this.anyValidating();
1721
1923
  }
1722
1924
  // Transient message in the actions bar (e.g. "2 files skipped — too large").
1723
1925
  flashNotice(text) {
@@ -1736,6 +1938,15 @@ var Picker = class {
1736
1938
  // ---- upload -------------------------------------------------------------
1737
1939
  async startUpload() {
1738
1940
  if (this.uploadStarted) return;
1941
+ if (this.anyValidating()) {
1942
+ this.flashNotice("Checking image dimensions\u2026");
1943
+ return;
1944
+ }
1945
+ if (this.hasUncroppedImages()) {
1946
+ this.items.forEach((i) => this.updateItemBadge(i));
1947
+ this.flashNotice("Crop required \u2014 crop every image to the set ratio before uploading.");
1948
+ return;
1949
+ }
1739
1950
  const queued = this.items.filter((i) => i.state === "queued");
1740
1951
  if (queued.length === 0) return;
1741
1952
  this.uploadStarted = true;
@@ -1891,6 +2102,34 @@ function cryptoId() {
1891
2102
  }
1892
2103
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
1893
2104
  }
2105
+ async function readImageSize(file) {
2106
+ if (typeof createImageBitmap === "function") {
2107
+ try {
2108
+ const bmp = await createImageBitmap(file);
2109
+ const w = bmp.width, h = bmp.height;
2110
+ bmp.close?.();
2111
+ return { w, h };
2112
+ } catch {
2113
+ }
2114
+ }
2115
+ return new Promise((resolve, reject) => {
2116
+ if (typeof URL === "undefined" || !URL.createObjectURL) {
2117
+ reject(new Error("no object URL support"));
2118
+ return;
2119
+ }
2120
+ const url = URL.createObjectURL(file);
2121
+ const img = new Image();
2122
+ img.onload = () => {
2123
+ URL.revokeObjectURL(url);
2124
+ resolve({ w: img.naturalWidth, h: img.naturalHeight });
2125
+ };
2126
+ img.onerror = () => {
2127
+ URL.revokeObjectURL(url);
2128
+ reject(new Error("decode failed"));
2129
+ };
2130
+ img.src = url;
2131
+ });
2132
+ }
1894
2133
  async function fetchUrlAsFile(url) {
1895
2134
  let res;
1896
2135
  try {