@dickpy/dsh-imagegen 1.0.2 → 1.0.4

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/index.js CHANGED
@@ -13,6 +13,8 @@ import { spawn } from "node:child_process";
13
13
  */
14
14
  /** Settings namespace this plugin owns (host settings seam + bridge). */
15
15
  const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
16
+ /** Published package version shared by the host updater and the client UI. */
17
+ const PLUGIN_VERSION = "1.0.4";
16
18
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
17
19
  const SETTINGS_API = {
18
20
  describe: "/api/dsh-imagegen/settings/describe",
@@ -438,7 +440,7 @@ async function readHistoryImage(file) {
438
440
  //#region src/updater.ts
439
441
  /** GitHub Release discovery and explicit, user-triggered plugin updates. */
440
442
  /** Keep this in sync with package.json for each published release. */
441
- const CURRENT_VERSION = "1.0.2";
443
+ const CURRENT_VERSION = PLUGIN_VERSION;
442
444
  const PACKAGE_NAME = "@dickpy/dsh-imagegen";
443
445
  const RELEASES_URL = "https://api.github.com/repos/dickpy/dsh-imagegen/releases/latest";
444
446
  const CHECK_TIMEOUT_MS = 1e4;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
3
3
  "description": "AI 生图 (image generation) plugin for the dsh web GUI: text-to-image and image-to-image through a configurable OpenAI-compatible endpoint (gpt-image-2 / gpt-image-1 / dall-e-3), with a settings card for api_url / api_key and a sidebar entry opening a split-pane generation studio.",
4
- "version": "1.0.2",
4
+ "version": "1.0.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -42,6 +42,13 @@ const DETAILS = ['', 'standard', 'high'] as const
42
42
 
43
43
  const PROMPT_MAX = 2000
44
44
  const REF_IMAGE_MAX_BYTES = 10 * 1024 * 1024
45
+ const PREVIEW_SCALE_MIN = 0.5
46
+ const PREVIEW_SCALE_MAX = 3
47
+ const PREVIEW_SCALE_STEP = 0.25
48
+
49
+ function clampPreviewScale(scale: number): number {
50
+ return Math.min(PREVIEW_SCALE_MAX, Math.max(PREVIEW_SCALE_MIN, scale))
51
+ }
45
52
 
46
53
  /** Read the current config from the settings scope snapshot. */
47
54
  function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
@@ -50,6 +57,13 @@ function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
50
57
  return value
51
58
  }
52
59
 
60
+ /** Track the redacted api-key presence bit exposed by the settings bridge. */
61
+ function useKeySet(scope: ImageGenScope): boolean {
62
+ const [keySet, setKeySet] = useState(scope.getKeySetSnapshot())
63
+ useEffect(() => scope.subscribeKeySet(() => { setKeySet(scope.getKeySetSnapshot()) }), [scope])
64
+ return keySet
65
+ }
66
+
53
67
  /** Tick a seconds counter while `running`. */
54
68
  function useElapsed(running: boolean, startedAt: number | null): number {
55
69
  const [elapsed, setElapsed] = useState(0)
@@ -108,6 +122,8 @@ export function ImageGenPanel(props: {
108
122
  const enabled = config?.enabled ?? true
109
123
  const apiUrl = config?.apiUrl ?? ''
110
124
  const configured = apiUrl.trim() !== ''
125
+ const keySet = useKeySet(scope)
126
+ const connected = enabled && configured && keySet
111
127
 
112
128
  const [mode, setMode] = useState<GenerateMode>('text')
113
129
  const [prompt, setPrompt] = useState('')
@@ -124,6 +140,8 @@ export function ImageGenPanel(props: {
124
140
  const [history, setHistory] = useState<HistoryEntry[]>([])
125
141
  const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
126
142
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
143
+ const [previewScale, setPreviewScale] = useState(1)
144
+ const [promptCopied, setPromptCopied] = useState(false)
127
145
  const [update, setUpdate] = useState<UpdateInfo | null>(null)
128
146
  const [updating, setUpdating] = useState(false)
129
147
  const [updateMessage, setUpdateMessage] = useState<string | null>(null)
@@ -233,10 +251,20 @@ export function ImageGenPanel(props: {
233
251
  /** Open the full-screen image preview at a given index. */
234
252
  const openPreview = (previewImages: GeneratedImage[], index: number): void => {
235
253
  setPreview({ images: previewImages, index })
254
+ setPreviewScale(1)
255
+ setPromptCopied(false)
256
+ }
257
+
258
+ const closePreview = (): void => {
259
+ setPreview(null)
260
+ setPreviewScale(1)
261
+ setPromptCopied(false)
236
262
  }
237
263
 
238
264
  /** Step the preview by ±1, wrapping around. */
239
265
  const stepPreview = (delta: number): void => {
266
+ setPreviewScale(1)
267
+ setPromptCopied(false)
240
268
  setPreview(current => {
241
269
  if (current === null) return null
242
270
  const total = current.images.length
@@ -248,9 +276,12 @@ export function ImageGenPanel(props: {
248
276
  useEffect(() => {
249
277
  if (preview === null) return
250
278
  const onKey = (event: KeyboardEvent): void => {
251
- if (event.key === 'Escape') setPreview(null)
279
+ if (event.key === 'Escape') closePreview()
252
280
  else if (event.key === 'ArrowLeft') stepPreview(-1)
253
281
  else if (event.key === 'ArrowRight') stepPreview(1)
282
+ else if (event.key === '+' || event.key === '=') setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP))
283
+ else if (event.key === '-') setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP))
284
+ else if (event.key === '0') setPreviewScale(1)
254
285
  }
255
286
  window.addEventListener('keydown', onKey)
256
287
  return () => window.removeEventListener('keydown', onKey)
@@ -313,19 +344,58 @@ export function ImageGenPanel(props: {
313
344
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
314
345
  const previewImage = preview === null ? null : preview.images[preview.index] ?? null
315
346
 
347
+ const copyPreviewPrompt = async (text: string): Promise<void> => {
348
+ try {
349
+ if (navigator.clipboard?.writeText !== undefined) {
350
+ await navigator.clipboard.writeText(text)
351
+ } else {
352
+ const textarea = document.createElement('textarea')
353
+ textarea.value = text
354
+ textarea.style.position = 'fixed'
355
+ textarea.style.opacity = '0'
356
+ document.body.appendChild(textarea)
357
+ textarea.select()
358
+ const copied = document.execCommand('copy')
359
+ textarea.remove()
360
+ if (!copied) throw new Error('copy failed')
361
+ }
362
+ setPromptCopied(true)
363
+ window.setTimeout(() => { setPromptCopied(false) }, 1800)
364
+ } catch {
365
+ setPromptCopied(false)
366
+ }
367
+ }
368
+
369
+ const addPreviewToEdit = (): void => {
370
+ if (previewImage === null || preview === null) return
371
+ setMode('edit')
372
+ setRefImage({
373
+ dataUrl: srcOf(previewImage),
374
+ name: `dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`,
375
+ })
376
+ if (prompt.trim() === '' && previewImage.revisedPrompt !== undefined) setPrompt(previewImage.revisedPrompt)
377
+ setError(null)
378
+ closePreview()
379
+ }
380
+
316
381
  return (
317
382
  <div className={css.panel}>
318
383
  <header className={css.panelHeader}>
319
- <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
320
- <span className={css.panelSubtitle}>{tt('panel.subtitle')}</span>
384
+ <span className={css.panelHeading}>
385
+ <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
386
+ <span className={css.panelSubtitle}>{tt('panel.subtitle')}</span>
387
+ </span>
388
+ <button
389
+ type="button"
390
+ className={css.connectionStatus}
391
+ data-connected={connected ? 'true' : 'false'}
392
+ aria-label={tt(connected ? 'connection.connected' : 'connection.disconnected')}
393
+ >
394
+ <span className={css.connectionDot} aria-hidden="true" />
395
+ {tt(connected ? 'connection.connected' : 'connection.disconnected')}
396
+ </button>
321
397
  </header>
322
398
 
323
- {!enabled
324
- ? <div className={css.banner} data-kind="warn">{tt('config.disabled')}</div>
325
- : !configured
326
- ? <div className={css.banner} data-kind="warn">{tt('config.missing')}</div>
327
- : <div className={css.banner} data-kind="ok">{tt('config.configured', { url: apiUrl })}</div>}
328
-
329
399
  {update !== null ? (
330
400
  <div className={css.updateBanner} data-kind={updateResult === 'success' ? 'ok' : 'warn'}>
331
401
  <span className={css.updateText}>
@@ -555,7 +625,7 @@ export function ImageGenPanel(props: {
555
625
  <span className={css.canvasHistoryTag}>{tt('history.viewing', { time: formatTime(viewingEntry.createdAt) })}</span>
556
626
  ) : null}
557
627
  </div>
558
- <div className={css.grid}>
628
+ <div className={css.grid} data-count={images.length}>
559
629
  {images.map((image, index) => (
560
630
  <figure
561
631
  key={index}
@@ -663,9 +733,9 @@ export function ImageGenPanel(props: {
663
733
  role="dialog"
664
734
  aria-modal="true"
665
735
  aria-label={tt('preview.title')}
666
- onClick={() => { setPreview(null) }}
736
+ onClick={closePreview}
667
737
  >
668
- <button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} onClick={() => { setPreview(null) }}>
738
+ <button type="button" className={css.lightboxClose} aria-label={tt('preview.close')} title={tt('preview.close')} onClick={closePreview}>
669
739
  <svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
670
740
  </button>
671
741
  {preview.images.length > 1 ? (
@@ -679,25 +749,60 @@ export function ImageGenPanel(props: {
679
749
  </>
680
750
  ) : null}
681
751
  <figure className={css.lightboxFigure} onClick={(event) => { event.stopPropagation() }}>
682
- <img
683
- className={css.lightboxImage}
684
- src={srcOf(previewImage)}
685
- alt={previewImage.revisedPrompt ?? tt('preview.title')}
686
- />
752
+ <div
753
+ className={css.lightboxStage}
754
+ onWheel={(event) => {
755
+ event.preventDefault()
756
+ setPreviewScale(current => clampPreviewScale(current + (event.deltaY < 0 ? PREVIEW_SCALE_STEP : -PREVIEW_SCALE_STEP)))
757
+ }}
758
+ >
759
+ <img
760
+ className={css.lightboxImage}
761
+ style={{ transform: `scale(${previewScale})` }}
762
+ src={srcOf(previewImage)}
763
+ alt={previewImage.revisedPrompt ?? tt('preview.title')}
764
+ />
765
+ </div>
766
+ <div className={css.lightboxTools} role="group" aria-label={tt('preview.zoomControls')}>
767
+ <button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomOut')} title={tt('preview.zoomOut')} onClick={() => { setPreviewScale(current => clampPreviewScale(current - PREVIEW_SCALE_STEP)) }}>
768
+ <svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
769
+ </button>
770
+ <button type="button" className={css.lightboxZoomLevel} aria-label={tt('preview.zoomReset')} title={tt('preview.zoomReset')} onClick={() => { setPreviewScale(1) }}>
771
+ {tt('preview.zoomLevel', { percent: Math.round(previewScale * 100) })}
772
+ </button>
773
+ <button type="button" className={css.lightboxTool} aria-label={tt('preview.zoomIn')} title={tt('preview.zoomIn')} onClick={() => { setPreviewScale(current => clampPreviewScale(current + PREVIEW_SCALE_STEP)) }}>
774
+ <svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true"><circle cx="7" cy="7" r="4.2"/><path d="M7 4.8v4.4M4.8 7h4.4M13 13l-2.8-2.8"/></svg>
775
+ </button>
776
+ </div>
687
777
  {previewImage.revisedPrompt !== undefined ? (
688
- <figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
689
- {tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
690
- </figcaption>
778
+ <div className={css.lightboxCaptionRow}>
779
+ <figcaption className={css.lightboxCaption} title={previewImage.revisedPrompt}>
780
+ {tt('revisedPrompt', { prompt: previewImage.revisedPrompt })}
781
+ </figcaption>
782
+ <button type="button" className={css.lightboxCopy} aria-label={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} title={tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')} onClick={() => { void copyPreviewPrompt(previewImage.revisedPrompt!) }}>
783
+ {promptCopied ? (
784
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 8l3 3 7-7"/></svg>
785
+ ) : (
786
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="5" y="5" width="7" height="8" rx="1"/><path d="M3 10V3.8c0-.44.36-.8.8-.8H9"/></svg>
787
+ )}
788
+ <span>{tt(promptCopied ? 'preview.copied' : 'preview.copyPrompt')}</span>
789
+ </button>
790
+ </div>
691
791
  ) : null}
692
792
  <div className={css.lightboxMeta}>
693
793
  <span className={css.lightboxIndex}>{tt('preview.index', { index: preview.index + 1, total: preview.images.length })}</span>
694
- <a
695
- className={css.lightboxDownload}
696
- href={srcOf(previewImage)}
697
- download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
698
- >
699
- {tt('download')}
700
- </a>
794
+ <span className={css.lightboxActions}>
795
+ <button type="button" className={css.lightboxEdit} onClick={addPreviewToEdit}>
796
+ {tt('preview.addToEdit')}
797
+ </button>
798
+ <a
799
+ className={css.lightboxDownload}
800
+ href={srcOf(previewImage)}
801
+ download={`dsh-image-${preview.index + 1}.${extensionOf(previewImage.mime)}`}
802
+ >
803
+ {tt('download')}
804
+ </a>
805
+ </span>
701
806
  </div>
702
807
  </figure>
703
808
  </div>,
@@ -11,6 +11,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
11
11
  import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
12
  import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
13
  import type { ImageGenScope } from './settings-scope.ts'
14
+ import { PLUGIN_VERSION } from '../protocol.ts'
14
15
  import css from './settings-card.module.css'
15
16
 
16
17
  /** The fields this card edits (the namespace's full schema). */
@@ -161,6 +162,10 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
161
162
  ? (
162
163
  <div className={css.body}>
163
164
  {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
165
+ <div className={css.versionRow}>
166
+ <span className={css.versionLabel}>{t('settings.currentVersion')}</span>
167
+ <code className={css.versionValue}>v{PLUGIN_VERSION}</code>
168
+ </div>
164
169
  <ValueField
165
170
  id="dsh-imagegen-settings-apikey"
166
171
  label={t('settings.apiKey')}
@@ -72,10 +72,20 @@ export const zh = {
72
72
  'preview.prev': '上一张',
73
73
  'preview.next': '下一张',
74
74
  'preview.index': '{index} / {total}',
75
+ 'preview.zoomControls': '图片缩放控制',
76
+ 'preview.zoomIn': '放大',
77
+ 'preview.zoomOut': '缩小',
78
+ 'preview.zoomReset': '重置缩放',
79
+ 'preview.zoomLevel': '{percent}%',
80
+ 'preview.copyPrompt': '复制提示词',
81
+ 'preview.copied': '已复制',
82
+ 'preview.addToEdit': '添加到图生图',
75
83
  // config banner
76
84
  'config.missing': '尚未配置 API:请前往「设置 → 插件 → 可配置」为 AI 生图填写 api_url 与 api_key。',
77
85
  'config.configured': '已连接 {url}',
78
86
  'config.disabled': '插件已停用,请在设置中重新启用。',
87
+ 'connection.connected': '已连接',
88
+ 'connection.disconnected': '未连接',
79
89
  // plugin update
80
90
  'update.available': '检测到新版本:{version}',
81
91
  'update.install': '在线更新',
@@ -86,6 +96,7 @@ export const zh = {
86
96
  // settings card
87
97
  'settings.title': 'AI 生图(dsh-imagegen)',
88
98
  'settings.description': '配置图像生成 API 地址与密钥',
99
+ 'settings.currentVersion': '当前版本',
89
100
  'settings.apiUrl': 'API 地址(api_url)',
90
101
  'settings.apiUrlHint': 'OpenAI 兼容接口基址,如 https://api.openai.com/v1;将自动拼接 /images/generations 与 /images/edits',
91
102
  'settings.apiKey': 'API 密钥(api_key)',
@@ -175,9 +186,19 @@ export const en: Record<keyof typeof zh, string> = {
175
186
  'preview.prev': 'Previous',
176
187
  'preview.next': 'Next',
177
188
  'preview.index': '{index} / {total}',
189
+ 'preview.zoomControls': 'Image zoom controls',
190
+ 'preview.zoomIn': 'Zoom in',
191
+ 'preview.zoomOut': 'Zoom out',
192
+ 'preview.zoomReset': 'Reset zoom',
193
+ 'preview.zoomLevel': '{percent}%',
194
+ 'preview.copyPrompt': 'Copy prompt',
195
+ 'preview.copied': 'Copied',
196
+ 'preview.addToEdit': 'Add to image to image',
178
197
  'config.missing': 'API not configured: open "Settings → Plugins → Configurable" and fill in api_url and api_key for AI Image.',
179
198
  'config.configured': 'Connected to {url}',
180
199
  'config.disabled': 'The plugin is disabled — re-enable it in Settings.',
200
+ 'connection.connected': 'Connected',
201
+ 'connection.disconnected': 'Disconnected',
181
202
  'update.available': 'A new version is available: {version}',
182
203
  'update.install': 'Update online',
183
204
  'update.installing': 'Updating…',
@@ -186,6 +207,7 @@ export const en: Record<keyof typeof zh, string> = {
186
207
  'update.release': 'View Release',
187
208
  'settings.title': 'AI Image (dsh-imagegen)',
188
209
  'settings.description': 'Configure the image generation API endpoint and key',
210
+ 'settings.currentVersion': 'Current version',
189
211
  'settings.apiUrl': 'API URL (api_url)',
190
212
  'settings.apiUrlHint': 'OpenAI-compatible base URL, e.g. https://api.openai.com/v1; /images/generations and /images/edits are appended',
191
213
  'settings.apiKey': 'API Key (api_key)',
@@ -121,10 +121,18 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
121
121
  }
122
122
 
123
123
  .panelHeader {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: 12px;
128
+ flex: none;
129
+ }
130
+
131
+ .panelHeading {
124
132
  display: flex;
125
133
  align-items: baseline;
126
134
  gap: 10px;
127
- flex: none;
135
+ min-width: 0;
128
136
  }
129
137
 
130
138
  .panelTitle {
@@ -143,27 +151,35 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
143
151
  text-overflow: ellipsis;
144
152
  }
145
153
 
146
- /* --- banners ------------------------------------------------------------------ */
154
+ /* --- connection status and update notice -------------------------------------- */
147
155
 
148
- .banner {
156
+ .connectionStatus {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: 6px;
149
160
  flex: none;
150
- padding: 7px 12px;
161
+ height: 28px;
162
+ padding: 0 10px;
163
+ border: 1px solid var(--dsw-alias-label-error);
164
+ border-radius: 8px;
165
+ background: transparent;
166
+ color: var(--dsw-alias-label-error);
167
+ font: inherit;
151
168
  font-size: 12px;
152
- line-height: 1.5;
153
- border-radius: 10px;
154
- border: 1px solid var(--dsw-alias-border-l2);
155
- color: var(--dsw-alias-label-secondary);
156
- overflow-wrap: anywhere;
169
+ line-height: 1;
170
+ white-space: nowrap;
157
171
  }
158
172
 
159
- .banner[data-kind='ok'] {
160
- color: var(--dsw-alias-state-success-primary);
173
+ .connectionStatus[data-connected='true'] {
161
174
  border-color: var(--dsw-alias-state-success-primary);
175
+ color: var(--dsw-alias-state-success-primary);
162
176
  }
163
177
 
164
- .banner[data-kind='warn'] {
165
- color: var(--dsw-alias-state-warn-primary);
166
- border-color: var(--dsw-alias-state-warn-primary);
178
+ .connectionDot {
179
+ width: 6px;
180
+ height: 6px;
181
+ border-radius: 50%;
182
+ background: currentColor;
167
183
  }
168
184
 
169
185
  .updateBanner {
@@ -205,6 +221,16 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
205
221
  }
206
222
 
207
223
  @media (max-width: 700px) {
224
+ .panelHeader {
225
+ align-items: flex-start;
226
+ }
227
+
228
+ .panelHeading {
229
+ align-items: flex-start;
230
+ flex-direction: column;
231
+ gap: 2px;
232
+ }
233
+
208
234
  .updateBanner {
209
235
  align-items: flex-start;
210
236
  flex-direction: column;
@@ -772,9 +798,29 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
772
798
 
773
799
  .grid {
774
800
  display: grid;
775
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
801
+ flex: 1;
802
+ min-height: 0;
776
803
  gap: 14px;
777
- align-content: start;
804
+ }
805
+
806
+ .grid[data-count='1'] {
807
+ grid-template-columns: minmax(0, 1fr);
808
+ grid-template-rows: minmax(0, 1fr);
809
+ }
810
+
811
+ .grid[data-count='2'] {
812
+ grid-template-columns: repeat(2, minmax(0, 1fr));
813
+ grid-template-rows: minmax(0, 1fr);
814
+ }
815
+
816
+ .grid[data-count='3'] {
817
+ grid-template-columns: repeat(3, minmax(0, 1fr));
818
+ grid-template-rows: minmax(0, 1fr);
819
+ }
820
+
821
+ .grid[data-count='4'] {
822
+ grid-template-columns: repeat(2, minmax(0, 1fr));
823
+ grid-template-rows: repeat(2, minmax(0, 1fr));
778
824
  }
779
825
 
780
826
  .imageCard {
@@ -787,6 +833,7 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
787
833
  overflow: hidden;
788
834
  background: var(--dsw-alias-bg-layer-2);
789
835
  cursor: zoom-in;
836
+ min-height: 0;
790
837
  }
791
838
 
792
839
  .imageCard:focus-visible {
@@ -796,9 +843,10 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
796
843
 
797
844
  .image {
798
845
  display: block;
846
+ flex: 1;
847
+ min-height: 0;
799
848
  width: 100%;
800
- aspect-ratio: 1 / 1;
801
- object-fit: cover;
849
+ object-fit: contain;
802
850
  background: var(--dsw-alias-bg-base);
803
851
  }
804
852
 
@@ -945,11 +993,11 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
945
993
  }
946
994
 
947
995
  .lightboxNav[data-dir='prev'] {
948
- left: 16px;
996
+ left: max(20px, calc(50% - 640px));
949
997
  }
950
998
 
951
999
  .lightboxNav[data-dir='next'] {
952
- right: 16px;
1000
+ right: max(20px, calc(50% - 640px));
953
1001
  }
954
1002
 
955
1003
  .lightboxFigure {
@@ -958,19 +1006,84 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
958
1006
  gap: 10px;
959
1007
  margin: 0;
960
1008
  max-width: min(1100px, calc(100vw - 160px));
961
- max-height: calc(100vh - 48px);
1009
+ width: min(1100px, calc(100vw - 160px));
1010
+ height: min(820px, calc(100vh - 48px));
1011
+ min-height: 0;
1012
+ }
1013
+
1014
+ .lightboxStage {
1015
+ flex: 1;
1016
+ min-height: 0;
1017
+ display: grid;
1018
+ place-items: center;
1019
+ overflow: auto;
1020
+ border-radius: 10px;
1021
+ background: rgba(255, 255, 255, 0.04);
962
1022
  }
963
1023
 
964
1024
  .lightboxImage {
965
- max-width: 100%;
966
- max-height: calc(100vh - 160px);
1025
+ max-width: calc(100% - 4px);
1026
+ max-height: calc(100% - 4px);
967
1027
  object-fit: contain;
968
1028
  border-radius: 10px;
969
1029
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
970
- background: rgba(255, 255, 255, 0.04);
1030
+ transition: transform 120ms ease;
1031
+ }
1032
+
1033
+ .lightboxTools {
1034
+ display: flex;
1035
+ align-items: center;
1036
+ justify-content: center;
1037
+ gap: 6px;
1038
+ }
1039
+
1040
+ .lightboxTool,
1041
+ .lightboxZoomLevel,
1042
+ .lightboxCopy {
1043
+ display: inline-flex;
1044
+ align-items: center;
1045
+ justify-content: center;
1046
+ color: #fff;
1047
+ background: rgba(255, 255, 255, 0.14);
1048
+ border: 1px solid rgba(255, 255, 255, 0.28);
1049
+ cursor: pointer;
1050
+ }
1051
+
1052
+ .lightboxTool,
1053
+ .lightboxZoomLevel {
1054
+ height: 32px;
1055
+ }
1056
+
1057
+ .lightboxTool {
1058
+ width: 32px;
1059
+ border-radius: 50%;
1060
+ }
1061
+
1062
+ .lightboxZoomLevel {
1063
+ min-width: 58px;
1064
+ padding: 0 9px;
1065
+ border-radius: 999px;
1066
+ font: inherit;
1067
+ font-size: 12px;
1068
+ font-variant-numeric: tabular-nums;
1069
+ }
1070
+
1071
+ .lightboxTool:hover,
1072
+ .lightboxZoomLevel:hover,
1073
+ .lightboxCopy:hover {
1074
+ background: rgba(255, 255, 255, 0.26);
1075
+ }
1076
+
1077
+ .lightboxCaptionRow {
1078
+ display: flex;
1079
+ align-items: flex-start;
1080
+ gap: 8px;
1081
+ min-width: 0;
971
1082
  }
972
1083
 
973
1084
  .lightboxCaption {
1085
+ flex: 1;
1086
+ min-width: 0;
974
1087
  font-size: 12px;
975
1088
  line-height: 1.6;
976
1089
  color: rgba(255, 255, 255, 0.9);
@@ -980,6 +1093,17 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
980
1093
  overflow: hidden;
981
1094
  }
982
1095
 
1096
+ .lightboxCopy {
1097
+ flex: none;
1098
+ gap: 5px;
1099
+ min-height: 28px;
1100
+ padding: 4px 9px;
1101
+ border-radius: 999px;
1102
+ font: inherit;
1103
+ font-size: 12px;
1104
+ white-space: nowrap;
1105
+ }
1106
+
983
1107
  .lightboxMeta {
984
1108
  display: flex;
985
1109
  align-items: center;
@@ -993,8 +1117,16 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
993
1117
  font-variant-numeric: tabular-nums;
994
1118
  }
995
1119
 
996
- .lightboxDownload {
1120
+ .lightboxActions {
1121
+ display: inline-flex;
1122
+ align-items: center;
1123
+ gap: 8px;
1124
+ }
1125
+
1126
+ .lightboxDownload,
1127
+ .lightboxEdit {
997
1128
  padding: 4px 14px;
1129
+ font: inherit;
998
1130
  font-size: 12.5px;
999
1131
  font-weight: 500;
1000
1132
  color: #fff;
@@ -1002,12 +1134,51 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
1002
1134
  border: 1px solid rgba(255, 255, 255, 0.28);
1003
1135
  border-radius: 999px;
1004
1136
  text-decoration: none;
1137
+ cursor: pointer;
1005
1138
  }
1006
1139
 
1007
- .lightboxDownload:hover {
1140
+ .lightboxDownload:hover,
1141
+ .lightboxEdit:hover {
1008
1142
  background: rgba(255, 255, 255, 0.26);
1009
1143
  }
1010
1144
 
1145
+ .lightboxEdit {
1146
+ color: #fff;
1147
+ background: rgba(255, 255, 255, 0.14);
1148
+ border: 1px solid rgba(255, 255, 255, 0.28);
1149
+ border-radius: 999px;
1150
+ }
1151
+
1152
+ @media (max-width: 720px) {
1153
+ .lightbox {
1154
+ padding: 16px;
1155
+ }
1156
+
1157
+ .lightboxFigure {
1158
+ width: calc(100vw - 32px);
1159
+ max-width: none;
1160
+ }
1161
+
1162
+ .lightboxNav[data-dir='prev'] {
1163
+ left: 20px;
1164
+ }
1165
+
1166
+ .lightboxNav[data-dir='next'] {
1167
+ right: 20px;
1168
+ }
1169
+
1170
+ .lightboxCaptionRow,
1171
+ .lightboxMeta {
1172
+ align-items: stretch;
1173
+ flex-direction: column;
1174
+ }
1175
+
1176
+ .lightboxCopy,
1177
+ .lightboxActions {
1178
+ align-self: flex-end;
1179
+ }
1180
+ }
1181
+
1011
1182
  @keyframes dshImageGenSpin {
1012
1183
  to { transform: rotate(360deg); }
1013
1184
  }