@hoardodile/ui 0.1.0 → 0.1.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.
Files changed (40) hide show
  1. package/dist/components/breadcrumb.js +3 -0
  2. package/dist/components/breadcrumb.js.map +1 -1
  3. package/dist/components/caption-bar.js +3 -0
  4. package/dist/components/caption-bar.js.map +1 -1
  5. package/dist/components/color-picker.js +3 -0
  6. package/dist/components/color-picker.js.map +1 -1
  7. package/dist/components/combobox.js +3 -0
  8. package/dist/components/combobox.js.map +1 -1
  9. package/dist/components/dropdown-menu.js +3 -0
  10. package/dist/components/dropdown-menu.js.map +1 -1
  11. package/dist/components/dropdown-select.js +3 -0
  12. package/dist/components/dropdown-select.js.map +1 -1
  13. package/dist/components/font-picker.js +3 -0
  14. package/dist/components/font-picker.js.map +1 -1
  15. package/dist/components/image-crop-panel.js +3 -0
  16. package/dist/components/image-crop-panel.js.map +1 -1
  17. package/dist/components/pagination-bar.js +3 -0
  18. package/dist/components/pagination-bar.js.map +1 -1
  19. package/dist/components/pagination.js +3 -0
  20. package/dist/components/pagination.js.map +1 -1
  21. package/dist/components/panel-toolbar.js +3 -0
  22. package/dist/components/panel-toolbar.js.map +1 -1
  23. package/dist/components/plugin-download-consent.d.ts +16 -9
  24. package/dist/components/plugin-download-consent.js +89 -33
  25. package/dist/components/plugin-download-consent.js.map +1 -1
  26. package/dist/components/search-field.js +3 -0
  27. package/dist/components/search-field.js.map +1 -1
  28. package/dist/components/sidebar.js +3 -0
  29. package/dist/components/sidebar.js.map +1 -1
  30. package/dist/components/toast.js +3 -0
  31. package/dist/components/toast.js.map +1 -1
  32. package/dist/icons/actions.js +3 -0
  33. package/dist/icons/actions.js.map +1 -1
  34. package/dist/icons/registry.d.ts +2 -1
  35. package/dist/icons/registry.js +9 -1
  36. package/dist/icons/registry.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/components/plugin-download-consent.test.tsx +40 -4
  39. package/src/components/plugin-download-consent.tsx +130 -39
  40. package/src/icons/registry.ts +8 -0
@@ -9,6 +9,10 @@ import { useTranslation } from "react-i18next"
9
9
  * both host surfaces (the app and the workbench) so the user sees one
10
10
  * dialog with one contract wherever a plugin asks to download.
11
11
  *
12
+ * One entry = one batch question: a single download is an items array of
13
+ * one (rendered with the single-file layout); a batched plugin call is
14
+ * one dialog listing every item, answered all-or-nothing.
15
+ *
12
16
  * Presentational by design: the queue lives in the host's consent store
13
17
  * (`@hoardodile/host-web`), this component renders exactly one ticket
14
18
  * and reports the decision through callbacks. Copy comes from the shared
@@ -24,16 +28,20 @@ import { useTranslation } from "react-i18next"
24
28
  * (and to `@hoardodile/host-web`'s consent-store entry). Declared here
25
29
  * instead of imported so this package stays closure-clean.
26
30
  */
27
- export type PluginConsentTicket = {
28
- readonly ticketId: string
29
- readonly pluginId: string
30
- readonly pluginName: string
31
+ export type PluginConsentItem = {
31
32
  readonly url: string
32
33
  readonly dest: string
33
34
  readonly sizeBytes?: number
34
35
  readonly reason?: string
35
36
  }
36
37
 
38
+ export type PluginConsentTicket = {
39
+ readonly ticketId: string
40
+ readonly pluginId: string
41
+ readonly pluginName: string
42
+ readonly items: readonly PluginConsentItem[]
43
+ }
44
+
37
45
  export type PluginDownloadConsentDialogProps = {
38
46
  /** Ticket to show; `null` hides the dialog (one at a time). */
39
47
  readonly entry: PluginConsentTicket | null
@@ -45,10 +53,10 @@ export type PluginDownloadConsentDialogProps = {
45
53
  }
46
54
 
47
55
  /**
48
- * Renders the shared consent question: URL verbatim (selectable
49
- * monospace, never a link), the vault-relative destination and the
50
- * plugin's stated reason, with Allow / Deny and a session-remember
51
- * checkbox.
56
+ * Renders the shared consent question: every download URL verbatim
57
+ * (selectable monospace, never a link), its vault-relative destination
58
+ * and the plugin's stated reason, with Allow / Deny and a session-remember
59
+ * checkbox. A batch is one dialog listing all items.
52
60
  *
53
61
  * Design contract (hd-plugin-design): card + hairline + `--radius-2xl`
54
62
  * + `--shadow-dialog`, footer parted by an inset hairline, focus on the
@@ -61,6 +69,7 @@ export function PluginDownloadConsentDialog(
61
69
  const { t } = useTranslation("ui", { useSuspense: false })
62
70
  const formatBytes = props.formatBytes ?? ((bytes: number) => `${bytes} B`)
63
71
  const activeId = entry === null ? null : entry.ticketId
72
+ const itemCount = entry === null ? 0 : entry.items.length
64
73
  const [remember, setRemember] = useState(false)
65
74
 
66
75
  // A fresh ticket starts with "remember" unchecked — the choice must
@@ -75,14 +84,23 @@ export function PluginDownloadConsentDialog(
75
84
  onOpenChange={(open) => {
76
85
  if (!open && activeId !== null) onDeny(activeId)
77
86
  }}
78
- title={t("pluginDownload.title", undefined)}
87
+ title={
88
+ entry === null || itemCount <= 1
89
+ ? t("pluginDownload.title", undefined)
90
+ : t("pluginDownload.titleMany", { count: itemCount })
91
+ }
79
92
  eyebrow={t("pluginDownload.eyebrow", undefined)}
80
93
  description={
81
94
  entry === null
82
95
  ? undefined
83
- : t("pluginDownload.description", {
84
- pluginName: entry.pluginName,
85
- })
96
+ : itemCount <= 1
97
+ ? t("pluginDownload.description", {
98
+ pluginName: entry.pluginName,
99
+ })
100
+ : t("pluginDownload.descriptionMany", {
101
+ pluginName: entry.pluginName,
102
+ count: itemCount,
103
+ })
86
104
  }
87
105
  size="md"
88
106
  contentTestId="plugin-download-consent"
@@ -110,20 +128,26 @@ export function PluginDownloadConsentDialog(
110
128
  </div>
111
129
  }
112
130
  >
113
- {entry === null ? null : (
131
+ {entry === null ? null : itemCount <= 1 ? (
114
132
  <ConsentBody
115
- entry={entry}
133
+ entry={entry.items[0]!}
116
134
  remember={remember}
117
135
  onRememberChange={setRemember}
118
136
  formatBytes={formatBytes}
119
137
  />
138
+ ) : (
139
+ <ConsentBatch
140
+ items={entry.items}
141
+ remember={remember}
142
+ onRememberChange={setRemember}
143
+ />
120
144
  )}
121
145
  </AppDialog>
122
146
  )
123
147
  }
124
148
 
125
149
  function ConsentBody(props: {
126
- readonly entry: PluginConsentTicket
150
+ readonly entry: PluginConsentItem
127
151
  readonly remember: boolean
128
152
  readonly onRememberChange: (value: boolean) => void
129
153
  readonly formatBytes: (bytes: number) => string
@@ -132,17 +156,7 @@ function ConsentBody(props: {
132
156
  const { t } = useTranslation("ui", { useSuspense: false })
133
157
  return (
134
158
  <div className="flex flex-col gap-4">
135
- <div className="flex flex-col gap-1.5">
136
- <span className="text-xs tracking-label text-muted-foreground uppercase">
137
- {t("pluginDownload.urlLabel", undefined)}
138
- </span>
139
- <code
140
- className="rounded-sm bg-muted px-2 py-1.5 text-xs text-secondary-foreground break-all select-all"
141
- data-testid="plugin-download-url"
142
- >
143
- {entry.url}
144
- </code>
145
- </div>
159
+ <ConsentUrlField label={t("pluginDownload.urlLabel", undefined)} url={entry.url} />
146
160
  <div className="flex flex-col gap-1.5">
147
161
  <span className="text-xs tracking-label text-muted-foreground uppercase">
148
162
  {t("pluginDownload.destLabel", undefined)}
@@ -165,19 +179,96 @@ function ConsentBody(props: {
165
179
  {entry.reason}
166
180
  </p>
167
181
  ) : null}
168
- <div className="flex items-center gap-2 border-t pt-3">
169
- <Checkbox
170
- checked={remember}
171
- onCheckedChange={(checked) => onRememberChange(checked === true)}
172
- id="plugin-download-remember"
173
- />
174
- <label
175
- htmlFor="plugin-download-remember"
176
- className="text-xs text-secondary-foreground select-none"
177
- >
178
- {t("pluginDownload.remember", undefined)}
179
- </label>
180
- </div>
182
+ <RememberRow remember={remember} onRememberChange={onRememberChange} />
183
+ </div>
184
+ )
185
+ }
186
+
187
+ /** Batch layout: one row per download, URL verbatim + dest + reason. */
188
+ function ConsentBatch(props: {
189
+ readonly items: readonly PluginConsentItem[]
190
+ readonly remember: boolean
191
+ readonly onRememberChange: (value: boolean) => void
192
+ }) {
193
+ const { items, remember, onRememberChange } = props
194
+ const { t } = useTranslation("ui", { useSuspense: false })
195
+ return (
196
+ <div className="flex flex-col gap-4">
197
+ <ul className="flex max-h-52 flex-col gap-3 overflow-y-auto pr-1">
198
+ {items.map((item, index) => (
199
+ <li
200
+ key={`${item.url}#${item.dest}`}
201
+ className="flex flex-col gap-1 rounded-md border border-border px-2.5 py-2"
202
+ data-testid={`plugin-download-item-${index}`}
203
+ >
204
+ <ConsentUrlField
205
+ label={t("pluginDownload.itemLabel", {
206
+ index: index + 1,
207
+ })}
208
+ url={item.url}
209
+ testId={`plugin-download-url-${index}`}
210
+ />
211
+ <code
212
+ className="text-xs text-secondary-foreground break-all select-all"
213
+ data-testid={`plugin-download-dest-${index}`}
214
+ >
215
+ {item.dest}
216
+ </code>
217
+ {item.reason !== undefined && item.reason.length > 0 ? (
218
+ <p className="text-xs text-secondary-foreground italic">
219
+ {item.reason}
220
+ </p>
221
+ ) : null}
222
+ </li>
223
+ ))}
224
+ </ul>
225
+ <p className="text-xs text-muted-foreground">
226
+ {t("pluginDownload.batchNote", { count: items.length })}
227
+ </p>
228
+ <RememberRow remember={remember} onRememberChange={onRememberChange} />
229
+ </div>
230
+ )
231
+ }
232
+
233
+ function ConsentUrlField(props: {
234
+ readonly label: string
235
+ readonly url: string
236
+ readonly testId?: string
237
+ }) {
238
+ return (
239
+ <div className="flex min-w-0 flex-col gap-1">
240
+ <span className="text-xs tracking-label text-muted-foreground uppercase">
241
+ {props.label}
242
+ </span>
243
+ <code
244
+ className="rounded-sm bg-muted px-2 py-1.5 text-xs text-secondary-foreground break-all select-all"
245
+ data-testid={props.testId ?? "plugin-download-url"}
246
+ >
247
+ {props.url}
248
+ </code>
249
+ </div>
250
+ )
251
+ }
252
+
253
+ function RememberRow(props: {
254
+ readonly remember: boolean
255
+ readonly onRememberChange: (value: boolean) => void
256
+ }) {
257
+ const { remember, onRememberChange } = props
258
+ const { t } = useTranslation("ui", { useSuspense: false })
259
+ return (
260
+ <div className="flex items-center gap-2 border-t pt-3">
261
+ <Checkbox
262
+ checked={remember}
263
+ onCheckedChange={(checked) => onRememberChange(checked === true)}
264
+ id="plugin-download-remember"
265
+ />
266
+ <label
267
+ htmlFor="plugin-download-remember"
268
+ className="text-xs text-secondary-foreground select-none"
269
+ >
270
+ {t("pluginDownload.remember", undefined)}
271
+ </label>
181
272
  </div>
182
273
  )
183
274
  }
@@ -140,6 +140,7 @@ import { ServerIcon as ServerBoldWeight } from "@solar-icons/react/bold/server"
140
140
  import { SettingsIcon as SettingsBoldWeight } from "@solar-icons/react/bold/settings"
141
141
  import { ShareIcon as ShareBoldWeight } from "@solar-icons/react/bold/share"
142
142
  import { ShieldCheckIcon as ShieldCheckBoldWeight } from "@solar-icons/react/bold/shield-check"
143
+ import { Shop2Icon as Shop2BoldWeight } from "@solar-icons/react/bold/shop-2"
143
144
  import { SidebarMinimalisticIcon as SidebarMinimalisticBoldWeight } from "@solar-icons/react/bold/sidebar-minimalistic"
144
145
  import { SliderHorizontalIcon as SliderHorizontalBoldWeight } from "@solar-icons/react/bold/slider-horizontal"
145
146
  import { SmartphoneIcon as SmartphoneBoldWeight } from "@solar-icons/react/bold/smartphone"
@@ -281,6 +282,7 @@ import { ServerIcon as ServerBoldDuotone } from "@solar-icons/react/bold-duotone
281
282
  import { SettingsIcon as SettingsBoldDuotone } from "@solar-icons/react/bold-duotone/settings"
282
283
  import { ShareIcon as ShareBoldDuotone } from "@solar-icons/react/bold-duotone/share"
283
284
  import { ShieldCheckIcon as ShieldCheckBoldDuotone } from "@solar-icons/react/bold-duotone/shield-check"
285
+ import { Shop2Icon as Shop2BoldDuotone } from "@solar-icons/react/bold-duotone/shop-2"
284
286
  import { SidebarMinimalisticIcon as SidebarMinimalisticBoldDuotone } from "@solar-icons/react/bold-duotone/sidebar-minimalistic"
285
287
  import { SliderHorizontalIcon as SliderHorizontalBoldDuotone } from "@solar-icons/react/bold-duotone/slider-horizontal"
286
288
  import { SmartphoneIcon as SmartphoneBoldDuotone } from "@solar-icons/react/bold-duotone/smartphone"
@@ -421,6 +423,7 @@ import { ServerIcon as ServerLinear } from "@solar-icons/react/linear/server"
421
423
  import { SettingsIcon as SettingsLinear } from "@solar-icons/react/linear/settings"
422
424
  import { ShareIcon as ShareLinear } from "@solar-icons/react/linear/share"
423
425
  import { ShieldCheckIcon as ShieldCheckLinear } from "@solar-icons/react/linear/shield-check"
426
+ import { Shop2Icon as Shop2Linear } from "@solar-icons/react/linear/shop-2"
424
427
  import { SidebarMinimalisticIcon as SidebarMinimalisticLinear } from "@solar-icons/react/linear/sidebar-minimalistic"
425
428
  import { SliderHorizontalIcon as SliderHorizontalLinear } from "@solar-icons/react/linear/slider-horizontal"
426
429
  import { SmartphoneIcon as SmartphoneLinear } from "@solar-icons/react/linear/smartphone"
@@ -1001,6 +1004,11 @@ export const ShieldCheck = createIcon({
1001
1004
  boldDuotone: ShieldCheckBoldDuotone,
1002
1005
  linear: ShieldCheckLinear,
1003
1006
  })
1007
+ export const Shop2 = createIcon({
1008
+ bold: Shop2BoldWeight,
1009
+ boldDuotone: Shop2BoldDuotone,
1010
+ linear: Shop2Linear,
1011
+ })
1004
1012
  export const SidebarMinimalistic = createIcon({
1005
1013
  bold: SidebarMinimalisticBoldWeight,
1006
1014
  boldDuotone: SidebarMinimalisticBoldDuotone,