@streamscloud/kit 0.85.1 → 0.87.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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/core/files/file-decorators.d.ts +3 -0
  3. package/dist/core/files/file-decorators.js +2 -0
  4. package/dist/core/files/file-validation-rules.d.ts +3 -8
  5. package/dist/core/files/file-with-blob-data-helper.d.ts +1 -1
  6. package/dist/core/files/file-with-blob-data-helper.js +1 -1
  7. package/dist/core/files/image-resizer.d.ts +17 -29
  8. package/dist/core/files/image-resizer.js +30 -57
  9. package/dist/core/files/index.d.ts +4 -4
  10. package/dist/core/files/index.js +2 -2
  11. package/dist/core/files/types.d.ts +14 -2
  12. package/dist/core/files/types.js +1 -10
  13. package/dist/core/files/upload-media-store.svelte.d.ts +11 -17
  14. package/dist/core/files/upload-media-store.svelte.js +32 -35
  15. package/dist/styles/_mixins.scss +71 -17
  16. package/dist/styles/reset.css +19 -12
  17. package/dist/ui/ai-panel/cmp.ai-panel.svelte +27 -0
  18. package/dist/ui/cropper/image-editor-dialog/cmp.image-editor-dialog.svelte +8 -12
  19. package/dist/ui/cropper/image-editor-dialog/crop-and-resize.d.ts +6 -13
  20. package/dist/ui/cropper/image-editor-dialog/crop-and-resize.js +6 -18
  21. package/dist/ui/cropper/image-editor-dialog/open-image-editor-dialog.d.ts +6 -1
  22. package/dist/ui/cropper/image-editor-dialog/open-image-editor-dialog.js +6 -1
  23. package/dist/ui/cropper/image-editor-dialog/types.d.ts +8 -5
  24. package/dist/ui/cropper/img-cropper/img-cropper.svelte.d.ts +6 -2
  25. package/dist/ui/cropper/img-cropper/img-cropper.svelte.js +18 -11
  26. package/dist/ui/date-picker/cmp.date-time-picker.svelte +67 -8
  27. package/dist/ui/date-picker/cmp.date-time-picker.svelte.d.ts +8 -5
  28. package/dist/ui/date-picker/cmp.time-picker.svelte +3 -2
  29. package/dist/ui/date-picker/cmp.time-picker.svelte.d.ts +2 -0
  30. package/dist/ui/dialog/cmp.dialog-container.svelte +19 -12
  31. package/dist/ui/dialog/cmp.dialog.svelte +19 -12
  32. package/dist/ui/dialog/layout/cmp.stepper-dialog.svelte +54 -0
  33. package/dist/ui/emoji-picker/cmp.emoji-panel.svelte +19 -12
  34. package/dist/ui/file-uploader/cmp.upload-progress-toaster.svelte +20 -11
  35. package/dist/ui/grouped-sidebar/cmp.grouped-sidebar.svelte +20 -11
  36. package/dist/ui/navigation/cmp.nav-menu.svelte +20 -11
  37. package/dist/ui/page-layout/cmp.page-layout.svelte +38 -24
  38. package/dist/ui/page-layout/cmp.page-panel-sidebars.svelte +40 -22
  39. package/dist/ui/page-layout/cmp.page-panel.svelte +20 -11
  40. package/dist/ui/popover/cmp.popover.svelte +19 -12
  41. package/dist/ui/select/cmp.input-suggest.svelte +1 -0
  42. package/dist/ui/select/cmp.singleselect-async.svelte +1 -0
  43. package/dist/ui/select/multiselect-base.svelte +1 -0
  44. package/dist/ui/select/select-core.svelte.js +41 -11
  45. package/dist/ui/select/select-listbox.svelte +42 -19
  46. package/dist/ui/select/select-listbox.svelte.d.ts +5 -3
  47. package/dist/ui/table/table-core.svelte +19 -12
  48. package/dist/ui/textarea/cmp.textarea.svelte +20 -11
  49. package/package.json +4 -7
package/README.md CHANGED
@@ -15,8 +15,8 @@ without it agent sessions run with no hooks and no shared skills. Re-run after c
15
15
  ## Checks
16
16
 
17
17
  ```sh
18
- npm run check # svelte-check + tsc for node files
19
- npm run lint # prettier --check + eslint
18
+ npm run check # the gate: strict svelte-check + tsc for node files + eslint + prettier --check
19
+ npm run lint:fix # eslint --fix + prettier --write
20
20
  ```
21
21
 
22
22
  ## Release
@@ -0,0 +1,3 @@
1
+ import type { BlobWithName, FileWithUploadUrl } from './types';
2
+ export declare const toFileWithUploadUrl: <T extends File>(file: T, uploadUrl: string) => FileWithUploadUrl<T>;
3
+ export declare const toBlobWithName: (blob: Blob, name: string) => BlobWithName;
@@ -0,0 +1,2 @@
1
+ export const toFileWithUploadUrl = (file, uploadUrl) => Object.assign(file, { uploadUrl });
2
+ export const toBlobWithName = (blob, name) => Object.assign(blob, { name });
@@ -1,4 +1,5 @@
1
1
  import type { AspectRatioBound, FileValidationRule, VideoOrientationValue } from './file-validation-types';
2
+ import type { ResizeLimits } from './types';
2
3
  /**
3
4
  * Built-in rule factories. Each takes a constraint and an optional `message`; when omitted, the
4
5
  * rule falls back to a localized default from `FileValidationLocalization` (kit ships no English
@@ -21,10 +22,7 @@ export declare const FileRules: {
21
22
  AudioDuration: (maxSeconds: number, message?: string) => FileValidationRule;
22
23
  AudioOnly: (message?: string) => FileValidationRule;
23
24
  Extensions: (extensions: string[], message?: string) => FileValidationRule;
24
- ImageDimensions: (limits: {
25
- max1: number;
26
- max2: number;
27
- }, message?: string) => FileValidationRule;
25
+ ImageDimensions: (limits: ResizeLimits, message?: string) => FileValidationRule;
28
26
  ImageMinDimensions: (limits: {
29
27
  min1: number;
30
28
  min2: number;
@@ -55,9 +53,6 @@ export declare const FileRules: {
55
53
  VideoOrientation: (orientation: VideoOrientationValue, message?: string, options?: {
56
54
  tolerancePercent?: number;
57
55
  }) => FileValidationRule;
58
- VideoDimensions: (limits: {
59
- max1: number;
60
- max2: number;
61
- }, message?: string) => FileValidationRule;
56
+ VideoDimensions: (limits: ResizeLimits, message?: string) => FileValidationRule;
62
57
  VideoDuration: (maxSeconds: number, message?: string) => FileValidationRule;
63
58
  };
@@ -1,4 +1,4 @@
1
- import { type BlobWithName, type FileWithBlobUrl } from './types';
1
+ import type { BlobWithName, FileWithBlobUrl } from './types';
2
2
  type FileGenerationOptions = {
3
3
  id?: string;
4
4
  maxNameLength?: number;
@@ -1,7 +1,7 @@
1
1
  import { Base64Helper } from './base64-helper';
2
+ import { toBlobWithName } from './file-decorators';
2
3
  import { FileHelper } from './file-helper';
3
4
  import { fetchFile } from './file-service';
4
- import { toBlobWithName } from './types';
5
5
  import { default as mime } from 'mime';
6
6
  import { nanoid } from 'nanoid';
7
7
  export class FileWithBlobDataHelper {
@@ -1,31 +1,19 @@
1
- import type { BlobWithName } from './types';
2
- export declare const resizeImage: <T extends File>(file: T, limits: {
3
- max1: number;
4
- max2: number;
5
- }) => Promise<BlobWithName>;
1
+ import type { ResizedBlob, ResizeLimits, SizedFile } from './types';
2
+ export declare const DEFAULT_RESIZE_LIMITS: Readonly<ResizeLimits>;
3
+ type PixelSize = {
4
+ width: number;
5
+ height: number;
6
+ };
7
+ /** `size` rounded down to whole pixels, at least one per side. */
8
+ export declare const toPixelSize: (size: PixelSize) => PixelSize;
9
+ /** `size` downscaled, keeping its aspect ratio, until it fits `limits` (never enlarged), in whole pixels. */
10
+ export declare const fitSize: (size: PixelSize, limits: ResizeLimits) => PixelSize;
11
+ /** `resizeBlob` for a file: always a new `File` under the source's name, with its size. */
12
+ export declare const resizeImage: (file: File, limits: ResizeLimits) => Promise<SizedFile>;
6
13
  /**
7
- * Resizes an image while maintaining its aspect ratio, ensuring that both dimensions fit within the given constraints.
8
- *
9
- * The function takes two numeric constraints (`max1` and `max2`) and determines which one applies to the long side
10
- * and which one applies to the short side of the image.
11
- *
12
- * - If the image already fits within the constraints (both sides are within their respective limits),
13
- * it is returned unchanged.
14
- * - If both constraints are provided, the image is resized so that the long side matches its limit.
15
- * However, if this causes the short side to exceed its limit, the short side constraint is applied instead.
16
- * - If only one constraint is provided, it is applied to the longer side, and the other side is scaled proportionally.
17
- * - If both constraints are zero or negative, the original image is returned without modifications.
18
- *
19
- * @param blob - The original image as a `Blob` object.
20
- * @param limits - An object containing two numeric constraints, `max1` and `max2`.
21
- * - **`max1`** - One of the two size constraints.
22
- * - **`max2`** - The second size constraint.
23
- * **Note:** `max1` and `max2` are not tied to specific dimensions (width or height).
24
- * The function determines which one applies to the long side and which to the short side.
25
- *
26
- * @returns A promise that resolves to a new resized `Blob` object, or the original `Blob` if resizing is not needed.
14
+ * Downscales an image, keeping its aspect ratio, until both sides fit `limits` (see `ResizeLimits`).
15
+ * Only JPEG, PNG and WebP are re-encoded; an image that already fits, or of any other type (SVG, GIF, …),
16
+ * comes back as the very same `Blob`. Either way the result carries the image's pixel size.
27
17
  */
28
- export declare const resizeBlob: <T extends Blob>(blob: T, limits: {
29
- max1: number;
30
- max2: number;
31
- }) => Promise<Blob>;
18
+ export declare const resizeBlob: (blob: Blob, limits: ResizeLimits) => Promise<ResizedBlob>;
19
+ export {};
@@ -1,70 +1,40 @@
1
- let cachedCanvas = null;
2
- const getCanvas = () => (cachedCanvas ??= document.createElement('canvas'));
1
+ export const DEFAULT_RESIZE_LIMITS = Object.freeze({ max1: 1600, max2: 1000 });
2
+ const RESIZABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
3
+ // A scaled side lands a hair under a whole number (999.9999999999999); a bare floor would drop a pixel.
4
+ const PIXEL_EPSILON = 1e-6;
5
+ /** `size` rounded down to whole pixels, at least one per side. */
6
+ export const toPixelSize = (size) => ({
7
+ width: Math.max(1, Math.floor(size.width + PIXEL_EPSILON)),
8
+ height: Math.max(1, Math.floor(size.height + PIXEL_EPSILON))
9
+ });
10
+ /** `size` downscaled, keeping its aspect ratio, until it fits `limits` (never enlarged), in whole pixels. */
11
+ export const fitSize = (size, limits) => {
12
+ const [longMax, shortMax] = limits.max1 >= limits.max2 ? [limits.max1, limits.max2] : [limits.max2, limits.max1];
13
+ const scale = Math.min(1, longMax / Math.max(size.width, size.height), shortMax / Math.min(size.width, size.height));
14
+ return toPixelSize({ width: size.width * scale, height: size.height * scale });
15
+ };
16
+ /** `resizeBlob` for a file: always a new `File` under the source's name, with its size. */
3
17
  export const resizeImage = async (file, limits) => {
4
- const blob = await resizeBlob(file, limits);
5
- const result = blob;
6
- if (!(blob instanceof File)) {
7
- result.name = file.name;
8
- }
9
- return result;
18
+ const { blob, width, height } = await resizeBlob(file, limits);
19
+ return Object.assign(new File([blob], file.name, { type: blob.type }), { width, height });
10
20
  };
11
21
  /**
12
- * Resizes an image while maintaining its aspect ratio, ensuring that both dimensions fit within the given constraints.
13
- *
14
- * The function takes two numeric constraints (`max1` and `max2`) and determines which one applies to the long side
15
- * and which one applies to the short side of the image.
16
- *
17
- * - If the image already fits within the constraints (both sides are within their respective limits),
18
- * it is returned unchanged.
19
- * - If both constraints are provided, the image is resized so that the long side matches its limit.
20
- * However, if this causes the short side to exceed its limit, the short side constraint is applied instead.
21
- * - If only one constraint is provided, it is applied to the longer side, and the other side is scaled proportionally.
22
- * - If both constraints are zero or negative, the original image is returned without modifications.
23
- *
24
- * @param blob - The original image as a `Blob` object.
25
- * @param limits - An object containing two numeric constraints, `max1` and `max2`.
26
- * - **`max1`** - One of the two size constraints.
27
- * - **`max2`** - The second size constraint.
28
- * **Note:** `max1` and `max2` are not tied to specific dimensions (width or height).
29
- * The function determines which one applies to the long side and which to the short side.
30
- *
31
- * @returns A promise that resolves to a new resized `Blob` object, or the original `Blob` if resizing is not needed.
22
+ * Downscales an image, keeping its aspect ratio, until both sides fit `limits` (see `ResizeLimits`).
23
+ * Only JPEG, PNG and WebP are re-encoded; an image that already fits, or of any other type (SVG, GIF, …),
24
+ * comes back as the very same `Blob`. Either way the result carries the image's pixel size.
32
25
  */
33
26
  export const resizeBlob = (blob, limits) => {
34
- const { max1, max2 } = limits;
35
- if (max1 <= 0 && max2 <= 0) {
36
- return Promise.resolve(blob);
37
- }
38
27
  return new Promise((resolve, reject) => {
39
28
  const image = new Image();
40
29
  image.src = URL.createObjectURL(blob);
41
30
  image.onload = () => {
42
31
  const { width, height } = image;
43
- const isHorizontal = width > height;
44
- const originalLongSide = isHorizontal ? width : height;
45
- const originalShortSide = isHorizontal ? height : width;
46
- const [longSideMax, shortSideMax] = max1 >= max2 ? [max1, max2] : [max2, max1];
47
- if (longSideMax > 0 && originalLongSide <= longSideMax && shortSideMax > 0 && originalShortSide <= shortSideMax) {
48
- URL.revokeObjectURL(image.src);
49
- return resolve(blob);
50
- }
51
- let newLongSide = Math.min(originalLongSide, longSideMax);
52
- let newShortSide = originalShortSide * (newLongSide / originalLongSide);
53
- if (shortSideMax > 0 && newShortSide > shortSideMax) {
54
- const reducer = newShortSide / shortSideMax;
55
- newShortSide = shortSideMax;
56
- newLongSide = newLongSide / reducer;
57
- }
58
- newLongSide = Math.floor(newLongSide);
59
- newShortSide = Math.floor(newShortSide);
60
- // Skip resize if dimensions unchanged (avoids lossy re-encoding)
61
- if (newLongSide === originalLongSide && newShortSide === originalShortSide) {
32
+ const { width: newWidth, height: newHeight } = fitSize({ width, height }, limits);
33
+ if (!RESIZABLE_TYPES.has(blob.type) || (newWidth === width && newHeight === height)) {
62
34
  URL.revokeObjectURL(image.src);
63
- return resolve(blob);
35
+ return resolve({ blob, width, height });
64
36
  }
65
- const newWidth = isHorizontal ? newLongSide : newShortSide;
66
- const newHeight = isHorizontal ? newShortSide : newLongSide;
67
- const canvas = getCanvas();
37
+ const canvas = document.createElement('canvas');
68
38
  canvas.width = newWidth;
69
39
  canvas.height = newHeight;
70
40
  const ctx = canvas.getContext('2d');
@@ -75,9 +45,12 @@ export const resizeBlob = (blob, limits) => {
75
45
  ctx.drawImage(image, 0, 0, newWidth, newHeight);
76
46
  canvas.toBlob((resizedBlob) => {
77
47
  URL.revokeObjectURL(image.src);
78
- ctx.clearRect(0, 0, canvas.width, canvas.height);
48
+ // Safari caps total canvas memory and holds a dropped canvas until GC; shrinking it to 1×1 releases it now.
49
+ canvas.width = 1;
50
+ canvas.height = 1;
51
+ ctx.clearRect(0, 0, 1, 1);
79
52
  if (resizedBlob) {
80
- resolve(resizedBlob);
53
+ resolve({ blob: resizedBlob, width: newWidth, height: newHeight });
81
54
  }
82
55
  else {
83
56
  reject(new Error('Failed to create blob from canvas'));
@@ -1,16 +1,16 @@
1
- export type { BlobWithName, FileWithBlobUrl, FileWithUploadUrl } from './types';
1
+ export type { BlobWithName, FileWithBlobUrl, FileWithUploadUrl, ResizedBlob, ResizeLimits, SizedFile } from './types';
2
2
  export type { AspectRatioBound, FileValidationResult, FileValidationRule, FileValidationRuleSets, VideoOrientationValue } from './file-validation-types';
3
3
  export type { BlobUpload, BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile, UploadingFileStatus } from './upload-types';
4
- export type { AddOptions, UploadMediaStoreOptions } from './upload-media-store.svelte';
4
+ export type { AddOptions, UploadMediaStoreOptions, UploadOutcome } from './upload-media-store.svelte';
5
5
  export type { FileTypeCheck } from './file-helper';
6
- export { toBlobWithName, toFileWithUploadUrl } from './types';
6
+ export { toBlobWithName, toFileWithUploadUrl } from './file-decorators';
7
7
  export { uploadBlob } from './blob-storage';
8
8
  export { openFile } from './open-file';
9
9
  export { FileHelper } from './file-helper';
10
10
  export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
11
11
  export { AcceptFileType, isPreviewableType, matchesAcceptedFileTypes } from './file-types';
12
12
  export { deriveAccept } from './file-validation-rule-sets';
13
- export { resizeBlob, resizeImage } from './image-resizer';
13
+ export { DEFAULT_RESIZE_LIMITS, fitSize, resizeBlob, resizeImage, toPixelSize } from './image-resizer';
14
14
  export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
15
15
  export { FilesProvider } from './files-provider';
16
16
  export { FileValidator } from './file-validator';
@@ -1,12 +1,12 @@
1
1
  // Utilities
2
- export { toBlobWithName, toFileWithUploadUrl } from './types';
2
+ export { toBlobWithName, toFileWithUploadUrl } from './file-decorators';
3
3
  export { uploadBlob } from './blob-storage';
4
4
  export { openFile } from './open-file';
5
5
  export { FileHelper } from './file-helper';
6
6
  export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
7
7
  export { AcceptFileType, isPreviewableType, matchesAcceptedFileTypes } from './file-types';
8
8
  export { deriveAccept } from './file-validation-rule-sets';
9
- export { resizeBlob, resizeImage } from './image-resizer';
9
+ export { DEFAULT_RESIZE_LIMITS, fitSize, resizeBlob, resizeImage, toPixelSize } from './image-resizer';
10
10
  export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
11
11
  export { FilesProvider } from './files-provider';
12
12
  export { FileValidator } from './file-validator';
@@ -2,11 +2,23 @@ export type FileWithBlobUrl = File & {
2
2
  blobUrl: string;
3
3
  id: string;
4
4
  };
5
+ export type SizedFile = File & {
6
+ width: number;
7
+ height: number;
8
+ };
9
+ /** Two positive pixel limits not tied to width / height — the image's longer side fits the larger one, its shorter side the smaller. */
10
+ export type ResizeLimits = {
11
+ max1: number;
12
+ max2: number;
13
+ };
14
+ export type ResizedBlob = {
15
+ blob: Blob;
16
+ width: number;
17
+ height: number;
18
+ };
5
19
  export type FileWithUploadUrl<T extends File = File> = T & {
6
20
  uploadUrl: string;
7
21
  };
8
- export declare const toFileWithUploadUrl: <T extends File>(file: T, uploadUrl: string) => FileWithUploadUrl<T>;
9
22
  export type BlobWithName = Blob & {
10
23
  name: string;
11
24
  };
12
- export declare const toBlobWithName: (blob: Blob, name: string) => BlobWithName;
@@ -1,10 +1 @@
1
- export const toFileWithUploadUrl = (file, uploadUrl) => {
2
- const fileWithUploadUrl = file;
3
- fileWithUploadUrl.uploadUrl = uploadUrl;
4
- return fileWithUploadUrl;
5
- };
6
- export const toBlobWithName = (blob, name) => {
7
- const blobWithName = blob;
8
- blobWithName.name = name;
9
- return blobWithName;
10
- };
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import type { ResizeLimits } from './types';
1
2
  import type { BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile } from './upload-types';
2
3
  type SuccessfulUpload = Extract<UploadingFile, {
3
4
  status: 'success';
@@ -16,15 +17,8 @@ export type UploadOutcome = {
16
17
  succeeded: SuccessfulUpload[];
17
18
  };
18
19
  export type UploadMediaStoreOptions = {
19
- /**
20
- * Auto-resize images before upload. Pass `false` to disable; pass an object to override the
21
- * defaults. `max1` / `max2` are not tied to width / height — the longer side is matched to
22
- * the larger limit. @default `{ max1: 1600, max2: 1000 }`
23
- */
24
- resize?: {
25
- max1: number;
26
- max2: number;
27
- } | false;
20
+ /** Downscale images to these limits before upload; `false` uploads them as they are. @default DEFAULT_RESIZE_LIMITS (1600 / 1000) */
21
+ resize?: ResizeLimits | false;
28
22
  /** Parallel upload limit. @default 20 */
29
23
  concurrency?: number;
30
24
  on?: {
@@ -62,7 +56,7 @@ export declare class UploadMediaStore {
62
56
  private _inFlight;
63
57
  private _nextRunId;
64
58
  private _strategy;
65
- private opts;
59
+ private _opts;
66
60
  constructor(strategy: BlobUploadStrategy, opts?: UploadMediaStoreOptions);
67
61
  /** Reactive read of the queue. */
68
62
  get files(): UploadingFile[];
@@ -102,12 +96,12 @@ export declare class UploadMediaStore {
102
96
  * with no extra wiring.
103
97
  */
104
98
  upload(): Promise<UploadOutcome>;
105
- private outcome;
106
- private settled;
107
- private runQueued;
108
- private maybeResize;
109
- private fail;
110
- private failAll;
111
- private transition;
99
+ private _outcome;
100
+ private _settled;
101
+ private _runQueued;
102
+ private _maybeResize;
103
+ private _fail;
104
+ private _failAll;
105
+ private _transition;
112
106
  }
113
107
  export {};
@@ -1,10 +1,9 @@
1
1
  import { AppUploadActivity } from './app-upload-activity.svelte';
2
2
  import { uploadBlob } from './blob-storage';
3
3
  import { isPreviewableType } from './file-types';
4
- import { resizeBlob } from './image-resizer';
4
+ import { DEFAULT_RESIZE_LIMITS, resizeBlob } from './image-resizer';
5
5
  import { nanoid } from 'nanoid';
6
6
  import { default as pLimit } from 'p-limit';
7
- const DEFAULT_RESIZE_LIMITS = { max1: 1600, max2: 1000 };
8
7
  const DEFAULT_CONCURRENCY = 20;
9
8
  const toPreviewUrl = (file, mode) => mode === 'always' || (mode === 'mime' && isPreviewableType(file.type)) ? URL.createObjectURL(file) : null;
10
9
  const revokePreviewUrl = (file) => {
@@ -38,10 +37,10 @@ export class UploadMediaStore {
38
37
  _inFlight = new Map();
39
38
  _nextRunId = 0;
40
39
  _strategy;
41
- opts;
40
+ _opts;
42
41
  constructor(strategy, opts = {}) {
43
42
  this._strategy = strategy;
44
- this.opts = opts;
43
+ this._opts = opts;
45
44
  }
46
45
  /** Reactive read of the queue. */
47
46
  get files() {
@@ -89,19 +88,19 @@ export class UploadMediaStore {
89
88
  async upload() {
90
89
  const queued = this._files.filter((f) => f.status === 'queued' || f.status === 'error');
91
90
  if (queued.length === 0) {
92
- await this.settled();
93
- return this.outcome();
91
+ await this._settled();
92
+ return this._outcome();
94
93
  }
95
94
  // Claim before the first await, or a second upload() during the strategy call re-claims the same files.
96
95
  for (const entry of queued) {
97
- this.transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 0, previewUrl: f.previewUrl, status: 'uploading' }));
96
+ this._transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 0, previewUrl: f.previewUrl, status: 'uploading' }));
98
97
  }
99
98
  const fileIds = new Set(queued.map((f) => f.id));
100
99
  const activityRunId = AppUploadActivity.beginRun(() => this._files.filter((f) => fileIds.has(f.id)));
101
100
  const runId = this._nextRunId++;
102
101
  const run = (async () => {
103
102
  try {
104
- await this.runQueued(queued);
103
+ await this._runQueued(queued);
105
104
  }
106
105
  finally {
107
106
  AppUploadActivity.endRun(activityRunId);
@@ -109,43 +108,43 @@ export class UploadMediaStore {
109
108
  }
110
109
  })();
111
110
  this._inFlight.set(runId, run);
112
- await this.settled();
113
- return this.outcome();
111
+ await this._settled();
112
+ return this._outcome();
114
113
  }
115
- outcome() {
114
+ _outcome() {
116
115
  return {
117
116
  failed: this._files.filter((f) => f.status === 'error'),
118
117
  succeeded: this._files.filter((f) => f.status === 'success')
119
118
  };
120
119
  }
121
- async settled() {
120
+ async _settled() {
122
121
  while (this._inFlight.size > 0) {
123
122
  await Promise.all([...this._inFlight.values()]);
124
123
  }
125
124
  }
126
- async runQueued(queued) {
125
+ async _runQueued(queued) {
127
126
  let presigned;
128
127
  try {
129
128
  presigned = await this._strategy(queued.length);
130
129
  }
131
130
  catch (error) {
132
- this.failAll(queued, error);
131
+ this._failAll(queued, error);
133
132
  return;
134
133
  }
135
134
  if (presigned.length < queued.length) {
136
- this.failAll(queued, new Error(`BlobUploadStrategy returned ${presigned.length} slots for ${queued.length} files`));
135
+ this._failAll(queued, new Error(`BlobUploadStrategy returned ${presigned.length} slots for ${queued.length} files`));
137
136
  return;
138
137
  }
139
- const limit = pLimit(this.opts.concurrency ?? DEFAULT_CONCURRENCY);
140
- const resizeOpt = this.opts.resize;
138
+ const limit = pLimit(this._opts.concurrency ?? DEFAULT_CONCURRENCY);
139
+ const resizeOpt = this._opts.resize;
141
140
  await Promise.all(queued.map((entry, i) => limit(async () => {
142
141
  try {
143
- const blob = resizeOpt === false ? entry.file : await this.maybeResize(entry.file, resizeOpt ?? DEFAULT_RESIZE_LIMITS);
142
+ const blob = resizeOpt === false ? entry.file : await this._maybeResize(entry.file, resizeOpt ?? DEFAULT_RESIZE_LIMITS);
144
143
  const slot = presigned[i];
145
144
  await uploadBlob(slot.uploadUrl, blob, (loaded, total) => {
146
- this.transition(entry.id, (f) => (f.status === 'uploading' ? { ...f, progress: total > 0 ? loaded / total : 0 } : f));
145
+ this._transition(entry.id, (f) => (f.status === 'uploading' ? { ...f, progress: total > 0 ? loaded / total : 0 } : f));
147
146
  });
148
- const done = this.transition(entry.id, (f) => ({
147
+ const done = this._transition(entry.id, (f) => ({
149
148
  id: f.id,
150
149
  file: f.file,
151
150
  progress: 1,
@@ -155,42 +154,40 @@ export class UploadMediaStore {
155
154
  readUrl: slot.readUrl
156
155
  }));
157
156
  if (done?.status === 'success') {
158
- this.opts.on?.blobId?.(done, slot.id);
157
+ this._opts.on?.blobId?.(done, slot.id);
159
158
  }
160
159
  }
161
160
  catch (error) {
162
- this.fail(entry.id, error);
161
+ this._fail(entry.id, error);
163
162
  }
164
163
  })));
165
- this.opts.on?.done?.();
164
+ this._opts.on?.done?.();
166
165
  }
167
- async maybeResize(file, limits) {
168
- // Only resize images — leave other media alone.
169
- if (!file.type.startsWith('image/') || file.type === 'image/gif') {
166
+ async _maybeResize(file, limits) {
167
+ if (!file.type.startsWith('image/')) {
170
168
  return file;
171
169
  }
172
170
  try {
173
- return await resizeBlob(file, limits);
171
+ return (await resizeBlob(file, limits)).blob;
174
172
  }
175
173
  catch {
176
- // Resize is best-effort: fall back to the original on failure (e.g. corrupt file).
177
174
  return file;
178
175
  }
179
176
  }
180
- fail(id, error) {
177
+ _fail(id, error) {
181
178
  const message = error instanceof Error ? error.message : 'Upload failed';
182
- const failed = this.transition(id, (f) => ({ id: f.id, file: f.file, progress: f.progress, previewUrl: f.previewUrl, status: 'error', error: message }));
179
+ const failed = this._transition(id, (f) => ({ id: f.id, file: f.file, progress: f.progress, previewUrl: f.previewUrl, status: 'error', error: message }));
183
180
  if (failed?.status === 'error') {
184
- this.opts.on?.error?.(failed, error);
181
+ this._opts.on?.error?.(failed, error);
185
182
  }
186
183
  }
187
- failAll(files, error) {
184
+ _failAll(files, error) {
188
185
  for (const entry of files) {
189
- this.fail(entry.id, error);
186
+ this._fail(entry.id, error);
190
187
  }
191
- this.opts.on?.done?.();
188
+ this._opts.on?.done?.();
192
189
  }
193
- transition(id, next) {
190
+ _transition(id, next) {
194
191
  let updated;
195
192
  this._files = this._files.map((f) => {
196
193
  if (f.id !== id) {
@@ -82,14 +82,24 @@
82
82
  max-height: 100%;
83
83
  }
84
84
 
85
- @mixin scrollbar($thumb-color: var(--scrollbar--thumb-color, light-dark(#d1d5db, #4b5563)), $track-color: var(--scrollbar--track-color, transparent)) {
85
+ @mixin scrollbar(
86
+ $thumb-color: var(--scrollbar--thumb-color, light-dark(#d1d5db, #4b5563)),
87
+ $track-color: var(--scrollbar--track-color, transparent),
88
+ $size: 6px,
89
+ $symmetric: null
90
+ ) {
86
91
  --_cross-browser-scrollbar--thumb-color: #{$thumb-color};
87
92
  --_cross-browser-scrollbar--track-color: #{$track-color};
88
93
 
89
- @include _cross-browser-scrollbar;
94
+ @include _cross-browser-scrollbar($size, $symmetric);
90
95
  }
91
96
 
92
- @mixin scrollbar-on-hover($thumb-color: var(--scrollbar--thumb-color, light-dark(#d1d5db, #4b5563)), $track-color: var(--scrollbar--track-color, transparent)) {
97
+ @mixin scrollbar-on-hover(
98
+ $thumb-color: var(--scrollbar--thumb-color, light-dark(#d1d5db, #4b5563)),
99
+ $track-color: var(--scrollbar--track-color, transparent),
100
+ $size: 6px,
101
+ $symmetric: null
102
+ ) {
93
103
  --_cross-browser-scrollbar--thumb-color: transparent;
94
104
  --_cross-browser-scrollbar--track-color: transparent;
95
105
 
@@ -98,29 +108,73 @@
98
108
  --_cross-browser-scrollbar--track-color: #{$track-color};
99
109
  }
100
110
 
101
- @include _cross-browser-scrollbar;
111
+ @include _cross-browser-scrollbar($size, $symmetric);
102
112
  }
103
113
 
104
- @mixin _cross-browser-scrollbar {
105
- &::-webkit-scrollbar {
106
- width: 6px;
107
- height: 6px;
108
- }
114
+ @mixin _cross-browser-scrollbar($size, $symmetric) {
115
+ --_cross-browser-scrollbar--gutter: 0px;
109
116
 
110
- &::-webkit-scrollbar-track {
111
- background: var(--_cross-browser-scrollbar--track-color);
112
- border-radius: 100vw;
113
- }
117
+ // a non-auto scrollbar-color / scrollbar-width (own or inherited) makes WebKit / Blink drop the 6px pseudo-element bar for the wider platform one
118
+ @include when-scrollbar-sizable {
119
+ scrollbar-color: auto;
120
+ scrollbar-width: auto;
121
+
122
+ &::-webkit-scrollbar {
123
+ width: $size;
124
+ height: $size;
125
+ }
126
+
127
+ &::-webkit-scrollbar-track {
128
+ background: var(--_cross-browser-scrollbar--track-color);
129
+ border-radius: 100vw;
130
+ }
114
131
 
115
- &::-webkit-scrollbar-thumb {
116
- background: var(--_cross-browser-scrollbar--thumb-color);
117
- border-radius: 100vw;
132
+ &::-webkit-scrollbar-thumb {
133
+ background: var(--_cross-browser-scrollbar--thumb-color);
134
+ border-radius: 100vw;
135
+ }
118
136
  }
119
137
 
120
- @supports (scrollbar-color: transparent transparent) {
138
+ @include when-scrollbar-unsizable {
121
139
  scrollbar-color: var(--_cross-browser-scrollbar--thumb-color) var(--_cross-browser-scrollbar--track-color);
122
140
  scrollbar-width: thin;
123
141
  }
142
+
143
+ // not scrollbar-gutter: both-edges here — Safari reserves both gutters on the end side
144
+ @if $symmetric {
145
+ @include when-scrollbar-sizable {
146
+ --_cross-browser-scrollbar--gutter: #{$size};
147
+
148
+ overflow-y: scroll;
149
+ }
150
+ }
151
+
152
+ @if $symmetric == all {
153
+ @include when-scrollbar-unsizable {
154
+ scrollbar-gutter: stable both-edges;
155
+ }
156
+ }
157
+ }
158
+
159
+ @mixin when-scrollbar-sizable {
160
+ @supports selector(::-webkit-scrollbar) {
161
+ @content;
162
+ }
163
+ }
164
+
165
+ // room to reserve for the platform scrollbar where its width can't be set (~11px thin bar + 1px); never subtract it — overlay scrollbars take 0
166
+ $unsizable-scrollbar-allowance: 12px;
167
+
168
+ // no ::-webkit-scrollbar (Firefox): the platform bar can't shrink into a panel padding, so a box sized for the 4–6px bar clips its content unless it adds the allowance here
169
+ @mixin when-scrollbar-unsizable {
170
+ @supports not selector(::-webkit-scrollbar) {
171
+ @content;
172
+ }
173
+ }
174
+
175
+ @mixin scrollbar-padding-inline($padding) {
176
+ padding-inline-start: max(#{$padding}, var(--_cross-browser-scrollbar--gutter, 0px));
177
+ padding-inline-end: max(0px, calc(#{$padding} - var(--_cross-browser-scrollbar--gutter, 0px)));
124
178
  }
125
179
 
126
180
  @mixin fade-in-animation($duration: 5s) {