minecodex 1.2.0 → 1.2.2

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.
@@ -5,6 +5,8 @@ const libraryHeader = document.querySelector(".library-header");
5
5
  const sidebarOpenButton = document.querySelector(".sidebar-open-button");
6
6
  const scrollArea = document.querySelector(".library-scroll");
7
7
  const grid = document.querySelector(".image-grid");
8
+ const newImagesChip = document.querySelector(".new-images-chip");
9
+ const newImagesLabel = newImagesChip.querySelector(".new-images-label");
8
10
  const pageSentinel = document.querySelector(".page-sentinel");
9
11
  const empty = document.querySelector(".empty-state");
10
12
  const emptyTitle = empty.querySelector("h2");
@@ -16,7 +18,8 @@ const filterLabel = document.querySelector(".filter-label");
16
18
  const filterCount = document.querySelector(".filter-count");
17
19
  const sourceMenu = document.querySelector(".source-menu");
18
20
  const densityButtonValue = document.querySelector(".density-button-value");
19
- const densitySlider = document.querySelector(".density-slider");
21
+ const densityTrigger = document.querySelector(".density-trigger");
22
+ const densityMenu = document.querySelector(".density-menu");
20
23
  const groupButton = document.querySelector(".group-button");
21
24
  const drawer = document.querySelector(".detail-drawer");
22
25
  const drawerImage = drawer.querySelector("img");
@@ -74,6 +77,8 @@ let activeThreadGroup = null;
74
77
  let savedBrowseState = null;
75
78
  let items = [];
76
79
  let totalItems = 0;
80
+ let feedSnapshot = null;
81
+ let pendingImageCount = 0;
77
82
  let filteredImageCount = 0;
78
83
  let pageRequest = null;
79
84
  let loadGeneration = 0;
@@ -82,6 +87,10 @@ let detailRequest = 0;
82
87
  let masonryTimer = null;
83
88
  let pendingScrollTop = null;
84
89
  let appliedHostTokens = new Set();
90
+ let imagesReady = false;
91
+ let syncPromise = null;
92
+ let hasStartedCloudSync = false;
93
+ let missingEditorTokenSignature = "";
85
94
  let densityColumns = null;
86
95
  let copyFeedbackTimer = null;
87
96
  let updateCheckTimer = null;
@@ -127,7 +136,7 @@ function applyHostTheme(payload) {
127
136
  for (const name of appliedHostTokens) root.style.removeProperty(name);
128
137
  appliedHostTokens = new Set();
129
138
  for (const [name, value] of Object.entries(payload.tokens ?? {})) {
130
- if (!name.startsWith("--") || typeof value !== "string") continue;
139
+ if (!name.startsWith("--") || typeof value !== "string" || !value.trim()) continue;
131
140
  root.style.setProperty(name, value);
132
141
  appliedHostTokens.add(name);
133
142
  }
@@ -142,6 +151,13 @@ function applyHostTheme(payload) {
142
151
  "--radius-md", "--radius-xl", "--shadow-md", "--shadow-xl",
143
152
  ];
144
153
  editorControls.hidden = !editorTokens.every((name) => appliedHostTokens.has(name));
154
+ const missingTokens = editorTokens.filter((name) => !appliedHostTokens.has(name));
155
+ const signature = missingTokens.join(",");
156
+ if (signature && signature !== missingEditorTokenSignature) {
157
+ console.warn("[Images] Native editor theme contract is incomplete:", missingTokens);
158
+ }
159
+ missingEditorTokenSignature = signature;
160
+ if (imagesReady && !hasStartedCloudSync) void refreshImages();
145
161
  }
146
162
 
147
163
  function announce(message) {
@@ -155,6 +171,7 @@ function announce(message) {
155
171
  function applySidebarState(payload) {
156
172
  const available = Boolean(payload?.available);
157
173
  const open = Boolean(payload?.open);
174
+ root.dataset.sidebarOpen = String(available && open);
158
175
  const label = typeof payload?.label === "string" && payload.label.trim()
159
176
  ? payload.label.trim()
160
177
  : t("Show sidebar");
@@ -310,6 +327,7 @@ function moveSourceOption(delta) {
310
327
  }
311
328
 
312
329
  function openSourceMenu({ focusIndex = null } = {}) {
330
+ closeDensityMenu();
313
331
  sourceMenu.hidden = false;
314
332
  filterButton.setAttribute("aria-expanded", "true");
315
333
  const options = sourceOptions();
@@ -332,22 +350,17 @@ async function loadFacets() {
332
350
  renderSourceMenu();
333
351
  }
334
352
 
335
- function recommendedColumnsForWidth(width) {
336
- if (width < 920) return 2;
337
- if (width < 1900) return 3;
338
- if (width < 2500) return 4;
339
- if (width < 3150) return 5;
340
- return 6;
341
- }
342
353
 
343
354
  function applyDensity() {
344
355
  if (densityColumns === null) {
345
- densityColumns = recommendedColumnsForWidth(scrollArea.clientWidth || window.innerWidth);
356
+ densityColumns = 4;
346
357
  }
347
358
  grid.style.setProperty("--grid-columns", String(densityColumns));
348
- densitySlider.value = String(densityColumns);
359
+ densityMenu.querySelectorAll("[data-columns]").forEach((option) => {
360
+ option.setAttribute("aria-selected", String(Number(option.dataset.columns) === densityColumns));
361
+ });
349
362
  densityButtonValue.textContent = String(densityColumns);
350
- densitySlider.setAttribute("aria-valuetext", `${densityColumns} columns`);
363
+
351
364
  scheduleMasonry();
352
365
  }
353
366
 
@@ -393,6 +406,7 @@ function scheduleMasonry() {
393
406
  function createCard(image) {
394
407
  const button = document.createElement("button");
395
408
  button.className = "image-card";
409
+ button.classList.toggle("is-wide", image.width >= image.height * 2 && image.height > 0);
396
410
  button.type = "button";
397
411
  button.dataset.imageId = image.id;
398
412
  const createdAt = dateFormatter.format(new Date(image.createdAt));
@@ -408,7 +422,10 @@ function createCard(image) {
408
422
  preview.width = image.width;
409
423
  preview.height = image.height;
410
424
  }
411
- preview.addEventListener("load", scheduleMasonry, { once: true });
425
+ preview.addEventListener("load", () => {
426
+ button.classList.toggle("is-wide", preview.naturalWidth >= preview.naturalHeight * 2 && preview.naturalHeight > 0);
427
+ scheduleMasonry();
428
+ }, { once: true });
412
429
 
413
430
  const caption = document.createElement("span");
414
431
  caption.className = "card-caption";
@@ -518,8 +535,9 @@ function render() {
518
535
  scheduleMasonry();
519
536
  }
520
537
 
521
- function pageUrl({ offset, limit = PAGE_SIZE } = {}) {
538
+ function pageUrl({ offset, limit = PAGE_SIZE, snapshot = feedSnapshot } = {}) {
522
539
  const parameters = new URLSearchParams({ limit: String(limit), offset: String(offset ?? 0) });
540
+ if (snapshot !== null) parameters.set("snapshot", String(snapshot));
523
541
  if (viewContext === "thread") {
524
542
  parameters.set("threadId", activeThreadGroup.threadId);
525
543
  parameters.set("sort", "asc");
@@ -537,7 +555,7 @@ async function loadPage({ reset = false, preserveScroll = false } = {}) {
537
555
  const offset = reset ? 0 : items.length;
538
556
  const preservedScrollTop = reset && preserveScroll ? scrollArea.scrollTop : null;
539
557
  const request = (async () => {
540
- const response = await fetch(pageUrl({ offset }));
558
+ const response = await fetch(pageUrl({ offset, snapshot: reset ? null : feedSnapshot }));
541
559
  if (!response.ok) throw new Error(t("Could not load images"));
542
560
  const payload = await response.json();
543
561
  if (generation !== loadGeneration) return;
@@ -547,6 +565,8 @@ async function loadPage({ reset = false, preserveScroll = false } = {}) {
547
565
  filteredImageCount = payload.imageCount ?? payload.count;
548
566
 
549
567
  if (reset) {
568
+ feedSnapshot = payload.snapshot;
569
+ setPendingImages(0);
550
570
  items = nextItems;
551
571
  if (preservedScrollTop === null) scrollArea.scrollTop = 0;
552
572
  else pendingScrollTop = preservedScrollTop;
@@ -590,25 +610,42 @@ async function toggleThreadGrouping() {
590
610
  await loadPage({ reset: true });
591
611
  }
592
612
 
613
+ function setPendingImages(count) {
614
+ const changed = pendingImageCount !== count;
615
+ pendingImageCount = count;
616
+ newImagesChip.hidden = count === 0;
617
+ newImagesLabel.textContent = t("{count} new images", { count });
618
+ if (changed && count > 0) announce(newImagesLabel.textContent);
619
+ }
620
+
593
621
  async function checkForUpdates() {
594
- if (!canPollForUpdates()) return;
595
- const response = await fetch(pageUrl({ offset: 0, limit: 1 }));
622
+ if (viewContext !== "browse" || feedSnapshot === null) return;
623
+ const generation = loadGeneration;
624
+ const response = await fetch(pageUrl({ offset: 0, limit: 1, snapshot: null }));
596
625
  if (!response.ok) throw new Error(t("Could not check for new images"));
597
626
  const payload = await response.json();
598
- const nextItems = groupByThread ? payload.groups : payload.images;
599
- const nextFirst = nextItems[0];
600
- const currentFirst = items[0];
601
- const changed = groupByThread
602
- ? payload.count !== totalItems
603
- || payload.imageCount !== filteredImageCount
604
- || nextFirst?.threadId !== currentFirst?.threadId
605
- || nextFirst?.updatedAt !== currentFirst?.updatedAt
606
- : payload.count !== totalItems || nextFirst?.id !== currentFirst?.id;
607
- if (changed) {
608
- await Promise.all([loadFacets(), loadPage({ reset: true, preserveScroll: true })]);
609
- }
627
+ if (generation !== loadGeneration) return;
628
+ setPendingImages(Math.max(0, (payload.imageCount ?? payload.count) - filteredImageCount));
610
629
  }
611
630
 
631
+ newImagesChip.addEventListener("click", async () => {
632
+ newImagesChip.disabled = true;
633
+ try {
634
+ await loadPage({ reset: true, preserveScroll: true });
635
+ await loadFacets();
636
+ // 等待瀑布流恢复高度后再滚动,避免布局回写打断动画。
637
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
638
+ pendingScrollTop = null;
639
+ scrollArea.scrollTo({ top: 0, behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth" });
640
+ grid.querySelector("button")?.focus({ preventScroll: true });
641
+ } catch (error) {
642
+ announce(t("Could not load images"));
643
+ console.error(error);
644
+ } finally {
645
+ newImagesChip.disabled = false;
646
+ }
647
+ });
648
+
612
649
  function canPollForUpdates() {
613
650
  return hostSurfaceActive
614
651
  && documentVisible
@@ -652,6 +689,7 @@ function setHostSurfaceActive(active) {
652
689
  cancelActiveCommentEditor();
653
690
  }
654
691
  refreshUpdatePolling({ immediate: changed && hostSurfaceActive });
692
+ if (changed && hostSurfaceActive && imagesReady && root.dataset.host === "codex") void refreshImages();
655
693
  }
656
694
 
657
695
  function setDocumentVisible(visible) {
@@ -820,6 +858,9 @@ function showImageDetails(image) {
820
858
  drawerDate.textContent = dateFormatter.format(new Date(image.createdAt));
821
859
  drawerSize.textContent = image.width && image.height ? `${image.width} × ${image.height}` : t("Original");
822
860
  drawerFileSize.textContent = formatBytes(image.byteLength);
861
+ const promptHeading = drawer.querySelector(".detail-section-heading h3");
862
+ promptHeading.dataset.i18n = image.promptKind === "request" ? "Original request" : "Generation prompt";
863
+ promptHeading.textContent = t(promptHeading.dataset.i18n);
823
864
  drawerPrompt.textContent = image.prompt || t("Prompt is not available for this image.");
824
865
  drawerPrompt.classList.toggle("is-missing", !image.prompt);
825
866
  promptCopyButton.disabled = !image.prompt;
@@ -867,6 +908,8 @@ async function openThreadGroup(group, originatingElement = null) {
867
908
  items: items.slice(),
868
909
  totalItems,
869
910
  filteredImageCount,
911
+ feedSnapshot,
912
+ pendingImageCount,
870
913
  scrollTop: scrollArea.scrollTop,
871
914
  nodes: [...grid.children],
872
915
  };
@@ -899,6 +942,8 @@ function leaveThreadGroup() {
899
942
  items = restore.items;
900
943
  totalItems = restore.totalItems;
901
944
  filteredImageCount = restore.filteredImageCount;
945
+ feedSnapshot = restore.feedSnapshot;
946
+ setPendingImages(restore.pendingImageCount);
902
947
  pendingScrollTop = restore.scrollTop;
903
948
  grid.replaceChildren(...restore.nodes);
904
949
  grid.classList.toggle("is-grouped", groupByThread);
@@ -991,14 +1036,49 @@ sourceMenu.addEventListener("keydown", (event) => {
991
1036
  }
992
1037
  });
993
1038
 
994
- densitySlider.addEventListener("input", () => {
995
- densityColumns = Number.parseInt(densitySlider.value, 10);
996
- try {
997
- localStorage.setItem(DENSITY_STORAGE_KEY, String(densityColumns));
998
- } catch {
999
- // The selected density still applies for the current window.
1039
+ function closeDensityMenu(restoreFocus = false) {
1040
+ densityMenu.hidden = true;
1041
+ densityTrigger.setAttribute("aria-expanded", "false");
1042
+ if (restoreFocus) densityTrigger.focus();
1043
+ }
1044
+
1045
+ function openDensityMenu() {
1046
+ closeSourceMenu();
1047
+ densityMenu.hidden = false;
1048
+ densityTrigger.setAttribute("aria-expanded", "true");
1049
+ densityMenu.querySelector('[aria-selected="true"]')?.focus();
1050
+ }
1051
+
1052
+ densityTrigger.addEventListener("click", () => {
1053
+ if (densityMenu.hidden) openDensityMenu();
1054
+ else closeDensityMenu();
1055
+ });
1056
+ densityTrigger.addEventListener("keydown", (event) => {
1057
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1058
+ event.preventDefault();
1059
+ openDensityMenu();
1000
1060
  }
1061
+ });
1062
+ densityMenu.addEventListener("click", (event) => {
1063
+ const option = event.target.closest("[data-columns]");
1064
+ if (!option) return;
1065
+ densityColumns = Number(option.dataset.columns);
1066
+ try { localStorage.setItem(DENSITY_STORAGE_KEY, String(densityColumns)); } catch {}
1001
1067
  applyDensity();
1068
+ closeDensityMenu(true);
1069
+ });
1070
+ densityMenu.addEventListener("keydown", (event) => {
1071
+ const options = [...densityMenu.querySelectorAll("[data-columns]")];
1072
+ const index = options.indexOf(document.activeElement);
1073
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
1074
+ event.preventDefault();
1075
+ const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1
1076
+ : (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length;
1077
+ options[next].focus();
1078
+ } else if (event.key === "Escape") {
1079
+ event.preventDefault();
1080
+ closeDensityMenu(true);
1081
+ } else if (event.key === "Tab") closeDensityMenu(true);
1002
1082
  });
1003
1083
 
1004
1084
  groupButton.addEventListener("click", () => toggleThreadGrouping().catch(console.error));
@@ -1016,42 +1096,72 @@ sidebarOpenButton.addEventListener("click", async () => {
1016
1096
 
1017
1097
  document.addEventListener("click", (event) => {
1018
1098
  if (!event.target.closest(".source-filter")) closeToolbarPopups();
1099
+ if (!event.target.closest(".density-control")) closeDensityMenu();
1019
1100
  });
1020
1101
 
1021
- rescan.addEventListener("click", async () => {
1102
+ async function refreshImages() {
1103
+ if (syncPromise) return syncPromise;
1104
+ hasStartedCloudSync = true;
1022
1105
  rescan.disabled = true;
1023
1106
  rescan.classList.add("is-loading");
1024
1107
  rescan.setAttribute("aria-busy", "true");
1025
- try {
1026
- await Promise.all([
1027
- (async () => {
1028
- const response = await fetch("/api/rescan", { method: "POST" });
1029
- if (!response.ok) throw new Error(t("Could not refresh images"));
1030
- const result = await response.json();
1031
- announce(result.inProgress ? t("Image refresh is already in progress.") : t("Images refreshed."));
1032
- await loadFacets();
1033
- if (shell.classList.contains("drawer-open") && activeImage) {
1034
- const detailResponse = await fetch(`/api/images/${activeImage.id}`);
1035
- if (detailResponse.ok) {
1036
- const payload = await detailResponse.json();
1037
- activeImage = payload.image;
1038
- updateSourceAction(activeImage);
1108
+ syncPromise = (async () => {
1109
+ let imported = 0, failures = 0;
1110
+ const updateGallery = async () => {
1111
+ await checkForUpdates();
1112
+ };
1113
+ try {
1114
+ const local = await fetch("/api/rescan", { method: "POST" });
1115
+ if (!local.ok) throw new Error(t("Could not refresh images"));
1116
+ await updateGallery();
1117
+ if (root.dataset.host === "codex") {
1118
+ const cachedResponse = await fetch("/api/cloud-image-cache");
1119
+ if (!cachedResponse.ok) throw new Error(t("Could not refresh images"));
1120
+ const cached = new Map((await cachedResponse.json()).items.map(item => [item.libraryId, item.version]));
1121
+ let cursor = null;
1122
+ const cursors = new Set();
1123
+ do {
1124
+ rescan.title = t("Syncing cloud images… {count} added", { count: imported });
1125
+ const page = await requestHostAction("list-cloud-images", { cursor }, 90_000);
1126
+ for (const item of page.items) {
1127
+ if (cached.get(item.libraryId) === item.version) continue;
1128
+ try {
1129
+ const result = await requestHostAction("import-cloud-image", { libraryId: item.libraryId }, 120_000);
1130
+ if (result.imported) {
1131
+ imported += 1;
1132
+ if (imported % 5 === 0) await updateGallery();
1133
+ }
1134
+ } catch (error) {
1135
+ failures += 1;
1136
+ console.warn("[Images] Cloud image could not be synced", item.libraryId, error.message);
1137
+ }
1138
+ rescan.title = t("Syncing cloud images… {count} added", { count: imported });
1039
1139
  }
1040
- } else if (viewContext === "browse") {
1041
- await loadPage({ reset: true, preserveScroll: true });
1042
- }
1043
- })(),
1044
- new Promise((resolve) => setTimeout(resolve, 1000)),
1045
- ]);
1046
- } catch (error) {
1047
- announce(error.message);
1048
- console.error(error);
1049
- } finally {
1050
- rescan.disabled = false;
1051
- rescan.classList.remove("is-loading");
1052
- rescan.removeAttribute("aria-busy");
1053
- }
1054
- });
1140
+ await updateGallery();
1141
+ cursor = page.cursor;
1142
+ if (cursor && cursors.has(cursor)) throw new Error("Cloud Library returned a repeated cursor");
1143
+ if (cursor) cursors.add(cursor);
1144
+ } while (cursor);
1145
+ }
1146
+ const status = failures
1147
+ ? t("Cloud sync finished: {count} added, {failed} failed. Refresh to retry.", { count: imported, failed: failures })
1148
+ : t("Images refreshed.");
1149
+ rescan.title = status;
1150
+ announce(status);
1151
+ } catch (error) {
1152
+ rescan.title = t("Cloud sync unavailable. Local images are still available.");
1153
+ announce(rescan.title);
1154
+ console.error(error);
1155
+ } finally {
1156
+ rescan.disabled = false;
1157
+ rescan.classList.remove("is-loading");
1158
+ rescan.removeAttribute("aria-busy");
1159
+ syncPromise = null;
1160
+ }
1161
+ })();
1162
+ return syncPromise;
1163
+ }
1164
+ rescan.addEventListener("click", () => void refreshImages());
1055
1165
 
1056
1166
  drawer.querySelector(".drawer-close").addEventListener("click", closeDrawer);
1057
1167
  sourceButton.addEventListener("click", () => {
@@ -1178,14 +1288,14 @@ function resetZoom() {
1178
1288
  zoomTriggerValue.textContent = `${fitZoomPercent()}%`;
1179
1289
  }
1180
1290
 
1181
- function requestHostAction(action, payload) {
1291
+ function requestHostAction(action, payload, timeoutMs = 30_000) {
1182
1292
  if (window.parent === window) return Promise.reject(new Error(t("Image editing is available inside Codex only.")));
1183
1293
  const requestId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
1184
1294
  return new Promise((resolve, reject) => {
1185
1295
  const timeout = setTimeout(() => {
1186
1296
  window.removeEventListener("message", onResult);
1187
1297
  reject(new Error(t("Codex did not respond to the image edit request.")));
1188
- }, 30_000);
1298
+ }, timeoutMs);
1189
1299
  function onResult(event) {
1190
1300
  if (event.source !== window.parent || event.data?.type !== "codex-personal:host-result" || event.data?.requestId !== requestId) return;
1191
1301
  clearTimeout(timeout);
@@ -1641,9 +1751,12 @@ applyDensity();
1641
1751
  setLocale(navigator.language);
1642
1752
  translateDocument(document);
1643
1753
  await Promise.all([loadFacets(), loadPage({ reset: true })]);
1754
+ imagesReady = true;
1755
+ if (root.dataset.host === "codex") void refreshImages();
1644
1756
  await new Promise((resolve) => setTimeout(resolve, 0));
1645
1757
 
1646
1758
  new ResizeObserver(() => {
1759
+ root.style.setProperty("--library-scrollbar-width", `${scrollArea.offsetWidth - scrollArea.clientWidth}px`);
1647
1760
  scheduleMasonry();
1648
1761
  }).observe(scrollArea);
1649
1762
 
@@ -10,6 +10,11 @@ export function setLocale(value) {
10
10
  return true;
11
11
  }
12
12
  const zh = {
13
+ "{count} new images": "{count} 张新图片",
14
+ "Original request": "原始请求",
15
+ "Syncing cloud images… {count} added": "正在同步云端图片…已新增 {count} 张",
16
+ "Cloud sync finished: {count} added, {failed} failed. Refresh to retry.": "云端同步完成:新增 {count} 张,{failed} 张失败,可刷新重试。",
17
+ "Cloud sync unavailable. Local images are still available.": "云端同步暂不可用,本地图片仍可浏览。",
13
18
  "Images": "图片",
14
19
  "Images · Codex": "图片 · Codex",
15
20
  "Show sidebar": "显示侧边栏",
@@ -18,20 +18,8 @@
18
18
  </button>
19
19
  <h1 data-i18n="Images">Images</h1>
20
20
  </div>
21
- <button class="icon-button rescan-button" type="button" aria-label="Refresh images" data-i18n-aria-label="Refresh images" title="Refresh images" data-i18n-title="Refresh images">
22
- <svg class="lucide refresh-icon" viewBox="0 0 24 24" aria-hidden="true">
23
- <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
24
- <path d="M21 3v5h-5"></path>
25
- <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
26
- <path d="M8 16H3v5"></path>
27
- </svg>
28
- <svg class="lucide loading-ring-icon" viewBox="0 0 24 24" aria-hidden="true">
29
- <path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
30
- </svg>
31
- </button>
32
- </header>
33
-
34
21
  <nav class="library-toolbar" aria-label="Image library controls" data-i18n-aria-label="Image library controls">
22
+ <div class="toolbar-actions">
35
23
  <div class="source-filter">
36
24
  <button
37
25
  class="toolbar-button source-filter-button"
@@ -50,24 +38,24 @@
50
38
  <div class="source-menu" id="source-menu" role="listbox" aria-label="Filter images by source" data-i18n-aria-label="Filter images by source" hidden></div>
51
39
  </div>
52
40
 
53
- <div class="toolbar-actions">
54
41
  <div class="density-control" title="Grid columns" data-i18n-title="Grid columns">
42
+ <button class="toolbar-button density-trigger" type="button" aria-label="Grid columns" data-i18n-aria-label="Grid columns" aria-haspopup="listbox" aria-controls="density-menu" aria-expanded="false">
55
43
  <svg class="lucide density-icon" viewBox="0 0 24 24" aria-hidden="true">
56
44
  <rect width="7" height="7" x="3" y="3" rx="1"></rect>
57
45
  <rect width="7" height="7" x="14" y="3" rx="1"></rect>
58
46
  <rect width="7" height="7" x="3" y="14" rx="1"></rect>
59
47
  <rect width="7" height="7" x="14" y="14" rx="1"></rect>
60
48
  </svg>
61
- <input
62
- class="density-slider"
63
- type="range"
64
- min="2"
65
- max="6"
66
- step="1"
67
- value="3"
68
- aria-label="Grid columns" data-i18n-aria-label="Grid columns"
69
- />
70
- <output class="density-button-value" aria-live="polite">3</output>
49
+ <span class="density-button-value">4</span>
50
+ <svg class="lucide filter-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
51
+ </button>
52
+ <div class="source-menu density-menu" id="density-menu" role="listbox" aria-label="Grid columns" data-i18n-aria-label="Grid columns" hidden>
53
+ <button class="source-option" type="button" role="option" data-columns="2" aria-selected="false" tabindex="-1">2</button>
54
+ <button class="source-option" type="button" role="option" data-columns="3" aria-selected="false" tabindex="-1">3</button>
55
+ <button class="source-option" type="button" role="option" data-columns="4" aria-selected="true" tabindex="-1">4</button>
56
+ <button class="source-option" type="button" role="option" data-columns="5" aria-selected="false" tabindex="-1">5</button>
57
+ <button class="source-option" type="button" role="option" data-columns="6" aria-selected="false" tabindex="-1">6</button>
58
+ </div>
71
59
  </div>
72
60
 
73
61
  <button
@@ -85,7 +73,23 @@
85
73
  </button>
86
74
  </div>
87
75
  </nav>
76
+ <button class="icon-button rescan-button" type="button" aria-label="Refresh images" data-i18n-aria-label="Refresh images" title="Refresh images" data-i18n-title="Refresh images">
77
+ <svg class="lucide refresh-icon" viewBox="0 0 24 24" aria-hidden="true">
78
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
79
+ <path d="M21 3v5h-5"></path>
80
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path>
81
+ <path d="M8 16H3v5"></path>
82
+ </svg>
83
+ <svg class="lucide loading-ring-icon" viewBox="0 0 24 24" aria-hidden="true">
84
+ <path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
85
+ </svg>
86
+ </button>
87
+ </header>
88
88
 
89
+
90
+
91
+ <div class="library-feed">
92
+ <button class="new-images-chip" type="button" hidden><span class="new-images-arrow" aria-hidden="true">↑</span><span class="new-images-label"></span></button>
89
93
  <div class="library-scroll">
90
94
  <section class="empty-state" hidden>
91
95
  <h2 data-i18n="No images yet">No images yet</h2>
@@ -95,6 +99,7 @@
95
99
  <section class="image-grid" aria-label="Generated images" data-i18n-aria-label="Generated images"></section>
96
100
  <div class="page-sentinel" aria-hidden="true" hidden></div>
97
101
  </div>
102
+ </div>
98
103
  </section>
99
104
 
100
105
  <div class="status-announcer" role="status" aria-live="polite" aria-atomic="true"></div>