@kernhq/module-inventory 0.1.2 → 0.3.0

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 (213) hide show
  1. package/README.md +134 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -0,0 +1,347 @@
1
+ <script lang="ts">
2
+ import {
3
+ Avatar,
4
+ Button,
5
+ EmptyState,
6
+ formatDate,
7
+ formatDateTime,
8
+ messageLocale,
9
+ relativeTime,
10
+ Skeleton,
11
+ } from '@kernhq/ui'
12
+ import { createInfiniteQuery } from '@tanstack/svelte-query'
13
+ import type { AssetHistoryEntry } from '../../contract/index.js'
14
+ import { getInventoryApi } from '../api-instance.js'
15
+ import { isolate, isolated } from '../bidi.js'
16
+ import { t } from '../i18n.js'
17
+ import type { Directory } from '../members.js'
18
+ import { nameOf, resolveName } from '../members.js'
19
+ import { formatPrice } from '../price.js'
20
+ import { inventoryKeys } from '../query.js'
21
+ import { actionKey, changeLineKind, fieldKey, noteOf, personIdOf, subjectOf } from '../timeline.js'
22
+ import TimelineText from './TimelineText.svelte'
23
+
24
+ /**
25
+ * An asset's own timeline, as sentences.
26
+ *
27
+ * `asset_history` stores `{ action, changes: [{field, from, to}], data: {…ids} }`, which is a shape
28
+ * for a database. Rendered raw it is a JSON diff of somebody's own laptop; rendered here it is
29
+ * "Ada handed it to Bruno" and "Warranty until changed from Mar 14, 2027 to Mar 14, 2028" — "it"
30
+ * rather than the tag, because the panel's own header is already showing `INV-0042`.
31
+ *
32
+ * Which sentence, and about whom, is decided in `timeline.ts` — pure, and therefore tested. This
33
+ * file supplies the words and the layout and nothing else.
34
+ */
35
+ interface Props {
36
+ workspaceId: string
37
+ assetId: string
38
+ /** Who the workspace currently has, for turning a stored uuid into a name. */
39
+ dir: Directory
40
+ /** A category id as its name, or null when the id names nothing this workspace still lists. */
41
+ categoryName: (id: string) => string | null
42
+ /**
43
+ * The asset's currency now, for reading a stored `priceMinor` back into an amount.
44
+ *
45
+ * How many decimal places minor units carry is a fact about the currency — none for yen, three
46
+ * for the dinar — so a timeline that assumed hundredths printed every yen price 100× too small.
47
+ * An entry that *changed* the currency carries both sides itself and is read from those instead;
48
+ * this is the answer for every other entry, which is almost all of them.
49
+ */
50
+ currency: string | null
51
+ }
52
+ const { workspaceId, assetId, dir, categoryName, currency }: Props = $props()
53
+
54
+ const api = getInventoryApi()
55
+
56
+ interface HistoryPage {
57
+ items: AssetHistoryEntry[]
58
+ nextCursor: string | null
59
+ }
60
+
61
+ const historyQuery = createInfiniteQuery(() => ({
62
+ queryKey: inventoryKeys.assetHistory(workspaceId, assetId),
63
+ queryFn: ({ pageParam }): Promise<HistoryPage> =>
64
+ api.assets.history({
65
+ workspaceId,
66
+ assetId,
67
+ ...(pageParam ? { cursor: pageParam as string } : {}),
68
+ }) as Promise<HistoryPage>,
69
+ initialPageParam: undefined as string | undefined,
70
+ getNextPageParam: (last: HistoryPage) => last.nextCursor ?? undefined,
71
+ enabled: Boolean(workspaceId && assetId),
72
+ }))
73
+
74
+ const entries = $derived(historyQuery.data?.pages.flatMap((page) => page.items) ?? [])
75
+
76
+ /**
77
+ * The four words `nameOf` needs, read on render so they follow the interface language.
78
+ *
79
+ * Four rather than two, and that is the fix: an id the directory does not have used to read as
80
+ * "a former member" whether the member list was still in flight, had failed, or really did not
81
+ * contain them — so every entry said "A former member handed it to A former member" for the first
82
+ * moments of every panel, and for ever if the request failed. `members.ts` decides which of the
83
+ * four applies; this only supplies the wording.
84
+ */
85
+ const words = $derived({
86
+ loading: t('member_loading'),
87
+ unknown: t('member_unknown'),
88
+ former: t('member_former'),
89
+ system: t('member_system'),
90
+ })
91
+
92
+ /**
93
+ * One value, in the units a person reads it in.
94
+ *
95
+ * A stored diff holds what the column holds: minor units for a price, an ISO date for a date, a
96
+ * uuid for a category. "priceMinor changed from 129900 to 149900" is arithmetic homework, and
97
+ * "categoryId changed from 0192… to 0192…" is not a sentence at all.
98
+ */
99
+ function readable(field: string, value: unknown, money: string | null): string {
100
+ if (value === null || value === undefined || value === '') return '—'
101
+ if (field === 'categoryId') return categoryName(String(value)) ?? t('category_none')
102
+ if (field === 'priceMinor') return formatPrice(Number(value), messageLocale(), money)
103
+ if (field === 'purchasedOn' || field === 'warrantyUntil') return formatDate(String(value))
104
+ return String(value)
105
+ }
106
+
107
+ /**
108
+ * Which currency one side of one entry is in — the entry's own answer where it has one.
109
+ *
110
+ * Somebody who re-priced a laptop from ¥120000 to €890 changed two columns in one edit, so the
111
+ * entry carries a `currency` change beside the `priceMinor` change and each side of the sentence
112
+ * is in a different currency. Reading both through today's currency would print one of them
113
+ * wrong, and it is the *old* one — the number nobody can check any more.
114
+ */
115
+ function currencyFor(entry: AssetHistoryEntry, side: 'from' | 'to'): string | null {
116
+ const changed = entry.changes.find((change) => change.field === 'currency')
117
+ if (!changed) return currency
118
+ const value = changed[side]
119
+ return typeof value === 'string' && value ? value : null
120
+ }
121
+
122
+ /**
123
+ * The label for one field, and the only value here that must *not* be isolated afterwards.
124
+ *
125
+ * A key that resolves is a translated word and belongs to the sentence around it. A field this
126
+ * client has never heard of prints its own name — ugly and true, because the rows outlive the image
127
+ * that wrote them — and that one is a Latin identifier dropped into a Persian sentence, so it is
128
+ * isolated here rather than left to reorder the words on either side of it.
129
+ */
130
+ function fieldLabel(field: string): string {
131
+ const key = fieldKey(field)
132
+ return key ? t(key) : isolate(field)
133
+ }
134
+
135
+ /**
136
+ * One line under an entry, for one field that changed.
137
+ *
138
+ * `changeLineKind` in `timeline.ts` decides which of the five sentences applies — including the one
139
+ * this used to get wrong, where a `description` change read its whole eight-kilobyte value out into
140
+ * a row of a 440px panel. `text_changed` says that it changed and `TimelineText` carries the text.
141
+ *
142
+ * Every value goes through `isolated()`: a serial number, a location, a price and a category name
143
+ * are all values somebody typed, and a Latin one inside «{field} از {from} به {to} تغییر کرد»
144
+ * loses its own punctuation to the sentence around it.
145
+ */
146
+ function changeLine(entry: AssetHistoryEntry, change: { field: string; from: unknown; to: unknown }): string {
147
+ const field = fieldLabel(change.field)
148
+ const kind = changeLineKind(change.field, change.from, change.to)
149
+ if (kind === 'cleared') return t('history_cleared', isolated({ field }))
150
+ if (kind === 'replaced') return t('history_replaced', isolated({ field }))
151
+ if (kind === 'text_changed') return t('history_text_changed', isolated({ field }))
152
+ if (kind === 'set')
153
+ return t(
154
+ 'history_set',
155
+ isolated({ field, to: readable(change.field, change.to, currencyFor(entry, 'to')) }),
156
+ )
157
+ return t(
158
+ 'history_changed',
159
+ isolated({
160
+ field,
161
+ from: readable(change.field, change.from, currencyFor(entry, 'from')),
162
+ to: readable(change.field, change.to, currencyFor(entry, 'to')),
163
+ }),
164
+ )
165
+ }
166
+
167
+ /** The text a `text_changed` line discloses. Null for `from` when there was nothing there before. */
168
+ const textOf = (value: unknown): string | null =>
169
+ value === null || value === undefined || value === '' ? null : String(value)
170
+
171
+ function headline(entry: AssetHistoryEntry): string {
172
+ const personId = personIdOf(entry.action, entry.data)
173
+ // `action` is a machine token — `repair_logged` — and lands inside a translated sentence for an
174
+ // action this client has no wording for, so it is isolated with the two names.
175
+ return t(
176
+ actionKey(entry.action),
177
+ isolated({
178
+ actor: nameOf(entry.actorId, dir, words),
179
+ person: nameOf(personId, dir, words),
180
+ action: entry.action,
181
+ }),
182
+ )
183
+ }
184
+
185
+ const SKELETON_ROWS = [0, 1, 2]
186
+ </script>
187
+
188
+ {#if historyQuery.isPending}
189
+ <ul class="skeleton">
190
+ {#each SKELETON_ROWS as row (row)}
191
+ <li class="srow">
192
+ <Skeleton height="22px" width="22px" radius="999px" />
193
+ <div class="slines">
194
+ <Skeleton height="12px" width="62%" />
195
+ <Skeleton height="10px" width="28%" />
196
+ </div>
197
+ </li>
198
+ {/each}
199
+ </ul>
200
+ {:else if historyQuery.isError}
201
+ <EmptyState bare compact icon="triangle-alert" title={t('history_error')}>
202
+ {#snippet actions()}
203
+ <Button size="sm" variant="secondary" onclick={() => void historyQuery.refetch()}>
204
+ {t('common.retry')}
205
+ </Button>
206
+ {/snippet}
207
+ </EmptyState>
208
+ {:else if entries.length === 0}
209
+ <!-- Unreachable in practice — `create` writes the first entry in the same transaction as the
210
+ asset — and rendered honestly all the same, because "unreachable" is a claim about today. -->
211
+ <EmptyState bare compact icon="clock" title={t('history_empty')} />
212
+ {:else}
213
+ <ol class="entries">
214
+ {#each entries as entry (entry.id)}
215
+ <!-- The avatar shows initials only for somebody actually resolved. Its colour is seeded from
216
+ the id either way, so it does not change when the member list arrives — and an unresolved
217
+ actor gets a plain square rather than the initials of "…". -->
218
+ {@const actor = resolveName(entry.actorId, dir)}
219
+ {@const note = noteOf(entry.data)}
220
+ {@const subject = subjectOf(entry.action, entry.data)}
221
+ <li class="entry">
222
+ <Avatar name={actor.name} id={entry.actorId} size={22} />
223
+ <div class="body">
224
+ <p class="line">{headline(entry)}</p>
225
+ <!-- What the entry is about — a repair's summary, a file's name. Rendered bare rather
226
+ than through the catalogue: it is text somebody typed, not an interface string. -->
227
+ {#if subject}<p class="subject">{subject}</p>{/if}
228
+ {#if entry.changes.length}
229
+ <ul class="changes">
230
+ {#each entry.changes as change (change.field)}
231
+ <li>
232
+ {changeLine(entry, change)}
233
+ {#if changeLineKind(change.field, change.from, change.to) === 'text_changed'}
234
+ <TimelineText
235
+ before={textOf(change.from)}
236
+ after={textOf(change.to) ?? ''}
237
+ />
238
+ {/if}
239
+ </li>
240
+ {/each}
241
+ </ul>
242
+ {/if}
243
+ {#if note}
244
+ <p class="note">{t('history_note', isolated({ note }))}</p>
245
+ {/if}
246
+ <!-- The exact moment is on the element, so a hover and a screen reader both get it;
247
+ the visible text is relative, which is what somebody reading a timeline wants. -->
248
+ <time class="when" datetime={entry.occurredAt} title={formatDateTime(entry.occurredAt)}>
249
+ {relativeTime(entry.occurredAt)}
250
+ </time>
251
+ </div>
252
+ </li>
253
+ {/each}
254
+ </ol>
255
+
256
+ {#if historyQuery.hasNextPage}
257
+ <div class="more">
258
+ <Button
259
+ size="sm"
260
+ variant="secondary"
261
+ loading={historyQuery.isFetchingNextPage}
262
+ onclick={() => historyQuery.fetchNextPage()}
263
+ >
264
+ {t('load_more')}
265
+ </Button>
266
+ </div>
267
+ {/if}
268
+ {/if}
269
+
270
+ <style>
271
+ .entries,
272
+ .skeleton {
273
+ display: flex;
274
+ flex-direction: column;
275
+ gap: 14px;
276
+ margin: 0;
277
+ padding: 0;
278
+ list-style: none;
279
+ }
280
+ .entry,
281
+ .srow {
282
+ display: grid;
283
+ /* Logical, so the avatar sits on the reading-start edge in Persian and Arabic too. */
284
+ grid-template-columns: 22px minmax(0, 1fr);
285
+ gap: 10px;
286
+ align-items: start;
287
+ }
288
+ .slines {
289
+ display: flex;
290
+ flex-direction: column;
291
+ gap: 6px;
292
+ }
293
+ .body {
294
+ display: flex;
295
+ flex-direction: column;
296
+ gap: 3px;
297
+ min-width: 0;
298
+ }
299
+ .line {
300
+ margin: 0;
301
+ font-size: 13px;
302
+ line-height: 1.5;
303
+ color: var(--kern-ink-900);
304
+ }
305
+ .changes {
306
+ margin: 2px 0 0;
307
+ /* Logical: a bulleted list indents from the reading-start edge in both directions. */
308
+ padding-inline-start: 16px;
309
+ display: flex;
310
+ flex-direction: column;
311
+ gap: 2px;
312
+ font-size: 12.5px;
313
+ line-height: 1.5;
314
+ /* Muted with a colour rather than with opacity, which fades text against the page. */
315
+ color: var(--kern-ink-600);
316
+ }
317
+ .note {
318
+ margin: 2px 0 0;
319
+ font-size: 12.5px;
320
+ line-height: 1.5;
321
+ color: var(--kern-ink-600);
322
+ border-inline-start: 2px solid var(--kern-border-hairline);
323
+ padding-inline-start: 8px;
324
+ }
325
+ .subject {
326
+ margin: 1px 0 0;
327
+ font-size: 12.5px;
328
+ line-height: 1.5;
329
+ /* Muted with a colour, never with opacity: a faded line at 0.5 is unreadable whatever its
330
+ token says, and this one carries the only specific word in the entry. */
331
+ color: var(--kern-ink-600);
332
+ overflow-wrap: anywhere;
333
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
334
+ first strong character, so a Latin name inside a Persian panel reads left to right and keeps
335
+ its own trailing punctuation instead of donating it to the paragraph. */
336
+ unicode-bidi: plaintext;
337
+ }
338
+ .when {
339
+ font-size: 11.5px;
340
+ color: var(--kern-ink-500);
341
+ }
342
+ .more {
343
+ display: flex;
344
+ justify-content: center;
345
+ padding-top: 12px;
346
+ }
347
+ </style>
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ import { t } from '../i18n.js'
3
+
4
+ /**
5
+ * The two versions of a long-text field, behind a disclosure.
6
+ *
7
+ * A description is up to eight thousand characters, and the timeline rendered a change to one as
8
+ * "Description changed from … to …" with both versions inline: sixteen kilobytes of somebody's
9
+ * prose in one row of a 440px panel, with every entry below it pushed off the screen. The line
10
+ * above this now says only *that* it changed, and the text lives here.
11
+ *
12
+ * **A component rather than a bare `<details>` in the timeline, for one reason:** `{#if open}`.
13
+ * `<details>` keeps its content in the document whether or not it is open, so fifty entries would
14
+ * still be four hundred kilobytes of text nodes the browser has to build and lay out. Binding
15
+ * `open` and gating on it means the prose exists only while somebody is reading it, and the
16
+ * disclosure is still a real `<details>` — keyboard-reachable, announced as a disclosure, and
17
+ * open by default in a printed page. Nothing is hidden: it is one keystroke away and shown whole,
18
+ * because truncating what somebody deliberately opened would be the same defect one layer down.
19
+ *
20
+ * `before` is null when there was nothing there — a description filled in for the first time has
21
+ * no previous version, and an empty "Before" block would invent one.
22
+ */
23
+ interface Props {
24
+ before: string | null
25
+ after: string
26
+ }
27
+ const { before, after }: Props = $props()
28
+
29
+ let open = $state(false)
30
+ </script>
31
+
32
+ <details class="disclosure" bind:open>
33
+ <summary>{t('history_show_text')}</summary>
34
+ {#if open}
35
+ <div class="body">
36
+ {#if before !== null}
37
+ <p class="label">{t('history_text_before')}</p>
38
+ <!-- `pre-wrap`, so the paragraphs somebody typed are the paragraphs they read back. -->
39
+ <blockquote class="text was">{before}</blockquote>
40
+ {/if}
41
+ <p class="label">{t('history_text_after')}</p>
42
+ <blockquote class="text">{after}</blockquote>
43
+ </div>
44
+ {/if}
45
+ </details>
46
+
47
+ <style>
48
+ .disclosure {
49
+ margin-top: 3px;
50
+ font-size: 12px;
51
+ }
52
+ summary {
53
+ /* `list-style` on the summary is what removes the native marker in Firefox; the ::-webkit rule
54
+ below is what removes it in Safari and Chrome. Neither alone does both. */
55
+ list-style: none;
56
+ display: inline-flex;
57
+ align-items: center;
58
+ gap: 4px;
59
+ width: fit-content;
60
+ cursor: pointer;
61
+ color: var(--kern-ink-600);
62
+ /* WCAG 2.5.8 wants 24px and a line of 12px text is about 16. The padding grows the hit area
63
+ and the equal negative margin gives the space back, so nothing moves. */
64
+ padding-block: 4px;
65
+ margin-block: -4px;
66
+ border-radius: var(--kern-r-sm);
67
+ }
68
+ summary::-webkit-details-marker {
69
+ display: none;
70
+ }
71
+ summary:hover {
72
+ color: var(--kern-ink-900);
73
+ }
74
+ /* The disclosure triangle, drawn rather than borrowed, so it turns with the reading direction:
75
+ `▸` in an Arabic panel points the wrong way, and a rotated glyph does not. */
76
+ summary::before {
77
+ content: '';
78
+ width: 0;
79
+ height: 0;
80
+ border-block: 3.5px solid transparent;
81
+ border-inline-start: 5px solid currentColor;
82
+ transition: transform 120ms ease;
83
+ }
84
+ .disclosure[open] summary::before {
85
+ /* Logical would be ideal and there is no logical rotation; the sign is flipped in RTL below. */
86
+ transform: rotate(90deg);
87
+ }
88
+ :global([dir='rtl']) .disclosure[open] summary::before {
89
+ transform: rotate(-90deg);
90
+ }
91
+ .body {
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: 2px;
95
+ margin-top: 6px;
96
+ }
97
+ .label {
98
+ margin: 4px 0 0;
99
+ font-size: 11px;
100
+ font-weight: 600;
101
+ letter-spacing: 0.03em;
102
+ text-transform: uppercase;
103
+ /* Muted with a colour, never opacity: a faded label is unreadable whatever its token says. */
104
+ color: var(--kern-ink-500);
105
+ }
106
+ .text {
107
+ margin: 0;
108
+ padding-inline-start: 8px;
109
+ border-inline-start: 2px solid var(--kern-border-hairline);
110
+ font-size: 12.5px;
111
+ line-height: 1.55;
112
+ color: var(--kern-ink-700);
113
+ white-space: pre-wrap;
114
+ overflow-wrap: anywhere;
115
+ /* A description somebody typed decides its own direction: `plaintext` takes it from the
116
+ text's own first strong character, so an English paragraph inside a Persian panel reads
117
+ left to right and keeps its own punctuation. `isolate` would not — on a block it leaves
118
+ the paragraph direction inherited. */
119
+ unicode-bidi: plaintext;
120
+ }
121
+ .was {
122
+ color: var(--kern-ink-500);
123
+ }
124
+ </style>
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The slice of core's API this module reaches for, named by shape rather than imported.
3
+ *
4
+ * A module talks to another module through `kernel.call()` on the server; on the client the shell
5
+ * hands over its own configured core client, and typing the seam structurally keeps the dependency
6
+ * pointing one way — inventory does not import core's router type, and core does not know inventory
7
+ * exists.
8
+ *
9
+ * Keep it to what is actually called. A wide type here is a promise about core's surface that this
10
+ * module has no standing to make: `Member` in core carries a role, role ids, group ids, a status
11
+ * and an invitation trail, and naming any of that here would make this module's screens break when
12
+ * a field it never reads changes shape. Four procedures are all of it, and each is gated in
13
+ * `core/src/modules/core/router.ts` — the two settings calls on `core.modules.manage`, the member
14
+ * list on `core.members.view`, the download URL on the file's own workspace membership.
15
+ */
16
+
17
+ /**
18
+ * A workspace member, as thinly as a picker and an avatar need one.
19
+ *
20
+ * `name` is nullable in core — somebody invited and not yet signed in has an email and nothing
21
+ * else — so every screen here falls back to the email rather than rendering an empty label.
22
+ */
23
+ export interface CoreMember {
24
+ userId: string
25
+ user: {
26
+ id: string
27
+ name: string | null
28
+ email: string
29
+ avatarUrl?: string | null
30
+ }
31
+ }
32
+
33
+ export interface CoreApi {
34
+ workspaces: {
35
+ members: {
36
+ /** Paged in core; this module asks for one large page and keeps the people, not the envelope. */
37
+ list(input: { workspaceId: string; limit?: number }): Promise<{ items: CoreMember[] }>
38
+ }
39
+ modules: {
40
+ list(input: { workspaceId: string }): Promise<
41
+ Array<{
42
+ manifest: { id: string }
43
+ state: {
44
+ enabled: boolean
45
+ settings?: Record<string, unknown>
46
+ }
47
+ }>
48
+ >
49
+ updateSettings(input: {
50
+ workspaceId: string
51
+ moduleId: string
52
+ settings: Record<string, unknown>
53
+ }): Promise<unknown>
54
+ }
55
+ }
56
+ /**
57
+ * Reading a file back.
58
+ *
59
+ * This module records that an asset has a file and never touches a byte — the bytes are core's,
60
+ * the URL is core's to sign, and it is signed for the person asking rather than for this module.
61
+ * `thumbnail: true` is what the asset photo asks for; a full-size photo in a 440px panel is a
62
+ * megabyte nobody looks at.
63
+ */
64
+ files: {
65
+ downloadUrl(input: {
66
+ id: string
67
+ disposition?: 'inline' | 'attachment'
68
+ thumbnail?: boolean
69
+ }): Promise<{ url: string }>
70
+ }
71
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { custodyActions } from './custody.js'
3
+
4
+ /**
5
+ * What the panel is allowed to offer.
6
+ *
7
+ * A decision rather than rendering, and wrong in a way nothing else would catch: a button that
8
+ * always produces a conflict is a door that will not open. What a stored uuid reads as is the
9
+ * neighbouring question and lives in `members.test.ts`, beside the file that answers it.
10
+ */
11
+ describe('custodyActions', () => {
12
+ it('offers handing over when nobody has it, and handing on or back when somebody does', () => {
13
+ expect(custodyActions({ held: false, archived: false, may: true })).toEqual(['assign'])
14
+ expect(custodyActions({ held: true, archived: false, may: true })).toEqual(['transfer', 'return'])
15
+ })
16
+
17
+ it('offers nothing without the permission — hidden, not disabled', () => {
18
+ expect(custodyActions({ held: false, archived: false, may: false })).toEqual([])
19
+ expect(custodyActions({ held: true, archived: false, may: false })).toEqual([])
20
+ })
21
+
22
+ it('offers nothing on an archived item, which the server refuses anyway', () => {
23
+ expect(custodyActions({ held: false, archived: true, may: true })).toEqual([])
24
+ expect(custodyActions({ held: true, archived: true, may: true })).toEqual([])
25
+ })
26
+
27
+ it('never offers assigning something that is already held', () => {
28
+ // The one combination that would 409 every single time it was pressed.
29
+ expect(custodyActions({ held: true, archived: false, may: true })).not.toContain('assign')
30
+ })
31
+ })
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which handover an asset can actually take right now.
3
+ *
4
+ * Three verbs and not one, because the server has three procedures and refuses the wrong one with
5
+ * its own sentence: assigning something somebody already holds is a mistake worth naming, and
6
+ * handing on something nobody holds is a different mistake. The interface's job is not to offer
7
+ * either — a button that always produces a conflict is a door that will not open.
8
+ *
9
+ * A `.ts` file rather than a `const` inside the panel for the reason `timeline.ts` and `price.ts`
10
+ * are: this is a fact with a right answer, and a `.svelte` file cannot be unit-tested.
11
+ */
12
+ export type CustodyAction = 'assign' | 'transfer' | 'return'
13
+
14
+ export interface CustodyState {
15
+ /** Somebody is holding it — `assets.custodian_user_id` is set. */
16
+ held: boolean
17
+ /** The workspace has taken it out of the register; the server refuses every handover. */
18
+ archived: boolean
19
+ /** This person holds `inventory.custody.manage`. */
20
+ may: boolean
21
+ }
22
+
23
+ /**
24
+ * What to offer, in the order the buttons appear.
25
+ *
26
+ * Empty for somebody without the permission — hide what a person may never do — and empty for an
27
+ * archived item, where the panel says *why* instead. An archived item deliberately offers nothing
28
+ * rather than a disabled row of buttons: the reason is one sentence, and one sentence beats three
29
+ * controls that cannot be pressed.
30
+ */
31
+ export function custodyActions(state: CustodyState): CustodyAction[] {
32
+ if (!state.may || state.archived) return []
33
+ return state.held ? ['transfer', 'return'] : ['assign']
34
+ }