dsh-cool-theme 0.5.0 → 0.6.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/lib/client.js CHANGED
@@ -40,6 +40,61 @@ window.__ModuleLoader__.load({
40
40
  const CUSTOM_ACTIVE_STORAGE_KEY = "cool-theme-custom-active";
41
41
  const CUSTOM_MIGRATED_STORAGE_KEY = "cool-theme-custom-migrated";
42
42
  const THEME_API_PATH_THEMES = `/cool-theme/api/themes`;
43
+ const THEME_ARCHIVE_EXTENSION = ".zip";
44
+ /**
45
+ * Import bounds. A theme is a small JSON document, so anything past these caps
46
+ * is refused before it is parsed rather than after it has been held in memory.
47
+ */
48
+ const THEME_ARCHIVE_MAX_BYTES = 8388608;
49
+ /** Route segments below {@link THEME_API_PATH_THEMES}: `/<id>/export`, `/import`. */
50
+ const THEME_API_SEGMENT_EXPORT = "export";
51
+ const THEME_API_PATH_IMPORT = `${THEME_API_PATH_THEMES}/import`;
52
+ //#endregion
53
+ //#region src/presets-ids.ts
54
+ /**
55
+ * The preset ids, in one place both halves can read.
56
+ *
57
+ * `presets.ts` carries the colour maps and is client-only; the Host needs the
58
+ * same id vocabulary to tell an archive that derives from a preset we ship from
59
+ * one that does not, so the names live here and that file checks itself against
60
+ * them.
61
+ */
62
+ const PRESET_IDS = [
63
+ "aura",
64
+ "ayu",
65
+ "catppuccin",
66
+ "catppuccin-frappe",
67
+ "catppuccin-macchiato",
68
+ "cobalt2",
69
+ "cursor",
70
+ "dracula",
71
+ "dsh",
72
+ "everforest",
73
+ "flexoki",
74
+ "github",
75
+ "gruvbox",
76
+ "kanagawa",
77
+ "lucent-orng",
78
+ "material",
79
+ "matrix",
80
+ "mercury",
81
+ "monokai",
82
+ "nightowl",
83
+ "nord",
84
+ "onedark",
85
+ "opencode",
86
+ "orng",
87
+ "osaka-jade",
88
+ "palenight",
89
+ "rosepine",
90
+ "solarized",
91
+ "synthwave84",
92
+ "system",
93
+ "tokyonight",
94
+ "vercel",
95
+ "vesper",
96
+ "zenburn"
97
+ ];
43
98
  //#endregion
44
99
  //#region src/client/presets/_helpers.ts
45
100
  /**
@@ -1919,6 +1974,12 @@ window.__ModuleLoader__.load({
1919
1974
  });
1920
1975
  //#endregion
1921
1976
  //#region src/client/presets.ts
1977
+ /**
1978
+ * Every shipped preset, one entry per {@link PRESET_IDS} name. The mapped type
1979
+ * makes the two lists agree at compile time: a preset without a colour map, or a
1980
+ * name with no entry here, is a type error rather than a theme that goes missing
1981
+ * from the picker (or from an import's validation).
1982
+ */
1922
1983
  const PRESETS = {
1923
1984
  aura,
1924
1985
  ayu,
@@ -1993,7 +2054,7 @@ window.__ModuleLoader__.load({
1993
2054
  const presetOptions = [{
1994
2055
  value: "dsh",
1995
2056
  label: PRESETS.dsh.label
1996
- }, ...Object.keys(PRESETS).filter((id) => id !== "dsh").map((id) => ({
2057
+ }, ...PRESET_IDS.filter((id) => id !== "dsh").map((id) => ({
1997
2058
  value: id,
1998
2059
  label: PRESETS[id].label
1999
2060
  }))];
@@ -2665,6 +2726,69 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2665
2726
  async function removeTheme(id) {
2666
2727
  await callApi(`${THEME_API_PATH_THEMES}/${encodeURIComponent(id)}`, { method: "DELETE" });
2667
2728
  }
2729
+ /** One theme the Host stored on the client's behalf, as a stored document. */
2730
+ function storedTheme(payload) {
2731
+ const doc = isRecord(payload.theme) ? payload.theme : {};
2732
+ if (typeof doc.id !== "string" || typeof doc.name !== "string") throw new Error("the Host did not report a stored theme");
2733
+ return {
2734
+ id: doc.id,
2735
+ name: doc.name
2736
+ };
2737
+ }
2738
+ /**
2739
+ * Download one theme as an archive. The response is the archive itself rather
2740
+ * than JSON, so this is the one call that does not go through {@link callApi}.
2741
+ */
2742
+ async function exportTheme(id, name) {
2743
+ const response = await fetch(`${THEME_API_PATH_THEMES}/${encodeURIComponent(id)}/${THEME_API_SEGMENT_EXPORT}`);
2744
+ if (response.status === 404 || response.status === 405) throw new ThemeApiUnavailableError(`${String(response.status)} ${response.statusText}`);
2745
+ if (!response.ok) throw new Error(`${String(response.status)} ${response.statusText}`);
2746
+ saveBlob(await response.blob(), suggestedName(response.headers.get("content-disposition"), name));
2747
+ }
2748
+ /**
2749
+ * The archive's own filename when the Host sent one, else the theme's name. The
2750
+ * Host is the authority because only it knows the theme id behind a bad name.
2751
+ */
2752
+ function suggestedName(disposition, fallback) {
2753
+ const encoded = disposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
2754
+ if (encoded !== void 0) try {
2755
+ return decodeURIComponent(encoded);
2756
+ } catch {}
2757
+ const plain = disposition?.match(/filename="?([^";]+)"?/i)?.[1];
2758
+ if (plain !== void 0 && plain !== "") return plain;
2759
+ const name = fallback.trim() === "" ? "theme" : fallback.trim();
2760
+ return name.endsWith(".zip") ? name : `${name}${THEME_ARCHIVE_EXTENSION}`;
2761
+ }
2762
+ /** Hand one blob to the browser's own download machinery. */
2763
+ function saveBlob(blob, fileName) {
2764
+ const url = URL.createObjectURL(blob);
2765
+ try {
2766
+ const anchor = document.createElement("a");
2767
+ anchor.href = url;
2768
+ anchor.download = fileName;
2769
+ anchor.rel = "noopener";
2770
+ document.body.append(anchor);
2771
+ anchor.click();
2772
+ anchor.remove();
2773
+ } finally {
2774
+ setTimeout(() => {
2775
+ URL.revokeObjectURL(url);
2776
+ }, 0);
2777
+ }
2778
+ }
2779
+ /**
2780
+ * Upload one archive. The Host validates it, names the theme and stores it, so
2781
+ * a success means the roster already has the theme — the answer carries what to
2782
+ * report, not what the client still has to write.
2783
+ */
2784
+ async function importTheme(file) {
2785
+ if (file.size > 8388608) throw new Error(`archive is larger than ${String(Math.round(THEME_ARCHIVE_MAX_BYTES / 1048576))} MB`);
2786
+ return storedTheme(await callApi(THEME_API_PATH_IMPORT, {
2787
+ method: "POST",
2788
+ headers: { "content-type": "application/octet-stream" },
2789
+ body: file
2790
+ }));
2791
+ }
2668
2792
  /**
2669
2793
  * The themes a browser saved before files became the store, minus those the
2670
2794
  * Host already has.
@@ -2684,6 +2808,16 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2684
2808
  */
2685
2809
  /** Reserved menu value that switches to the user-defined theme. */
2686
2810
  const CUSTOM_SELECTION = CUSTOM_PRESET_ID;
2811
+ /**
2812
+ * Which message an import failure deserves. The Host answers with its own
2813
+ * reason, so the shape of that reason is what selects the localized text; an
2814
+ * unrecognised failure keeps the Host's own words rather than hiding them.
2815
+ */
2816
+ function importFailureMessage(t, error) {
2817
+ const message = error instanceof Error ? error.message : String(error);
2818
+ if (error instanceof ThemeApiUnavailableError) return t("custom.toast.unavailable");
2819
+ return t(/not a zip archive|zip central directory|zip local header|zip64|unsupported zip compression/i.test(message) ? "custom.importFailed.notArchive" : /missing theme\.json/i.test(message) ? "custom.importFailed.noDocument" : /newer than this plugin/i.test(message) ? "custom.importFailed.tooNew" : /carries media|unsupported media type|unusable media path/i.test(message) ? "custom.importFailed.media" : /larger than|too large|too many files/i.test(message) ? "custom.importFailed.tooLarge" : /not valid JSON|must be|unusable theme|did not report/i.test(message) ? "custom.importFailed.badDocument" : "custom.importFailed.other").replace("{0}", message);
2820
+ }
2687
2821
  /** Structural equality of the two seeds in one pair. */
2688
2822
  function samePair(a, b) {
2689
2823
  return a?.light === b?.light && a?.dark === b?.dark;
@@ -2909,20 +3043,125 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2909
3043
  }));
2910
3044
  }
2911
3045
  /**
3046
+ * Lucide `edit` (the pencil in a square), path data verbatim. Row actions are
3047
+ * icon-only, so the glyph has to read as the label the accessible name still
3048
+ * carries.
3049
+ */
3050
+ function IconEdit(props) {
3051
+ const { size = 14 } = props;
3052
+ return react.createElement("svg", {
3053
+ width: size,
3054
+ height: size,
3055
+ viewBox: "0 0 24 24",
3056
+ fill: "none",
3057
+ stroke: "currentColor",
3058
+ strokeWidth: 2,
3059
+ strokeLinecap: "round",
3060
+ strokeLinejoin: "round",
3061
+ xmlns: "http://www.w3.org/2000/svg",
3062
+ "aria-hidden": true,
3063
+ focusable: false
3064
+ }, react.createElement("path", { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }), react.createElement("path", { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }));
3065
+ }
3066
+ /** Lucide `trash`, path data verbatim; the row's danger action. */
3067
+ function IconTrash(props) {
3068
+ const { size = 14 } = props;
3069
+ return react.createElement("svg", {
3070
+ width: size,
3071
+ height: size,
3072
+ viewBox: "0 0 24 24",
3073
+ fill: "none",
3074
+ stroke: "currentColor",
3075
+ strokeWidth: 2,
3076
+ strokeLinecap: "round",
3077
+ strokeLinejoin: "round",
3078
+ xmlns: "http://www.w3.org/2000/svg",
3079
+ "aria-hidden": true,
3080
+ focusable: false
3081
+ }, react.createElement("path", { d: "M3 6h18" }), react.createElement("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }), react.createElement("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" }), react.createElement("line", {
3082
+ x1: 10,
3083
+ y1: 11,
3084
+ x2: 10,
3085
+ y2: 17
3086
+ }), react.createElement("line", {
3087
+ x1: 14,
3088
+ y1: 11,
3089
+ x2: 14,
3090
+ y2: 17
3091
+ }));
3092
+ }
3093
+ /** Lucide `share`, path data verbatim; sends one theme out as an archive. */
3094
+ function IconShare(props) {
3095
+ const { size = 14 } = props;
3096
+ return react.createElement("svg", {
3097
+ width: size,
3098
+ height: size,
3099
+ viewBox: "0 0 24 24",
3100
+ fill: "none",
3101
+ stroke: "currentColor",
3102
+ strokeWidth: 2,
3103
+ strokeLinecap: "round",
3104
+ strokeLinejoin: "round",
3105
+ xmlns: "http://www.w3.org/2000/svg",
3106
+ "aria-hidden": true,
3107
+ focusable: false
3108
+ }, react.createElement("path", { d: "M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" }), react.createElement("polyline", { points: "16 6 12 2 8 6" }), react.createElement("line", {
3109
+ x1: 12,
3110
+ y1: 2,
3111
+ x2: 12,
3112
+ y2: 15
3113
+ }));
3114
+ }
3115
+ /** Lucide `download`, path data verbatim; takes one archive in. */
3116
+ function IconDownload(props) {
3117
+ const { size = 14 } = props;
3118
+ return react.createElement("svg", {
3119
+ width: size,
3120
+ height: size,
3121
+ viewBox: "0 0 24 24",
3122
+ fill: "none",
3123
+ stroke: "currentColor",
3124
+ strokeWidth: 2,
3125
+ strokeLinecap: "round",
3126
+ strokeLinejoin: "round",
3127
+ xmlns: "http://www.w3.org/2000/svg",
3128
+ "aria-hidden": true,
3129
+ focusable: false
3130
+ }, react.createElement("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }), react.createElement("polyline", { points: "7 10 12 15 17 10" }), react.createElement("line", {
3131
+ x1: 12,
3132
+ y1: 15,
3133
+ x2: 12,
3134
+ y2: 3
3135
+ }));
3136
+ }
3137
+ /**
2912
3138
  * One collapsed saved-theme card: the name activates the theme, and the trailing
2913
- * controls open the editor or delete it. The card as a whole is a click target
2914
- * too, but its controls own their clicks.
3139
+ * controls export it, open the editor or delete it. The card as a whole is a
3140
+ * click target too, but its controls own their clicks.
2915
3141
  */
2916
3142
  function ThemeCard(props) {
2917
- const { entry, active, disabled, t, onActivate, onEdit, onDelete } = props;
2918
- /** One dense capsule action; `danger` is the borderless delete variant. */
2919
- const action = (label, onClick, danger) => react.createElement("button", {
3143
+ const { entry, active, disabled, t, onActivate, onExport, onEdit, onDelete } = props;
3144
+ /**
3145
+ * One square icon action; `danger` is the tinted delete variant. The glyph is
3146
+ * decorative, so the label travels as the accessible name instead of as text,
3147
+ * and as `data-tip` for the hover tooltip. `leave` marks the button on the
3148
+ * markup the outside-click guard watches (see the guard's own comment).
3149
+ */
3150
+ const action = (label, icon, onClick, variant) => react.createElement("button", {
2920
3151
  type: "button",
2921
- className: danger ? "ct-list-btn ct-list-btn-danger" : "ct-list-btn",
3152
+ className: variant === "danger" ? "ct-list-btn ct-list-btn-danger ct-tip" : "ct-list-btn ct-tip",
3153
+ "aria-label": label,
3154
+ "data-tip": label,
3155
+ ...variant === "leave" ? {
3156
+ "data-ct-leave": "true",
3157
+ "data-ct-entry": entry.id
3158
+ } : {},
2922
3159
  onClick
2923
- }, label);
3160
+ }, icon);
2924
3161
  return react.createElement("div", {
2925
3162
  className: "ct-list-row",
3163
+ "data-ct-leave": "true",
3164
+ "data-ct-entry": entry.id,
2926
3165
  onClick: disabled ? void 0 : onActivate
2927
3166
  }, react.createElement("span", { className: "ct-list-identity" }, react.createElement("button", {
2928
3167
  className: "ct-list-name",
@@ -2935,7 +3174,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2935
3174
  }, entry.name)), react.createElement("div", {
2936
3175
  className: "ct-list-actions",
2937
3176
  onClick: (e) => e.stopPropagation()
2938
- }, active ? react.createElement("span", { className: "ct-list-badge" }, t("custom.inUse")) : null, action(t("custom.edit"), onEdit), action(t("custom.delete"), onDelete, true)));
3177
+ }, active ? react.createElement("span", { className: "ct-list-badge" }, t("custom.inUse")) : null, action(t("custom.edit"), react.createElement(IconEdit, { size: 14 }), onEdit, "leave"), action(t("custom.export"), react.createElement(IconShare, { size: 14 }), onExport), action(t("custom.delete"), react.createElement(IconTrash, { size: 14 }), onDelete, "danger")));
2939
3178
  }
2940
3179
  /**
2941
3180
  * Which appearance the colour dots edit. `system` resolves through the same
@@ -2971,9 +3210,12 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2971
3210
  const [list, setList] = react.useState(() => saved.list());
2972
3211
  const [activeId, setActiveId] = react.useState(() => saved.activeId());
2973
3212
  const [pendingDelete, setPendingDelete] = react.useState(null);
3213
+ const [importing, setImporting] = react.useState(false);
3214
+ /** The hidden picker the import button opens. */
3215
+ const importInputRef = react.useRef(null);
2974
3216
  const [editing, setEditing] = react.useState(null);
2975
3217
  const [confirmClose, setConfirmClose] = react.useState(false);
2976
- const [confirmLeave, setConfirmLeave] = react.useState(false);
3218
+ const [pendingLeave, setPendingLeave] = react.useState(null);
2977
3219
  const editingRef = react.useRef(null);
2978
3220
  editingRef.current = editing;
2979
3221
  const savingRef = react.useRef(false);
@@ -2983,8 +3225,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
2983
3225
  dirtyRef.current = editing !== null && isDirty(editing, custom);
2984
3226
  const confirmCloseRef = react.useRef(false);
2985
3227
  confirmCloseRef.current = confirmClose;
2986
- const confirmLeaveRef = react.useRef(false);
2987
- confirmLeaveRef.current = confirmLeave;
3228
+ const pendingLeaveRef = react.useRef(null);
3229
+ pendingLeaveRef.current = pendingLeave;
3230
+ const listRef = react.useRef(list);
3231
+ listRef.current = list;
2988
3232
  const pendingDeleteRef = react.useRef(null);
2989
3233
  pendingDeleteRef.current = pendingDelete;
2990
3234
  const [toast, setToast] = react.useState(null);
@@ -3021,6 +3265,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3021
3265
  return null;
3022
3266
  }
3023
3267
  }
3268
+ /** Load a card: the store moves the active pointer itself, so `syncSaved`
3269
+ * re-reads the badge rather than a separate `setActive` call. */
3024
3270
  function onLoadSaved(id) {
3025
3271
  const next = saved.load(id);
3026
3272
  if (next) setCustomState(next);
@@ -3101,13 +3347,25 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3101
3347
  function settingsPanel() {
3102
3348
  return document.querySelector("[data-slot=\"settings.close\"]")?.closest("[role=\"dialog\"]") ?? null;
3103
3349
  }
3350
+ /**
3351
+ * Whether `node` sits on a nav *cell* — the buttons that switch sections —
3352
+ * rather than anywhere else in the rail. Cells are the only buttons in the
3353
+ * nav, and the shell marks the active one with `aria-current`; taking that
3354
+ * button's parent gives the cell list itself, which still matches once the
3355
+ * active cell moves to whatever was just clicked.
3356
+ */
3357
+ function isNavCell(panel, node) {
3358
+ const list = panel?.querySelector("nav button[aria-current]")?.parentElement;
3359
+ const button = node.closest("button");
3360
+ return !!list && !!button && list.contains(button);
3361
+ }
3104
3362
  function onKeyDown(e) {
3105
3363
  if (e.key !== "Escape") return;
3106
- if (confirmCloseRef.current || confirmLeaveRef.current) {
3364
+ if (confirmCloseRef.current || pendingLeaveRef.current) {
3107
3365
  e.preventDefault();
3108
3366
  e.stopPropagation();
3109
3367
  setConfirmClose(false);
3110
- setConfirmLeave(false);
3368
+ setPendingLeave(null);
3111
3369
  return;
3112
3370
  }
3113
3371
  if (!dirtyRef.current || !onRequestClose) return;
@@ -3116,7 +3374,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3116
3374
  setConfirmClose(true);
3117
3375
  }
3118
3376
  function onClick(e) {
3119
- if (!dirtyRef.current || confirmCloseRef.current || confirmLeaveRef.current || pendingDeleteRef.current) return;
3377
+ if (!dirtyRef.current || confirmCloseRef.current || pendingLeaveRef.current || pendingDeleteRef.current) return;
3120
3378
  const target = e.target instanceof Element ? e.target : null;
3121
3379
  if (!target) return;
3122
3380
  if (target.closest("[data-ct-editor]")) return;
@@ -3129,9 +3387,23 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3129
3387
  return;
3130
3388
  }
3131
3389
  if (!panel || !panel.contains(target)) return;
3390
+ if (isNavCell(panel, target)) {
3391
+ if (!onRequestClose) return;
3392
+ e.preventDefault();
3393
+ e.stopPropagation();
3394
+ setConfirmClose(true);
3395
+ return;
3396
+ }
3397
+ const leave = target.closest("[data-ct-leave]");
3398
+ if (!leave) return;
3399
+ const entry = listRef.current.find((item) => item.id === leave.getAttribute("data-ct-entry"));
3400
+ if (!entry) return;
3132
3401
  e.preventDefault();
3133
3402
  e.stopPropagation();
3134
- setConfirmLeave(true);
3403
+ setPendingLeave({
3404
+ entry,
3405
+ edit: !!target.closest("button[data-ct-leave]")
3406
+ });
3135
3407
  }
3136
3408
  document.addEventListener("keydown", onKeyDown, true);
3137
3409
  document.addEventListener("click", onClick, true);
@@ -3161,6 +3433,61 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3161
3433
  setEditing(null);
3162
3434
  syncSaved();
3163
3435
  }
3436
+ /**
3437
+ * Send one card out as an archive. Export never touches the draft or the
3438
+ * roster, so it stays available while an editor is open and needs no guard.
3439
+ */
3440
+ async function exportCard(entry) {
3441
+ try {
3442
+ await exportTheme(entry.id, entry.name);
3443
+ showToast(t("custom.toast.exported").replace("{0}", entry.name));
3444
+ } catch (error) {
3445
+ showToast(t(error instanceof ThemeApiUnavailableError ? "custom.toast.unavailable" : "custom.toast.failed"));
3446
+ }
3447
+ }
3448
+ /**
3449
+ * Take one archive in. The Host validates the archive and writes the theme it
3450
+ * carried — a theme is a document, so an import stores exactly what an export
3451
+ * packed, with the Host choosing the id and a free name. The draft is never
3452
+ * involved, so importing leaves an open editor exactly as it was.
3453
+ */
3454
+ async function importArchive(file) {
3455
+ setImporting(true);
3456
+ try {
3457
+ const imported = await importTheme(file);
3458
+ await reloadRoster();
3459
+ showToast(t("custom.toast.imported").replace("{0}", imported.name));
3460
+ } catch (error) {
3461
+ showToast(importFailureMessage(t, error));
3462
+ } finally {
3463
+ setImporting(false);
3464
+ }
3465
+ }
3466
+ /**
3467
+ * Re-read the roster after a write the store did not make. The import route is
3468
+ * the Host's own: the cached list still holds the pre-import roster, so without
3469
+ * this the new card only appears once some later mutation refreshes the cache.
3470
+ * A failed re-read is not a failed import — the theme is stored either way — so
3471
+ * the card is left to the next roster mutation rather than reported as an error.
3472
+ */
3473
+ async function reloadRoster() {
3474
+ try {
3475
+ await saved.refresh();
3476
+ syncSaved();
3477
+ } catch {}
3478
+ }
3479
+ /** Open the picker, clearing the previous choice so the same file can be re-picked. */
3480
+ function startImport() {
3481
+ const input = importInputRef.current;
3482
+ if (!input) return;
3483
+ input.value = "";
3484
+ input.click();
3485
+ }
3486
+ function onImportPicked(e) {
3487
+ const file = e.target.files?.[0];
3488
+ e.target.value = "";
3489
+ if (file) importArchive(file);
3490
+ }
3164
3491
  /** Commit the open editor: create a new entry, or update the one being edited. */
3165
3492
  async function saveEdit() {
3166
3493
  if (!editing) return;
@@ -3279,7 +3606,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3279
3606
  key
3280
3607
  }, react.createElement("div", { className: "ct-seed-label" }, label), react.createElement("div", { className: "ct-seed-dots" }, ...dots.map((d) => react.createElement("label", {
3281
3608
  key: d.key,
3282
- className: "ct-seed-dot",
3609
+ className: "ct-seed-dot ct-tip",
3283
3610
  style: { background: d.value },
3284
3611
  "data-tip": d.name
3285
3612
  }, react.createElement("input", {
@@ -3397,6 +3724,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3397
3724
  disabled: editing !== null,
3398
3725
  t,
3399
3726
  onActivate: () => onLoadSaved(entry.id),
3727
+ onExport: () => void exportCard(entry),
3400
3728
  onEdit: () => startEdit(entry),
3401
3729
  onDelete: () => setPendingDelete(entry)
3402
3730
  }));
@@ -3404,13 +3732,29 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3404
3732
  children.push(react.createElement("div", {
3405
3733
  className: "ct-list",
3406
3734
  key: "saved-list"
3407
- }, ...cards), react.createElement("button", {
3735
+ }, ...cards), react.createElement("div", {
3736
+ className: "ct-add-row",
3737
+ key: "add-row"
3738
+ }, react.createElement("button", {
3739
+ key: "import",
3740
+ type: "button",
3741
+ className: "ct-add-btn",
3742
+ disabled: importing,
3743
+ onClick: startImport
3744
+ }, react.createElement(IconDownload, null), t("custom.import")), react.createElement("button", {
3408
3745
  key: "add",
3409
3746
  type: "button",
3410
3747
  className: "ct-add-btn",
3411
3748
  disabled: editing !== null,
3412
3749
  onClick: startAdd
3413
- }, react.createElement(IconPlus, null), t("custom.add")));
3750
+ }, react.createElement(IconPlus, null), t("custom.add")), react.createElement("input", {
3751
+ key: "import-input",
3752
+ ref: importInputRef,
3753
+ type: "file",
3754
+ accept: `${THEME_ARCHIVE_EXTENSION},application/zip`,
3755
+ style: { display: "none" },
3756
+ onChange: onImportPicked
3757
+ })));
3414
3758
  }
3415
3759
  if (pendingDelete) children.push(react.createElement(ConfirmDialog, {
3416
3760
  key: "confirm-delete",
@@ -3433,17 +3777,22 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3433
3777
  },
3434
3778
  onCancel: () => setConfirmClose(false)
3435
3779
  }));
3436
- if (confirmLeave && editing) children.push(react.createElement(ConfirmDialog, {
3780
+ if (pendingLeave && editing) children.push(react.createElement(ConfirmDialog, {
3437
3781
  key: "confirm-leave",
3438
3782
  title: t("custom.unsaved.title"),
3439
3783
  description: t("custom.unsaved.leaveDesc").replace("{0}", editing.name.trim() || t("custom.defaultName")),
3440
3784
  confirmLabel: t("custom.unsaved.leaveConfirm"),
3441
3785
  cancelLabel: t("custom.unsaved.cancel"),
3442
3786
  onConfirm: () => {
3443
- setConfirmLeave(false);
3787
+ const next = pendingLeave;
3788
+ setPendingLeave(null);
3444
3789
  cancelEdit();
3790
+ if (next.entry) {
3791
+ if (next.edit) startEdit(next.entry);
3792
+ else onLoadSaved(next.entry.id);
3793
+ }
3445
3794
  },
3446
- onCancel: () => setConfirmLeave(false)
3795
+ onCancel: () => setPendingLeave(null)
3447
3796
  }));
3448
3797
  if (toast) {
3449
3798
  const onDone = () => setToast((cur) => cur?.seq === toast.seq ? null : cur);
@@ -3509,6 +3858,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3509
3858
  "custom.save": "保存",
3510
3859
  "custom.add": "添加自定义主题",
3511
3860
  "custom.edit": "编辑",
3861
+ "custom.export": "导出",
3862
+ "custom.import": "导入主题",
3512
3863
  "custom.name.placeholder": "主题名称",
3513
3864
  "custom.defaultName": "自定义主题",
3514
3865
  "custom.inUse": "使用中",
@@ -3517,6 +3868,15 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3517
3868
  "custom.toast.saved": "已保存",
3518
3869
  "custom.toast.failed": "主题文件操作失败",
3519
3870
  "custom.toast.unavailable": "主题文件接口未就绪,请重启 DSH 服务",
3871
+ "custom.toast.exported": "已导出「{0}」",
3872
+ "custom.toast.imported": "已导入「{0}」",
3873
+ "custom.importFailed.notArchive": "导入失败:不是主题压缩包",
3874
+ "custom.importFailed.noDocument": "导入失败:压缩包缺少 theme.json",
3875
+ "custom.importFailed.badDocument": "导入失败:theme.json 不是有效的主题文件",
3876
+ "custom.importFailed.tooNew": "导入失败:主题文件版本比当前插件更新",
3877
+ "custom.importFailed.media": "导入失败:主题包含媒体文件,暂不支持导入",
3878
+ "custom.importFailed.tooLarge": "导入失败:压缩包超过大小上限",
3879
+ "custom.importFailed.other": "导入失败:{0}",
3520
3880
  "custom.delete.title": "删除该自定义主题?",
3521
3881
  "custom.delete.desc": "将删除「{0}」,且无法恢复。",
3522
3882
  "custom.unsaved.title": "放弃未保存的修改?",
@@ -3571,6 +3931,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3571
3931
  "custom.save": "Save",
3572
3932
  "custom.add": "Add custom theme",
3573
3933
  "custom.edit": "Edit",
3934
+ "custom.export": "Export",
3935
+ "custom.import": "Import theme",
3574
3936
  "custom.name.placeholder": "Theme name",
3575
3937
  "custom.defaultName": "Custom theme",
3576
3938
  "custom.inUse": "In use",
@@ -3579,6 +3941,15 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3579
3941
  "custom.toast.saved": "Saved",
3580
3942
  "custom.toast.failed": "Theme file operation failed",
3581
3943
  "custom.toast.unavailable": "Theme file API not ready — restart the DSH service",
3944
+ "custom.toast.exported": "Exported \"{0}\"",
3945
+ "custom.toast.imported": "Imported \"{0}\"",
3946
+ "custom.importFailed.notArchive": "Import failed: not a theme archive",
3947
+ "custom.importFailed.noDocument": "Import failed: the archive has no theme.json",
3948
+ "custom.importFailed.badDocument": "Import failed: theme.json is not a usable theme",
3949
+ "custom.importFailed.tooNew": "Import failed: the theme file is newer than this plugin reads",
3950
+ "custom.importFailed.media": "Import failed: the theme carries media, which cannot be imported yet",
3951
+ "custom.importFailed.tooLarge": "Import failed: the archive is over the size limit",
3952
+ "custom.importFailed.other": "Import failed: {0}",
3582
3953
  "custom.delete.title": "Delete this custom theme?",
3583
3954
  "custom.delete.desc": "\"{0}\" will be removed. This cannot be undone.",
3584
3955
  "custom.unsaved.title": "Discard unsaved changes?",
@@ -3632,8 +4003,10 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3632
4003
  ".ct-seed-dot{position:relative;box-sizing:border-box;display:inline-block;flex:none;width:26px;height:26px;border:1px solid var(--dsw-alias-border-l2);border-radius:50%;cursor:pointer;}",
3633
4004
  ".ct-seed-input{position:absolute;inset:0;box-sizing:border-box;width:100%;height:100%;padding:0;border:none;background:transparent;opacity:0;cursor:pointer;}",
3634
4005
  ".ct-seed-dot:focus-within{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
3635
- ".ct-seed-dot::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 7px);left:50%;z-index:20;transform:translateX(-50%);padding:3px 8px;border-radius:6px;background:var(--dsw-alias-label-primary,#151517);color:var(--dsw-alias-bg-layer-3,#fff);font-size:11px;font-weight:400;line-height:17px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .12s;}",
3636
- ".ct-seed-dot:hover::after,.ct-seed-dot:focus-within::after{opacity:1;}",
4006
+ ".ct-tip::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);z-index:100;box-sizing:border-box;width:max-content;max-width:220px;padding:3px 7px;border-radius:8px;background:var(--dsw-alias-tooltip-bg);color:var(--dsw-static-neutral-bluish-00);font-size:13px;font-weight:400;line-height:20px;white-space:pre-line;overflow-wrap:break-word;text-align:left;pointer-events:none;opacity:0;visibility:hidden;transition:opacity 120ms ease;}",
4007
+ ".ct-seed-dot:hover::after,.ct-seed-dot:focus-within::after{opacity:1;visibility:visible;}",
4008
+ ".ct-list-btn:hover::after,.ct-list-btn:focus-visible::after{opacity:1;visibility:visible;}",
4009
+ "@media (prefers-reduced-motion: reduce){.ct-tip::after{transition:none;}}",
3637
4010
  ".ct-list{margin-top:4px;display:flex;flex-direction:column;gap:8px;}",
3638
4011
  ".ct-list-row{display:flex;align-items:center;gap:10px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:transparent;cursor:pointer;transition:border-color .16s ease,background .16s ease;}",
3639
4012
  ".ct-list-row:hover{background:var(--dsw-alias-interactive-bg-hover);}",
@@ -3642,11 +4015,16 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3642
4015
  ".ct-list-name:disabled{cursor:default;}",
3643
4016
  ".ct-list-actions{display:inline-flex;align-items:center;gap:4px;flex:none;margin-left:auto;}",
3644
4017
  ".ct-list-badge{display:inline-flex;align-items:center;height:28px;padding:0 10px;border-radius:14px;background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary));font-size:12px;line-height:18px;white-space:nowrap;}",
3645
- ".ct-list-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:14px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:12px;line-height:18px;}",
4018
+ ".ct-list-badge + .ct-list-btn{margin-left:8px;}",
4019
+ ".ct-list-btn{position:relative;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:28px;width:28px;padding:0;border:none;border-radius:14px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;line-height:0;}",
3646
4020
  ".ct-list-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
4021
+ ".ct-list-btn:active{background:var(--dsw-alias-interactive-bg-active);}",
3647
4022
  ".ct-list-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
3648
- ".ct-list-btn-danger{border:none;color:var(--dsw-alias-state-error-primary);}",
4023
+ ".ct-list-btn:disabled{opacity:.4;cursor:not-allowed;}",
4024
+ ".ct-list-btn:disabled:hover{background:transparent;}",
4025
+ ".ct-list-btn-danger{color:var(--dsw-alias-state-error-primary);}",
3649
4026
  ".ct-list-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);}",
4027
+ ".ct-list-btn-danger:active{background:var(--dsw-alias-interactive-bg-hover-danger);}",
3650
4028
  ".ct-editor{padding:16px;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:transparent;display:flex;flex-direction:column;gap:0;}",
3651
4029
  ".ct-editor-head{display:flex;align-items:center;gap:10px;padding-bottom:12px;}",
3652
4030
  ".ct-editor-name{flex:1 1 auto;min-width:0;box-sizing:border-box;height:36px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;}",
@@ -3663,6 +4041,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3663
4041
  ".ct-add-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}",
3664
4042
  ".ct-add-btn:disabled{opacity:.4;cursor:default;}",
3665
4043
  ".ct-add-btn:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3);}",
4044
+ ".ct-add-row{display:flex;align-items:stretch;gap:8px;margin-top:8px;}",
4045
+ ".ct-add-row .ct-add-btn{flex:1 1 0;width:auto;margin-top:0;min-width:0;}",
3666
4046
  ".ct-modal-root{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px;}",
3667
4047
  ".ct-modal-mask{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.24));backdrop-filter:var(--dsw-mask-blur,blur(2px));}",
3668
4048
  ".ct-modal-card{position:relative;z-index:1;display:flex;flex-direction:column;gap:12px;width:min(380px,100%);padding:24px;border-radius:24px;background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);}",
@@ -3778,11 +4158,20 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3778
4158
  lastPreset = base;
3779
4159
  writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(base, loadCustom().theme));
3780
4160
  }
4161
+ /**
4162
+ * Install a palette. The override layer for our source is replaced in place
4163
+ * rather than torn down first: `theme.overrideTokens` restacks the same source
4164
+ * atomically, while disposing the old layer emits an extra `theme/change` with
4165
+ * our variables missing. The shell's presenter answers that by removing every
4166
+ * token from `body`, so the whole page falls back to the base palette and
4167
+ * repaints before the new layer lands — twice the DOM work and a visible
4168
+ * flash per change, which is exactly what a dragged colour slider produces.
4169
+ */
3781
4170
  function apply(src) {
3782
- overrideDispose = release(overrideDispose);
3783
- fallbackDispose = release(fallbackDispose);
3784
4171
  const target = src === CUSTOM_SELECTION ? buildCustomPreset(loadCustom().theme) : src;
3785
4172
  if (typeof target === "string" && NOOP_PRESET_IDS.has(target)) {
4173
+ overrideDispose = release(overrideDispose);
4174
+ fallbackDispose = release(fallbackDispose);
3786
4175
  ensureBaseline(false);
3787
4176
  return;
3788
4177
  }
@@ -3790,23 +4179,76 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3790
4179
  const overrides = buildOverrides(target);
3791
4180
  if (theme?.overrideTokens) try {
3792
4181
  overrideDispose = theme.overrideTokens("dsh-cool-theme", overrides);
4182
+ fallbackDispose = release(fallbackDispose);
3793
4183
  return;
3794
4184
  } catch {}
4185
+ overrideDispose = release(overrideDispose);
4186
+ fallbackDispose = release(fallbackDispose);
3795
4187
  fallbackDispose = injector.insert(buildFullCssFallback(target));
3796
4188
  }
4189
+ /**
4190
+ * Coalesce a burst of palette applications into one.
4191
+ *
4192
+ * A native colour input fires `change` on every pointer move while its slider
4193
+ * is dragged. Each application rebuilds both ramps, rewrites every token on
4194
+ * `body` and forces a style flush in the shell's presenter, so applying once
4195
+ * per event is what makes the colour editor stutter. The first change lands
4196
+ * immediately and the rest are throttled to one per interval, newest request
4197
+ * winning: the palette the pointer is released on is always the one applied.
4198
+ */
4199
+ const APPLY_INTERVAL_MS = 80;
4200
+ let pendingApply = null;
4201
+ let applyTimer = null;
4202
+ let lastApplyAt = 0;
4203
+ let disposed = false;
4204
+ /** Run the newest pending application now, if any. */
4205
+ function flushApply() {
4206
+ if (applyTimer !== null) {
4207
+ clearTimeout(applyTimer);
4208
+ applyTimer = null;
4209
+ }
4210
+ const run = pendingApply;
4211
+ pendingApply = null;
4212
+ if (!run) return;
4213
+ lastApplyAt = Date.now();
4214
+ run();
4215
+ }
4216
+ /** Drop a coalesced application, for a fiber that is going away. */
4217
+ function cancelApply() {
4218
+ if (applyTimer !== null) {
4219
+ clearTimeout(applyTimer);
4220
+ applyTimer = null;
4221
+ }
4222
+ pendingApply = null;
4223
+ }
4224
+ /**
4225
+ * Apply a palette, at most once per interval while a burst of edits is
4226
+ * running. The callback is deferred whole, so a dropped intermediate edit
4227
+ * never even rebuilds its ramps; only the newest one is executed.
4228
+ */
4229
+ function scheduleApply(run) {
4230
+ if (disposed) return;
4231
+ pendingApply = run;
4232
+ const elapsed = Date.now() - lastApplyAt;
4233
+ if (elapsed >= APPLY_INTERVAL_MS) {
4234
+ flushApply();
4235
+ return;
4236
+ }
4237
+ if (applyTimer === null) applyTimer = setTimeout(flushApply, APPLY_INTERVAL_MS - elapsed);
4238
+ }
3797
4239
  function setSelection(id) {
3798
4240
  writeRaw(THEME_STORAGE_KEY, id);
3799
4241
  if (id !== CUSTOM_SELECTION) lastPreset = id;
3800
- apply(id);
4242
+ scheduleApply(() => apply(id));
3801
4243
  }
3802
4244
  function setCustom(next) {
3803
4245
  persistCustom(next);
3804
- apply(buildCustomPreset(next));
4246
+ scheduleApply(() => apply(buildCustomPreset(next)));
3805
4247
  }
3806
4248
  function resetCustom() {
3807
4249
  const theme = extractSeeds(presetDef(customBase()));
3808
4250
  persistCustom(theme);
3809
- apply(buildCustomPreset(theme));
4251
+ scheduleApply(() => apply(buildCustomPreset(theme)));
3810
4252
  return theme;
3811
4253
  }
3812
4254
  const fallbackForBase = (base) => extractSeeds(presetDef(isPresetId(base) ? base : lastPreset));
@@ -3849,7 +4291,7 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3849
4291
  if (isPresetId(entry.base)) lastPreset = entry.base;
3850
4292
  writeRaw(CUSTOM_ACTIVE_STORAGE_KEY, entry.id);
3851
4293
  writeRaw(CUSTOM_THEME_STORAGE_KEY, encodeCustom(entry.base, entry.theme));
3852
- apply(buildCustomPreset(entry.theme));
4294
+ scheduleApply(() => apply(buildCustomPreset(entry.theme)));
3853
4295
  return entry.theme;
3854
4296
  }
3855
4297
  const saved = {
@@ -3911,6 +4353,8 @@ html[data-ds-dark-theme], body[data-ds-dark-theme] {
3911
4353
  }).catch(() => {});
3912
4354
  } catch {}
3913
4355
  ctx.effect(() => () => {
4356
+ disposed = true;
4357
+ cancelApply();
3914
4358
  release(pluginCssDisposer);
3915
4359
  baselineDisposer = release(baselineDisposer);
3916
4360
  overrideDispose = release(overrideDispose);