dsh-side-chat-plus 0.3.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +317 -0
  3. package/README.zh.md +261 -0
  4. package/cordis.patch.yml +8 -0
  5. package/dsh.plugin.json +16 -0
  6. package/lib/client-registry.js +2949 -0
  7. package/lib/client-registry.js.map +1 -0
  8. package/lib/client.js +2949 -0
  9. package/lib/client.js.map +1 -0
  10. package/lib/index.js +840 -0
  11. package/lib/types/client/api.d.ts +218 -0
  12. package/lib/types/client/attachments/AttachmentRail.d.ts +39 -0
  13. package/lib/types/client/attachments/DropOverlay.d.ts +18 -0
  14. package/lib/types/client/attachments/ImageLightbox.d.ts +20 -0
  15. package/lib/types/client/attachments/MessageImage.d.ts +38 -0
  16. package/lib/types/client/attachments/index.d.ts +18 -0
  17. package/lib/types/client/index.d.ts +6 -0
  18. package/lib/types/client/locales.d.ts +178 -0
  19. package/lib/types/context-types.d.ts +390 -0
  20. package/lib/types/index.d.ts +7 -0
  21. package/lib/types/settings-shared.d.ts +24 -0
  22. package/lib/types/trust-fence.d.ts +20 -0
  23. package/lib/types/wire.d.ts +25 -0
  24. package/package.json +114 -0
  25. package/src/client/api.ts +112 -0
  26. package/src/client/attachments/AttachmentRail.module.css +89 -0
  27. package/src/client/attachments/AttachmentRail.tsx +173 -0
  28. package/src/client/attachments/DropOverlay.module.css +38 -0
  29. package/src/client/attachments/DropOverlay.tsx +62 -0
  30. package/src/client/attachments/ImageLightbox.module.css +44 -0
  31. package/src/client/attachments/ImageLightbox.tsx +58 -0
  32. package/src/client/attachments/MessageImage.module.css +61 -0
  33. package/src/client/attachments/MessageImage.tsx +120 -0
  34. package/src/client/attachments/index.ts +19 -0
  35. package/src/client/client.module.css +1032 -0
  36. package/src/client/index.tsx +1966 -0
  37. package/src/client/layout.css +16 -0
  38. package/src/client/locales.ts +181 -0
  39. package/src/context-types.ts +384 -0
  40. package/src/css-modules.d.ts +10 -0
  41. package/src/index.ts +840 -0
  42. package/src/settings-shared.ts +33 -0
  43. package/src/trust-fence.ts +70 -0
  44. package/src/wire.ts +81 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Client API client for the /sidechat JSON routes (same fetch pattern the
3
+ * better-sidebar built-ins use). All requests are same-origin POSTs; the host
4
+ * fence (loopback/trusted authority) passes them naturally.
5
+ */
6
+
7
+ /** One API failure envelope. */
8
+ export interface SidechatApiError {
9
+ code: string
10
+ message: string
11
+ }
12
+
13
+ /** The unwrapped success value of one API method. */
14
+ export type SidechatResult<T> = { ok: true; value: T } | { ok: false; error: SidechatApiError }
15
+
16
+ /** Side-chat list row. */
17
+ export interface SidechatListItem {
18
+ childId: string
19
+ running: boolean
20
+ runningSince?: number
21
+ }
22
+
23
+ /** Browser-submitted prompt content part (text or base64 image). */
24
+ export type PromptContentPart = { type: 'text'; text: string } | { type: 'image'; mediaType: string; data: string; name?: string }
25
+
26
+ /** One durable image reference returned by the host transcript. */
27
+ export interface SidechatImageRef {
28
+ attachmentId: string
29
+ mediaType: string
30
+ bytes: number
31
+ width: number
32
+ height: number
33
+ name?: string
34
+ }
35
+
36
+ /** One transcript message block. */
37
+ export type SidechatMessageBlock = { type: 'text'; text: string } | { type: 'image'; ref: SidechatImageRef } | { type: 'reasoning'; text: string }
38
+
39
+ /** One transcript message. */
40
+ export interface SidechatMessage {
41
+ role: 'user' | 'assistant'
42
+ blocks: SidechatMessageBlock[]
43
+ }
44
+
45
+ /** Model / effort directory (provider groups → models → reasoning efforts). */
46
+ export interface SidechatDirectory {
47
+ groups: Array<{
48
+ id: string
49
+ name: string
50
+ models: Array<{
51
+ id: string
52
+ name: string
53
+ description?: string
54
+ reasoning?: { efforts: Array<{ id: string; name: string }>; defaultEffort?: string }
55
+ }>
56
+ }>
57
+ }
58
+
59
+ /** Permission options. */
60
+ export interface SidechatPermissions {
61
+ options: Array<{ value: string; name: string; description?: string }>
62
+ current: string
63
+ }
64
+
65
+ /** Call one /sidechat/api method. */
66
+ async function call<T>(method: string, payload: Record<string, unknown>): Promise<SidechatResult<T>> {
67
+ const res = await fetch(`/sidechat/api/${method}`, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify(payload),
71
+ })
72
+ return (await res.json()) as SidechatResult<T>
73
+ }
74
+
75
+ export const api = {
76
+ start: (args: { parentSessionId: string; content: PromptContentPart[]; lookupEnabled: boolean; provider?: string; model?: string; reasoningEffort?: string; preset?: string }) =>
77
+ call<{ childId: string; provider: string; model: string; reasoningEffort?: string }>('sidechat.start', args),
78
+ followup: (args: { childId: string; content: PromptContentPart[]; lookupEnabled: boolean }) =>
79
+ call<{ accepted: true }>('sidechat.followup', args),
80
+ list: (args: { parentSessionId: string }) =>
81
+ call<{ items: SidechatListItem[] }>('sidechat.list', args),
82
+ history: (args: { childId: string }) =>
83
+ call<{ messages: SidechatMessage[] }>('sidechat.history', args),
84
+ stop: (args: { childId: string }) =>
85
+ call<{ accepted: true }>('sidechat.stop', args),
86
+ selectModel: (args: { childId: string; provider: string; model: string; reasoningEffort?: string }) =>
87
+ call<{ accepted: true }>('sidechat.selectModel', args),
88
+ selectPermission: (args: { childId: string; presetName: string }) =>
89
+ call<{ accepted: true }>('sidechat.selectPermission', args),
90
+ summarize: (args: { parentSessionId: string; text: string; provider?: string; model?: string; reasoningEffort?: string; locale?: string }) =>
91
+ call<{ summary: string }>('sidechat.summarize', args),
92
+ inject: (args: { parentSessionId: string; text: string; summary?: string }) =>
93
+ call<{ accepted: true }>('sidechat.inject', args),
94
+ directory: () => call<SidechatDirectory>('sidechat.directory', {}),
95
+ permissions: () => call<SidechatPermissions>('sidechat.permissions', {}),
96
+ limits: () => call<{ mediaTypes: string[]; maxImageBytes: number; maxImagesPerMessage: number; maxMessageImageBytes: number; maxImagePixels: number }>('sidechat.limits', {}),
97
+ attachment: (args: { childId: string; attachmentId: string }) =>
98
+ call<{ mediaType: string; data: string }>('sidechat.attachment', args),
99
+ inherit: (args: { parentSessionId: string }) =>
100
+ call<{ provider: string; model: string; reasoningEffort?: string }>('sidechat.inherit', args),
101
+ commands: (args: { childId: string }) =>
102
+ call<{ commands: Array<{ name: string; description: string }> }>('sidechat.commands', args),
103
+ command: (args: { childId: string; line: string }) =>
104
+ call<{ executed: boolean }>('sidechat.command', args),
105
+ state: (args: { childId: string }) =>
106
+ call<{ plan: { active: boolean; pending: boolean }; goal: { id: string; objective: string } | null }>('sidechat.state', args),
107
+ settingsGet: () => call<{ value?: unknown; revision?: number }>('settings.get', {}),
108
+ settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
109
+ call<{ value?: unknown; revision?: number }>('settings.update', { patch, expectedRevision }),
110
+ dispose: (args: { childId: string }) =>
111
+ call<{ accepted: true }>('sidechat.dispose', args),
112
+ }
@@ -0,0 +1,89 @@
1
+ .root {
2
+ position: relative;
3
+ display: flex;
4
+ align-items: center;
5
+ min-width: 0;
6
+ }
7
+
8
+ .rail {
9
+ display: flex;
10
+ gap: 8px;
11
+ overflow-x: auto;
12
+ scrollbar-width: none;
13
+ padding: 2px;
14
+ }
15
+
16
+ .rail::-webkit-scrollbar {
17
+ display: none;
18
+ }
19
+
20
+ .item {
21
+ position: relative;
22
+ flex: none;
23
+ }
24
+
25
+ .thumbnail {
26
+ display: block;
27
+ width: 56px;
28
+ height: 56px;
29
+ padding: 0;
30
+ overflow: hidden;
31
+ border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.1));
32
+ border-radius: 8px;
33
+ background: none;
34
+ cursor: pointer;
35
+ }
36
+
37
+ .thumbnail img {
38
+ width: 100%;
39
+ height: 100%;
40
+ object-fit: cover;
41
+ }
42
+
43
+ .remove {
44
+ position: absolute;
45
+ top: -6px;
46
+ right: -6px;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ width: 18px;
51
+ height: 18px;
52
+ padding: 0;
53
+ border: none;
54
+ border-radius: 50%;
55
+ background: var(--dsw-color-surface-raised, #fff);
56
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
57
+ color: var(--dsw-color-text-secondary, #646a73);
58
+ cursor: pointer;
59
+ opacity: 0;
60
+ }
61
+
62
+ .item:hover .remove,
63
+ .remove:focus-visible {
64
+ opacity: 1;
65
+ }
66
+
67
+ .arrow {
68
+ position: absolute;
69
+ z-index: 1;
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ width: 22px;
74
+ height: 22px;
75
+ padding: 0;
76
+ border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.1));
77
+ border-radius: 50%;
78
+ background: var(--dsw-color-surface-raised, #fff);
79
+ color: var(--dsw-color-text-secondary, #646a73);
80
+ cursor: pointer;
81
+ }
82
+
83
+ .arrowLeft {
84
+ left: -10px;
85
+ }
86
+
87
+ .arrowRight {
88
+ right: -10px;
89
+ }
@@ -0,0 +1,173 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import {
3
+ IconChevronLeftOutline14,
4
+ IconChevronRightOutline14,
5
+ IconCloseFill14,
6
+ } from '@deepseek-ai/dsh-client-ui-primitives'
7
+ import css from './AttachmentRail.module.css'
8
+
9
+ /** One rail thumbnail; strings arrive resolved (zero-cordis atom). */
10
+ export interface AttachmentRailItem {
11
+ /** Stable identity for the React key. */
12
+ id: string
13
+ /** Object or data URL rendered as the thumbnail. */
14
+ previewUrl: string
15
+ /** Image alt text (display name with the owner's fallback applied). */
16
+ alt: string
17
+ /** Accessible label of the item's remove control. */
18
+ removeLabel: string
19
+ }
20
+
21
+ /** Rail-level strings the owner resolves from its own locale namespace. */
22
+ export interface AttachmentRailLabels {
23
+ /** Accessible name of the rail group. */
24
+ group: string
25
+ /** Thumbnail tooltip inviting the original-image preview. */
26
+ open: string
27
+ /** Accessible label of the left paging arrow. */
28
+ scrollLeft: string
29
+ /** Accessible label of the right paging arrow. */
30
+ scrollRight: string
31
+ }
32
+
33
+ /** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox notch wheels report lines). */
34
+ const WHEEL_LINE_PX = 16
35
+
36
+ /** Smooth paging unless the user asked for reduced motion. */
37
+ function pageBehavior(): ScrollBehavior {
38
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
39
+ }
40
+
41
+ /**
42
+ * Horizontal thumbnail rail over the caller's draft attachments.
43
+ *
44
+ * The rail scrolls with its scrollbar hidden; overflow is announced by edge
45
+ * arrows recomputed from scroll geometry on scroll, item-count changes, and
46
+ * rail size changes (a ResizeObserver on the rail element, so sidebar or panel
47
+ * resizes count, not only window resizes). A vertical wheel pans the rail
48
+ * horizontally and is consumed exclusively, a newly added item is revealed at
49
+ * the rail's end, and each thumbnail opens on a single click while its remove
50
+ * control sits inside the card. The owner decides mounting.
51
+ */
52
+ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, onOpen, onRemove }: {
53
+ items: readonly T[]
54
+ labels: AttachmentRailLabels
55
+ onOpen: (item: T) => void
56
+ onRemove: (item: T) => void
57
+ }) {
58
+ const railRef = useRef<HTMLDivElement | null>(null)
59
+ // null marks the first layout pass: a rail that MOUNTS over an existing
60
+ // draft is initial display, not growth, and must not jump to the end.
61
+ const countRef = useRef<number | null>(null)
62
+ const [edges, setEdges] = useState({ left: false, right: false })
63
+ const updateEdges = useCallback(() => {
64
+ const el = railRef.current
65
+ if (el === null) return
66
+ // 1px slack: engines report fractional scroll positions at the edges.
67
+ const left = el.scrollLeft > 1
68
+ const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
69
+ setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
70
+ }, [])
71
+ useLayoutEffect(() => {
72
+ const grew = countRef.current !== null && items.length > countRef.current
73
+ countRef.current = items.length
74
+ const el = railRef.current
75
+ if (el === null) return
76
+ // A newly added attachment lands at the rail's end: reveal it.
77
+ if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
78
+ updateEdges()
79
+ }, [items.length, updateEdges])
80
+ useEffect(() => {
81
+ const el = railRef.current
82
+ if (el === null) return
83
+ // The rail's width follows the composer, which resizes with sidebars and
84
+ // panels, not only the window — observe the element itself.
85
+ let disconnect = (): void => {}
86
+ if (typeof ResizeObserver !== 'undefined') {
87
+ const observer = new ResizeObserver(updateEdges)
88
+ observer.observe(el)
89
+ disconnect = () => { observer.disconnect() }
90
+ }
91
+ // The rail scrolls horizontally ONLY: any wheel tick with a vertical
92
+ // component is consumed — without preventDefault it would also scroll the
93
+ // conversation behind the composer, and React's root wheel listener is
94
+ // passive, so the exclusion needs this manually attached non-passive
95
+ // listener.
96
+ const onWheel = (event: globalThis.WheelEvent): void => {
97
+ if (event.deltaY === 0) return
98
+ const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
99
+ ? WHEEL_LINE_PX
100
+ : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
101
+ event.preventDefault()
102
+ el.scrollBy({
103
+ left: event.deltaX !== 0
104
+ ? event.deltaX * scale
105
+ : Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
106
+ behavior: 'auto',
107
+ })
108
+ }
109
+ el.addEventListener('wheel', onWheel, { passive: false })
110
+ return () => {
111
+ disconnect()
112
+ el.removeEventListener('wheel', onWheel)
113
+ }
114
+ }, [updateEdges])
115
+ const page = (direction: -1 | 1): void => {
116
+ const el = railRef.current
117
+ if (el === null) return
118
+ // One viewport minus a card keeps the last visible thumbnail as context.
119
+ el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
120
+ }
121
+ return (
122
+ <div className={css.root}>
123
+ {edges.left && (
124
+ <button
125
+ type="button"
126
+ className={`${css.arrow} ${css.arrowLeft}`}
127
+ aria-label={labels.scrollLeft}
128
+ onClick={() => { page(-1) }}
129
+ >
130
+ <IconChevronLeftOutline14 />
131
+ </button>
132
+ )}
133
+ <div
134
+ ref={railRef}
135
+ className={css.rail}
136
+ role="group"
137
+ aria-label={labels.group}
138
+ onScroll={updateEdges}
139
+ >
140
+ {items.map(item => (
141
+ <div key={item.id} className={css.item}>
142
+ <button
143
+ type="button"
144
+ className={css.thumbnail}
145
+ title={labels.open}
146
+ onClick={() => { onOpen(item) }}
147
+ >
148
+ <img src={item.previewUrl} alt={item.alt} />
149
+ </button>
150
+ <button
151
+ type="button"
152
+ className={css.remove}
153
+ aria-label={item.removeLabel}
154
+ onClick={() => { onRemove(item) }}
155
+ >
156
+ <IconCloseFill14 size={12} />
157
+ </button>
158
+ </div>
159
+ ))}
160
+ </div>
161
+ {edges.right && (
162
+ <button
163
+ type="button"
164
+ className={`${css.arrow} ${css.arrowRight}`}
165
+ aria-label={labels.scrollRight}
166
+ onClick={() => { page(1) }}
167
+ >
168
+ <IconChevronRightOutline14 />
169
+ </button>
170
+ )}
171
+ </div>
172
+ )
173
+ }
@@ -0,0 +1,38 @@
1
+ .mask {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: 999;
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ /* Decoration only: drag targeting must stay on the page below. */
9
+ pointer-events: none;
10
+ background: rgba(255, 255, 255, 0.72);
11
+ border: 2px dashed var(--dsw-color-border-strong, #4d6bfe);
12
+ border-radius: 12px;
13
+ }
14
+
15
+ .wrap {
16
+ display: flex;
17
+ flex-direction: column;
18
+ align-items: center;
19
+ gap: 12px;
20
+ text-align: center;
21
+ }
22
+
23
+ .illustration {
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ }
28
+
29
+ .title {
30
+ font-size: 16px;
31
+ font-weight: 600;
32
+ color: var(--dsw-color-text, #1f2329);
33
+ }
34
+
35
+ .desc {
36
+ font-size: 13px;
37
+ color: var(--dsw-color-text-secondary, #646a73);
38
+ }
@@ -0,0 +1,62 @@
1
+ import { createPortal } from 'react-dom'
2
+ import css from './DropOverlay.module.css'
3
+
4
+ /** Drop-overlay strings the owner resolves from its own locale namespace. */
5
+ export interface DropOverlayLabels {
6
+ /** Headline inviting the drop, or naming why it is unavailable. */
7
+ title: string
8
+ /** Limits line under the title; shown only while drops are accepted. */
9
+ desc?: string | undefined
10
+ }
11
+
12
+ /**
13
+ * Full-viewport invitation shown while a file drag is over the page.
14
+ * Decoration only: `pointer-events: none` keeps drag targeting on the page
15
+ * below, so the owner's document-level listeners keep an accurate enter/leave
16
+ * count and own accept/reject. Rendered through a body portal for the same
17
+ * transformed-ancestor reason as the lightbox.
18
+ */
19
+ export function DropOverlay({ disabled, labels }: {
20
+ disabled: boolean
21
+ labels: DropOverlayLabels
22
+ }) {
23
+ return createPortal(
24
+ <div className={css.mask} role="status">
25
+ <div className={css.wrap}>
26
+ <div className={css.illustration} aria-hidden="true">
27
+ {disabled ? <BlockedIllustration /> : <UploadIllustration />}
28
+ </div>
29
+ <div className={css.title}>{labels.title}</div>
30
+ {!disabled && labels.desc !== undefined && <div className={css.desc}>{labels.desc}</div>}
31
+ </div>
32
+ </div>,
33
+ document.body,
34
+ )
35
+ }
36
+
37
+ /** Tilted photo-and-note cards. */
38
+ const UploadIllustration = () => (
39
+ <svg width="96" height="72" viewBox="0 0 96 72" fill="none" xmlns="http://www.w3.org/2000/svg">
40
+ <rect y="14" width="38" height="38" rx="10" transform="rotate(-22 0 14)" fill="#9ce5ed" />
41
+ <rect x="62" y="7" width="37" height="43" rx="8" transform="rotate(17 62 7)" fill="#679efe" />
42
+ <rect x="26" y="30" width="38" height="38" rx="10" fill="#3964fe" />
43
+ <path
44
+ d="M33 62c1-2 4-8 7-14 1-2 5-2 6 1 2 5 5 9 7 10 3 1 7-9 17 2"
45
+ stroke="#fff"
46
+ strokeWidth="3"
47
+ fill="none"
48
+ />
49
+ <circle cx="53" cy="43" r="4" fill="#fff" />
50
+ </svg>
51
+ )
52
+
53
+ /** Greyed cards with a blocked badge. */
54
+ const BlockedIllustration = () => (
55
+ <svg width="96" height="72" viewBox="0 0 96 72" fill="none" xmlns="http://www.w3.org/2000/svg">
56
+ <rect y="14" width="38" height="38" rx="10" transform="rotate(-22 0 14)" fill="#c4c9d0" />
57
+ <rect x="62" y="7" width="37" height="43" rx="8" transform="rotate(17 62 7)" fill="#b0b6be" />
58
+ <rect x="26" y="30" width="38" height="38" rx="10" fill="#d8dce1" />
59
+ <circle cx="45" cy="49" r="12" stroke="#fff" strokeWidth="3" fill="none" />
60
+ <path d="M36 40l18 18" stroke="#fff" strokeWidth="3" strokeLinecap="round" />
61
+ </svg>
62
+ )
@@ -0,0 +1,44 @@
1
+ .backdrop {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: 1000;
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ padding: 32px;
9
+ }
10
+
11
+ .mask {
12
+ position: absolute;
13
+ inset: 0;
14
+ background: rgba(0, 0, 0, 0.72);
15
+ }
16
+
17
+ .image {
18
+ position: relative;
19
+ max-width: 100%;
20
+ max-height: 100%;
21
+ object-fit: contain;
22
+ border-radius: 8px;
23
+ }
24
+
25
+ .close {
26
+ position: absolute;
27
+ top: 20px;
28
+ right: 20px;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ width: 32px;
33
+ height: 32px;
34
+ padding: 0;
35
+ border: none;
36
+ border-radius: 50%;
37
+ background: rgba(255, 255, 255, 0.16);
38
+ color: #fff;
39
+ cursor: pointer;
40
+ }
41
+
42
+ .close:hover {
43
+ background: rgba(255, 255, 255, 0.28);
44
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import css from './ImageLightbox.module.css'
5
+
6
+ /** Preview strings the owner resolves from its own locale namespace. */
7
+ export interface ImageLightboxLabels {
8
+ /** Accessible name of the preview dialog. */
9
+ dialog: string
10
+ /** Accessible label of the close control. */
11
+ close: string
12
+ }
13
+
14
+ /**
15
+ * Document-level original-image preview opened by clicking a thumbnail.
16
+ * Closes on Escape, backdrop press, or the close control, and restores focus
17
+ * to the opener on unmount. Rendered through a body portal: an opener inside
18
+ * a transformed or filtered ancestor would otherwise trap the fixed backdrop
19
+ * in that ancestor's box instead of covering the viewport.
20
+ */
21
+ export function ImageLightbox({ src, alt, labels, onClose }: {
22
+ src: string
23
+ alt: string
24
+ labels: ImageLightboxLabels
25
+ onClose: () => void
26
+ }) {
27
+ const closeRef = useRef<HTMLButtonElement | null>(null)
28
+ const restoreRef = useRef<HTMLElement | null>(null)
29
+
30
+ useEffect(() => {
31
+ restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
32
+ closeRef.current?.focus()
33
+ const onKeyDown = (event: globalThis.KeyboardEvent): void => {
34
+ if (event.key === 'Escape') onClose()
35
+ }
36
+ window.addEventListener('keydown', onKeyDown)
37
+ return () => {
38
+ window.removeEventListener('keydown', onKeyDown)
39
+ restoreRef.current?.focus()
40
+ }
41
+ }, [onClose])
42
+
43
+ return createPortal(
44
+ <div
45
+ className={css.backdrop}
46
+ role="dialog"
47
+ aria-modal="true"
48
+ aria-label={labels.dialog}
49
+ >
50
+ <div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
51
+ <img className={css.image} src={src} alt={alt} />
52
+ <button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>
53
+ <IconCloseOutline16 size={16} />
54
+ </button>
55
+ </div>,
56
+ document.body,
57
+ )
58
+ }
@@ -0,0 +1,61 @@
1
+ .gallery {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+ gap: 8px;
5
+ max-width: 100%;
6
+ }
7
+
8
+ .gallery[data-align='start'] {
9
+ justify-content: flex-start;
10
+ }
11
+
12
+ .gallery[data-align='end'] {
13
+ justify-content: flex-end;
14
+ }
15
+
16
+ .frame {
17
+ padding: 0;
18
+ overflow: hidden;
19
+ border: 1px solid var(--dsw-color-border, rgba(0, 0, 0, 0.08));
20
+ border-radius: 8px;
21
+ background: none;
22
+ cursor: pointer;
23
+ }
24
+
25
+ .frame[data-variant='tile'] {
26
+ width: 64px;
27
+ height: 64px;
28
+ }
29
+
30
+ .frame img {
31
+ width: 100%;
32
+ height: 100%;
33
+ object-fit: cover;
34
+ }
35
+
36
+ .loading {
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: center;
40
+ width: 100%;
41
+ height: 100%;
42
+ min-width: 64px;
43
+ min-height: 64px;
44
+ font-size: 12px;
45
+ color: var(--dsw-color-text-secondary, #646a73);
46
+ }
47
+
48
+ .error {
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ min-width: 64px;
53
+ min-height: 64px;
54
+ padding: 0 8px;
55
+ border: 1px dashed var(--dsw-color-border, rgba(0, 0, 0, 0.16));
56
+ border-radius: 8px;
57
+ background: none;
58
+ font-size: 12px;
59
+ color: var(--dsw-color-text-secondary, #646a73);
60
+ cursor: pointer;
61
+ }