@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
@@ -1,23 +1,44 @@
1
1
  <script lang="ts">
2
2
  import {
3
3
  Badge,
4
- type BadgeTone,
5
4
  Button,
5
+ coreApi,
6
+ Dialog,
7
+ DropdownMenu,
6
8
  EmptyState,
9
+ formatDate,
10
+ IconButton,
7
11
  Input,
12
+ keys,
13
+ type MenuItem,
14
+ navigation,
15
+ Page,
16
+ PageHeader,
8
17
  Select,
9
18
  type SelectOption,
10
19
  Skeleton,
11
- Spinner,
12
- StatTile,
20
+ Switch,
13
21
  session,
22
+ Table,
23
+ TableCell,
24
+ TableHeader,
25
+ TableRow,
26
+ toast,
14
27
  } from '@kernhq/ui'
15
- import { createQuery } from '@tanstack/svelte-query'
28
+ import { createInfiniteQuery, createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
29
+ import type { Asset, AssetStatus, Category } from '../../contract/index.js'
16
30
  import { getInventoryApi } from '../api-instance.js'
31
+ import { isolated } from '../bidi.js'
32
+ import AssetDetailPanel from '../components/AssetDetailPanel.svelte'
17
33
  import AssetFormDialog from '../components/AssetFormDialog.svelte'
34
+ import type { CoreApi, CoreMember } from '../core-api.js'
35
+ import { errorMessage } from '../errors.js'
18
36
  import { t } from '../i18n.js'
19
- import { canInventory, INVENTORY_PERMISSIONS } from '../permissions.js'
37
+ import { ASSET_PARAM } from '../links.js'
38
+ import { directory, directoryStatus, nameOf } from '../members.js'
39
+ import { canInventory } from '../permissions.js'
20
40
  import { inventoryKeys } from '../query.js'
41
+ import { statusTone } from '../status.js'
21
42
 
22
43
  /**
23
44
  * This module's screen.
@@ -35,232 +56,704 @@ interface Props {
35
56
  const { workspaceId }: Props = $props()
36
57
 
37
58
  const api = getInventoryApi()
59
+ const core = coreApi<CoreApi>()
60
+ const queryClient = useQueryClient()
38
61
 
39
- let search = $state('')
62
+ let searchText = $state('')
40
63
  let statusFilter = $state<string>('')
64
+ let categoryFilter = $state<string>('')
41
65
  let showArchived = $state(false)
42
66
  let dialogOpen = $state(false)
67
+ /** The row the dialog is editing, or null when it is adding one. */
68
+ let editingAsset = $state<Asset | null>(null)
69
+ /** The row awaiting an archive confirmation. */
70
+ let archiving = $state<Asset | null>(null)
43
71
 
44
72
  // Debounce the search into the cache key, so typing does not refetch per keystroke.
45
- let q = $state('')
73
+ let query = $state('')
46
74
  $effect(() => {
47
- const value = search
48
- const timer = setTimeout(() => (q = value), 250)
75
+ const value = searchText
76
+ const timer = setTimeout(() => (query = value), 250)
49
77
  return () => clearTimeout(timer)
50
78
  })
51
79
 
52
- const filters = $derived.by(() => {
53
- const f: Record<string, unknown> = {}
54
- if (q) f.q = q
55
- if (statusFilter) f.status = statusFilter
56
- if (!showArchived) f.archived = false
57
- return Object.keys(f).length ? f : undefined
80
+ /**
81
+ * The two filters that live in the URL rather than in this component.
82
+ *
83
+ * `?asset=<id>` is which panel is open, and `?custodian=<userId>` is "what is this person holding"
84
+ * — the return list an offboarding notification links to. Both are in the URL for the same reason:
85
+ * they are places somebody went, so they survive a reload, can be shared with the person who has to
86
+ * act on them, and close on Back. Built from `navigation` because a module cannot read `$app/state`
87
+ * — it is a SvelteKit alias, and this package is type-checked on its own.
88
+ */
89
+ const params = $derived(new URLSearchParams(navigation.search))
90
+ const custodianFilter = $derived(params.get('custodian') ?? '')
91
+
92
+ /**
93
+ * Every filter is part of the key and part of the request.
94
+ *
95
+ * Filtering in the browser is only ever right for a list that is entirely loaded, and this one is
96
+ * paged: dropping archived rows client-side returned twenty rows, showed eleven, and made the list
97
+ * look short rather than paged.
98
+ */
99
+ const filters = $derived({
100
+ ...(query ? { q: query } : {}),
101
+ ...(statusFilter ? { status: statusFilter as AssetStatus } : {}),
102
+ ...(categoryFilter ? { categoryId: categoryFilter } : {}),
103
+ ...(custodianFilter ? { custodianUserId: custodianFilter } : {}),
104
+ archived: showArchived,
58
105
  })
59
106
 
60
- const assetsQuery = createQuery(() => ({
107
+ /**
108
+ * Whether anything is *narrowing* the list.
109
+ *
110
+ * The archived switch is not in here: it widens the list rather than narrowing it, so a workspace
111
+ * with no assets and the switch on is still empty rather than filtered, and offering to "clear" it
112
+ * would hide rows somebody just asked to see. The custodian **is** in here, so the count line stops
113
+ * claiming the workspace total the moment the list is one person's.
114
+ */
115
+ const narrowed = $derived(
116
+ Boolean(query) || Boolean(statusFilter) || Boolean(categoryFilter) || Boolean(custodianFilter),
117
+ )
118
+
119
+ function clearFilters() {
120
+ searchText = ''
121
+ query = ''
122
+ statusFilter = ''
123
+ categoryFilter = ''
124
+ setParam('custodian', null)
125
+ }
126
+
127
+ /**
128
+ * The people this workspace has, asked for only when there is a uuid on screen to turn into a name.
129
+ *
130
+ * The same key `AssetDetailPanel` uses, so opening a panel afterwards costs no second request — and
131
+ * no request at all in the ordinary case, where nothing is filtered by custodian.
132
+ */
133
+ const membersQuery = createQuery(() => ({
134
+ queryKey: keys.members(workspaceId),
135
+ queryFn: () => core.workspaces.members.list({ workspaceId }),
136
+ enabled: Boolean(workspaceId) && Boolean(custodianFilter),
137
+ }))
138
+ const dir = $derived(
139
+ directory((membersQuery.data?.items ?? []) as CoreMember[], directoryStatus(membersQuery)),
140
+ )
141
+ /**
142
+ * Whose list this is.
143
+ *
144
+ * `nameOf` never answers with the uuid: somebody who has since been removed from the workspace
145
+ * reads as "a former member", which is exactly the case this filter exists for — the offboarding
146
+ * notification is *about* somebody who has left.
147
+ *
148
+ * It only says so once the member list has actually arrived. Before that this line claimed the
149
+ * person had left the workspace while their name was still being fetched, on the one screen whose
150
+ * whole purpose is to be *about* a named person.
151
+ */
152
+ const custodianName = $derived(
153
+ custodianFilter
154
+ ? nameOf(custodianFilter, dir, {
155
+ loading: t('member_loading'),
156
+ unknown: t('member_unknown'),
157
+ former: t('member_former'),
158
+ system: t('member_system'),
159
+ })
160
+ : null,
161
+ )
162
+
163
+ interface AssetPage {
164
+ items: Asset[]
165
+ nextCursor: string | null
166
+ /** `page()` in `@kernhq/contracts` declares it; this module's server does not fill it yet. */
167
+ total?: number
168
+ }
169
+
170
+ const assetsQuery = createInfiniteQuery(() => ({
61
171
  queryKey: inventoryKeys.assets(workspaceId, filters),
62
- queryFn: () =>
172
+ queryFn: ({ pageParam }): Promise<AssetPage> =>
63
173
  api.assets.list({
64
174
  workspaceId,
65
- ...(q ? { q } : {}),
66
- ...(statusFilter
67
- ? { status: statusFilter as 'in_stock' | 'assigned' | 'under_repair' | 'retired' }
68
- : {}),
69
- }),
175
+ ...filters,
176
+ ...(pageParam ? { cursor: pageParam as string } : {}),
177
+ }) as Promise<AssetPage>,
178
+ initialPageParam: undefined as string | undefined,
179
+ getNextPageParam: (last: AssetPage) => last.nextCursor ?? undefined,
70
180
  enabled: Boolean(workspaceId),
71
181
  }))
72
182
 
73
- const assets = $derived(assetsQuery.data?.items ?? [])
74
- const shownAssets = $derived(showArchived ? assets : assets.filter((a) => !a.archivedAt))
75
- const counts = $derived({
76
- total: assets.length,
77
- assigned: assets.filter((a) => a.status === 'assigned').length,
78
- inStock: assets.filter((a) => a.status === 'in_stock').length,
79
- repair: assets.filter((a) => a.status === 'under_repair').length,
80
- })
183
+ const assets = $derived(assetsQuery.data?.pages.flatMap((page) => page.items) ?? [])
184
+
185
+ /**
186
+ * The workspace's categories, **archived ones included**.
187
+ *
188
+ * One query rather than two, and it asks for everything because the two things it feeds want
189
+ * different halves: the chip on a row has to name the category an asset carries even after somebody
190
+ * archived it — that is the whole point of archiving rather than deleting — while the filter offers
191
+ * only the live ones, because filtering by something nobody can file anything under is a dead end.
192
+ *
193
+ * The key is the same one `AssetDetailPanel` uses, so opening a panel costs no second request.
194
+ */
195
+ const categoriesQuery = createQuery(() => ({
196
+ queryKey: inventoryKeys.categories(workspaceId, true),
197
+ queryFn: () => api.categories.list({ workspaceId, archived: true }),
198
+ enabled: Boolean(workspaceId),
199
+ }))
200
+ const categories = $derived<Category[]>(categoriesQuery.data ?? [])
201
+ const categoryNames = $derived(new Map(categories.map((row) => [row.id, row.name])))
202
+
203
+ /**
204
+ * What the filter offers: the live categories, plus whichever one is currently chosen.
205
+ *
206
+ * The second half matters exactly once, and it is not hypothetical — somebody filters by "Cameras",
207
+ * an administrator archives it in another tab, and without this the control would show an empty
208
+ * value while the list stayed filtered. A filter that does not say what it is filtering by is worse
209
+ * than one offering a category nobody can file anything new under.
210
+ */
211
+ const categoryOptions = $derived<SelectOption[]>([
212
+ { value: '', label: t('filter_all_categories') },
213
+ ...categories
214
+ .filter((row) => !row.archivedAt || row.id === categoryFilter)
215
+ .map((row) => ({ value: row.id, label: row.name })),
216
+ ])
217
+
218
+ // ------------------------------------------------------------------- the panel, and the URL
81
219
 
220
+ /**
221
+ * Which asset is open lives in the URL, not in this component's state. See `params` above.
222
+ *
223
+ * The parameter's name comes from `links.ts`, which is also where a dashboard card builds the link
224
+ * that sets it — one constant, so a card cannot send somebody to a URL this page ignores.
225
+ */
226
+ const openAssetId = $derived(params.get(ASSET_PARAM))
227
+
228
+ /**
229
+ * Set or clear one search parameter, leaving every other one alone.
230
+ *
231
+ * One function rather than one per parameter, because "keep the rest of the URL" is the part that
232
+ * is easy to get wrong: clearing the custodian filter by rebuilding the query string from scratch
233
+ * would close the panel somebody has open.
234
+ *
235
+ * A push, deliberately — not the `replaceState` a filter change would use. Opening a panel, and
236
+ * arriving on somebody's return list from a notification, are both places somebody went, so Back
237
+ * should undo them; a keystroke in the search box is not, and a list that pushed every one of those
238
+ * would fill the back button with states nobody meant to visit.
239
+ */
240
+ function setParam(name: string, value: string | null) {
241
+ const next = new URLSearchParams(navigation.search)
242
+ if (value) next.set(name, value)
243
+ else next.delete(name)
244
+ const search = next.toString()
245
+ void navigation.go(`${navigation.pathname}${search ? `?${search}` : ''}`, {
246
+ keepFocus: true,
247
+ noScroll: true,
248
+ })
249
+ }
250
+
251
+ const setAsset = (id: string | null) => setParam(ASSET_PARAM, id)
252
+
253
+ /**
254
+ * The register in numbers, for the one line that has to be a number.
255
+ *
256
+ * One request for the whole workspace rather than a `count(*)` bolted onto every page of a keyset
257
+ * list — which is the expensive way to answer a question that does not change between pages.
258
+ */
259
+ const statsQuery = createQuery(() => ({
260
+ queryKey: inventoryKeys.stats(workspaceId),
261
+ queryFn: () => api.stats.summary({ workspaceId }),
262
+ enabled: Boolean(workspaceId),
263
+ }))
264
+
265
+ /**
266
+ * A count that stays true while the list pages.
267
+ *
268
+ * `t('count', { n: assets.length })` counted the pages *loaded*, so 120 assets read "50 assets" and
269
+ * then "100 assets" after Load more — a number that is simply wrong, on the line whose whole job is
270
+ * to be the number.
271
+ *
272
+ * Three answers, in order of how true they are. **The workspace total** when nothing is narrowing
273
+ * the list and archived rows are out, because then the list *is* every live asset and
274
+ * `stats.summary` has counted them. **What the server said** if it ever fills `total` on a page.
275
+ * Otherwise how many are being *shown*, which is honest about being a partial answer rather than
276
+ * claiming a filtered, half-loaded list is all there is.
277
+ */
278
+ const reportedTotal = $derived(assetsQuery.data?.pages.at(-1)?.total)
279
+ const workspaceTotal = $derived(!narrowed && !showArchived ? statsQuery.data?.total : undefined)
280
+ const countLine = $derived(
281
+ workspaceTotal !== undefined
282
+ ? t('count', { n: workspaceTotal })
283
+ : reportedTotal !== undefined
284
+ ? t('count', { n: reportedTotal })
285
+ : assetsQuery.hasNextPage
286
+ ? t('count_showing', { n: assets.length })
287
+ : t('count', { n: assets.length }),
288
+ )
289
+
290
+ /**
291
+ * Every status the contract can hold, offered ahead of the features that set them.
292
+ *
293
+ * Three of the six are written now — `in_stock` and `assigned` by custody, `under_repair` by a
294
+ * repair — and `reserved`, `lost` and `retired` wait on the features that set them, so those three
295
+ * correctly return nothing. That is the data being uniform rather than the filter being broken, and
296
+ * the alternative — hiding options and adding them back one release later — teaches somebody the
297
+ * list is unstable. The README says the same thing in as many words.
298
+ */
82
299
  const statusOptions: SelectOption[] = [
83
300
  { value: '', label: t('filter_all_statuses') },
84
301
  { value: 'in_stock', label: t('status_in_stock') },
85
302
  { value: 'assigned', label: t('status_assigned') },
303
+ { value: 'reserved', label: t('status_reserved') },
86
304
  { value: 'under_repair', label: t('status_under_repair') },
305
+ { value: 'lost', label: t('status_lost') },
87
306
  { value: 'retired', label: t('status_retired') },
88
307
  ]
89
308
 
90
- function statusTone(status: string): BadgeTone {
91
- switch (status) {
92
- case 'assigned':
93
- return 'info'
94
- case 'under_repair':
95
- return 'warning'
96
- case 'retired':
97
- return 'grey'
98
- default:
99
- return 'success'
309
+ // ---------------------------------------------------------------- row actions
310
+
311
+ /**
312
+ * `assets.update` and `assets.archive` had no entry point anywhere in this module.
313
+ *
314
+ * Three of five procedures were unreachable and the module had one screen, so an asset could be
315
+ * created and then never corrected — a typo in a name was permanent, and a laptop that left the
316
+ * company stayed in the register for ever.
317
+ */
318
+ let acting = $state(false)
319
+
320
+ /** Archive and restore are one procedure, so they are one mutation and one busy flag. */
321
+ interface ArchiveVars {
322
+ assetId: string
323
+ archived: boolean
324
+ name: string
325
+ }
326
+
327
+ const setArchived = createMutation(() => ({
328
+ mutationFn: (vars: ArchiveVars) =>
329
+ api.assets.archive({ workspaceId, assetId: vars.assetId, archived: vars.archived }),
330
+ onSuccess: (_saved: Asset, vars: ArchiveVars) => {
331
+ toast.success(t(vars.archived ? 'archived_toast' : 'restored_toast', isolated({ name: vars.name })))
332
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
333
+ archiving = null
334
+ },
335
+ // Not `error.message`: that is a sentence the *server* wrote, in English, and archiving something
336
+ // somebody is still holding is exactly the refusal a reader needs to understand. `errors.ts` maps
337
+ // the reason token this module's server sends — `inventory.asset.still_held` — to a translated
338
+ // sentence, and keeps the server's words only for a failure it does not recognise.
339
+ onError: (error: unknown) => toast.error(errorMessage(error, t)),
340
+ onSettled: () => {
341
+ acting = false
342
+ },
343
+ }))
344
+
345
+ /**
346
+ * The guard is a plain flag set in the same tick as the click.
347
+ *
348
+ * `disabled={mutation.isPending}` reaches the button one render later, and two quick clicks are one
349
+ * render apart — so on a confirmation it files two decisions.
350
+ */
351
+ function confirmArchive() {
352
+ const target = archiving
353
+ if (!target || acting) return
354
+ acting = true
355
+ setArchived.mutate({ assetId: target.id, archived: true, name: target.name })
356
+ }
357
+
358
+ function restore(asset: Asset) {
359
+ if (acting) return
360
+ acting = true
361
+ setArchived.mutate({ assetId: asset.id, archived: false, name: asset.name })
362
+ }
363
+
364
+ function openCreate() {
365
+ editingAsset = null
366
+ dialogOpen = true
367
+ }
368
+
369
+ function openEdit(asset: Asset) {
370
+ editingAsset = asset
371
+ dialogOpen = true
372
+ }
373
+
374
+ /**
375
+ * Restoring is not destructive and needs no confirmation; archiving is, and states what happens.
376
+ * Hidden rather than disabled for somebody without `manage`: they may never do it, so the menu is
377
+ * not there at all rather than being a door that will not open.
378
+ */
379
+ function actionsFor(asset: Asset): MenuItem[] {
380
+ const items: MenuItem[] = [
381
+ // The name in the row is already a button that opens this; the menu carries it too because a
382
+ // menu that lists everything a row can do is the one place somebody looks for what a row can do.
383
+ { label: t('open'), icon: 'arrow-right', onSelect: () => setAsset(asset.id) },
384
+ { label: t('common.edit'), icon: 'square-pen', onSelect: () => openEdit(asset) },
385
+ ]
386
+ if (asset.archivedAt) {
387
+ items.push({ label: t('restore'), icon: 'rotate-ccw', onSelect: () => restore(asset) })
388
+ } else {
389
+ items.push({ type: 'separator' })
390
+ items.push({
391
+ label: t('common.archive'),
392
+ icon: 'archive',
393
+ danger: true,
394
+ onSelect: () => (archiving = asset),
395
+ })
100
396
  }
397
+ return items
101
398
  }
102
- const statusLabel = (status: string) => t(`status_${status}`)
399
+
400
+ const workspaceName = $derived(session.workspaces.find((w) => w.id === workspaceId)?.name ?? '')
401
+ const canManage = $derived(canInventory('manage'))
402
+ /** The last column carries the row menu, and only exists when there is a menu to carry. */
403
+ const COLUMNS = $derived(
404
+ canManage
405
+ ? 'minmax(96px, 110px) minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 132px 44px'
406
+ : 'minmax(96px, 110px) minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 132px',
407
+ )
408
+ const SKELETON_ROWS = [0, 1, 2, 3, 4, 5]
103
409
  </script>
104
410
 
105
- <svelte:head><title>{t('title')} · {session.workspaces.find((w) => w.id === workspaceId)?.name ?? ''}</title></svelte:head>
106
-
107
- <section>
108
- <header>
109
- <h1>{t('nav')}</h1>
110
- <div class="actions">
111
- <Input bind:value={search} type="search" size="sm" placeholder={t('search_placeholder')} aria-label={t('search_placeholder')} />
112
- <Select bind:value={statusFilter} options={statusOptions} size="sm" />
113
- {#if canInventory('manage')}
114
- <Button size="sm" onclick={() => (dialogOpen = true)}>{t('new')}</Button>
115
- {/if}
411
+ <PageHeader crumbs={[{ label: workspaceName }, { label: t('title') }]} title={t('title')}>
412
+ {#snippet search()}
413
+ <Input
414
+ bind:value={searchText}
415
+ type="search"
416
+ size="sm"
417
+ placeholder={t('search_placeholder')}
418
+ aria-label={t('search_placeholder')}
419
+ />
420
+ {/snippet}
421
+ {#snippet actions()}
422
+ <Select
423
+ bind:value={categoryFilter}
424
+ options={categoryOptions}
425
+ size="sm"
426
+ ariaLabel={t('category')}
427
+ />
428
+ <Select bind:value={statusFilter} options={statusOptions} size="sm" ariaLabel={t('status')} />
429
+ {#if canManage}
430
+ <Button size="sm" icon="plus" onclick={openCreate}>{t('new')}</Button>
431
+ {/if}
432
+ {/snippet}
433
+ </PageHeader>
434
+
435
+ <Page>
436
+ <!--
437
+ The return list, when somebody arrived here from an offboarding notification.
438
+ Named rather than silent: a list that has quietly dropped nine tenths of the register with no
439
+ sentence saying why is the single most confusing thing a filter can do, and this one is not set
440
+ by any control on the page — it came in on the URL.
441
+ -->
442
+ {#if custodianFilter}
443
+ <div class="held-by">
444
+ <span>{t('held_by', isolated({ name: custodianName ?? '' }))}</span>
445
+ <Button size="sm" variant="ghost" onclick={() => setParam('custodian', null)}>
446
+ {t('held_by_clear')}
447
+ </Button>
116
448
  </div>
117
- </header>
449
+ {/if}
118
450
 
119
- <div class="tiles">
120
- <StatTile size="md" label={t('count', { n: counts.total })} value={String(counts.total)} />
121
- <StatTile size="md" label={t('status_in_stock')} value={String(counts.inStock)} />
122
- <StatTile size="md" label={t('status_assigned')} value={String(counts.assigned)} />
123
- <StatTile size="md" label={t('status_under_repair')} value={String(counts.repair)} />
451
+ <div class="bar">
452
+ <p class="count" aria-live="polite">{countLine}</p>
453
+ <Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
124
454
  </div>
125
455
 
126
456
  {#if assetsQuery.isPending}
127
- <Spinner />
457
+ <!-- Shaped like the table it is standing in for, not a spinner in the middle of a content
458
+ area: the page does not jump when the rows arrive, and the shape says what is coming. -->
459
+ <div class="skeleton">
460
+ {#each SKELETON_ROWS as row (row)}
461
+ <div class="srow" style:grid-template-columns={COLUMNS}>
462
+ <Skeleton height="12px" width="72%" />
463
+ <Skeleton height="12px" width="58%" />
464
+ <Skeleton height="12px" width="46%" />
465
+ <Skeleton height="12px" width="40%" />
466
+ <Skeleton height="18px" width="76px" radius="999px" />
467
+ {#if canManage}<Skeleton height="12px" width="16px" />{/if}
468
+ </div>
469
+ {/each}
470
+ </div>
128
471
  {:else if assetsQuery.isError}
129
- <p class="error">{t('common.error')}</p>
130
- {:else if shownAssets.length === 0}
472
+ <!-- An error needs a message *and* a way out of it. This was a bare red sentence. -->
473
+ <EmptyState icon="triangle-alert" title={t('load_error')} description={t('common.error')}>
474
+ {#snippet actions()}
475
+ <Button variant="secondary" onclick={() => void assetsQuery.refetch()}>
476
+ {t('common.retry')}
477
+ </Button>
478
+ {/snippet}
479
+ </EmptyState>
480
+ {:else if assets.length === 0 && narrowed}
481
+ <!-- A search that matched nothing used to say "No assets yet · Add the first laptop…" beside a
482
+ New asset button — the wrong sentence and the wrong action for a workspace full of them. -->
483
+ <EmptyState icon="search" title={t('no_matches')} description={t('no_matches_desc')}>
484
+ {#snippet actions()}
485
+ <Button variant="secondary" onclick={clearFilters}>{t('clear_filters')}</Button>
486
+ {/snippet}
487
+ </EmptyState>
488
+ {:else if assets.length === 0}
131
489
  <EmptyState icon="briefcase" title={t('empty')} description={t('empty_desc')}>
132
490
  {#snippet actions()}
133
- {#if canInventory('manage')}
134
- <Button onclick={() => (dialogOpen = true)}>{t('new')}</Button>
491
+ {#if canManage}
492
+ <Button onclick={openCreate}>{t('new')}</Button>
135
493
  {/if}
136
494
  {/snippet}
137
495
  </EmptyState>
138
496
  {:else}
139
- <p class="count">{t('count', { n: shownAssets.length })}</p>
140
- <div class="table" role="table" aria-label={t('title')}>
141
- <div class="thead" role="row">
142
- <span role="columnheader">{t('code')}</span>
143
- <span role="columnheader">{t('name')}</span>
144
- <span role="columnheader">{t('location')}</span>
145
- <span role="columnheader">{t('warranty_until')}</span>
146
- <span role="columnheader">{t('category')}</span>
147
- </div>
148
- {#each shownAssets as asset (asset.id)}
149
- <div class="trow" role="row" tabindex="-1">
150
- <span class="cell code" role="cell"><code>{asset.code}</code></span>
151
- <span class="cell who" role="cell">
497
+ <Table columns={COLUMNS} ariaLabel={t('title')}>
498
+ <TableHeader>
499
+ <TableCell header>{t('code')}</TableCell>
500
+ <TableCell header>{t('name')}</TableCell>
501
+ <TableCell header>{t('location')}</TableCell>
502
+ <TableCell header>{t('warranty_until')}</TableCell>
503
+ <TableCell header>{t('status')}</TableCell>
504
+ {#if canManage}<TableCell header end></TableCell>{/if}
505
+ </TableHeader>
506
+ {#each assets as asset (asset.id)}
507
+ <TableRow>
508
+ <TableCell><code>{asset.code}</code></TableCell>
509
+ <TableCell>
152
510
  <span class="stack">
153
- <span class="name">{asset.name}</span>
154
- {#if asset.serialNumber}<span class="sub">S/N {asset.serialNumber}</span>{/if}
511
+ <!--
512
+ A button rather than the whole row: a row carrying a menu cannot itself be a button
513
+ without nesting one inside the other, which is invalid and breaks the menu. The name
514
+ is what somebody aims at anyway, and it is reachable from the keyboard.
515
+ -->
516
+ <button
517
+ type="button"
518
+ class="name"
519
+ onclick={() => setAsset(asset.id)}
520
+ aria-label={t('open_asset', isolated({ name: asset.name }))}
521
+ >
522
+ {asset.name}
523
+ </button>
524
+ {#if asset.categoryId || asset.serialNumber}
525
+ <span class="sub">
526
+ {#if asset.categoryId}
527
+ <!-- Named even when the category has since been archived: `categories` is
528
+ fetched with archived rows for exactly this. -->
529
+ <span class="chip">
530
+ {categoryNames.get(asset.categoryId) ?? t('category_none')}
531
+ </span>
532
+ {/if}
533
+ <!-- `S/N` was a literal English abbreviation sitting in the middle of a Persian,
534
+ Arabic, German or Turkish table; `serial_number` is translated in all five. -->
535
+ {#if asset.serialNumber}
536
+ <span class="serial">
537
+ {t('serial_number')}:
538
+ <span class="ltr">{asset.serialNumber}</span>
539
+ </span>
540
+ {/if}
541
+ </span>
542
+ {/if}
543
+ </span>
544
+ </TableCell>
545
+ <TableCell><span class="muted">{asset.location ?? '—'}</span></TableCell>
546
+ <!-- `formatDate`, not the stored value. This column printed the ISO string — 2027-03-14
547
+ — beside a panel that renders the same field as "14 Mar 2027", so the same fact read
548
+ two ways on one screen, and a Persian reader got Gregorian Latin digits in a table
549
+ where every other date follows their calendar. -->
550
+ <TableCell>
551
+ <span class="muted">
552
+ {asset.warrantyUntil ? formatDate(asset.warrantyUntil) : '—'}
155
553
  </span>
156
- </span>
157
- <span class="cell muted" role="cell">{asset.location ?? '—'}</span>
158
- <span class="cell muted" role="cell">{asset.warrantyUntil ?? '—'}</span>
159
- <span class="cell" role="cell">
554
+ </TableCell>
555
+ <TableCell>
160
556
  {#if asset.archivedAt}
161
557
  <Badge tone="grey">{t('archived')}</Badge>
162
558
  {:else}
163
- <Badge tone={statusTone(asset.status)}>{statusLabel(asset.status)}</Badge>
559
+ <Badge tone={statusTone(asset.status)}>{t(`status_${asset.status}`)}</Badge>
164
560
  {/if}
165
- </span>
166
- </div>
561
+ </TableCell>
562
+ {#if canManage}
563
+ <TableCell end>
564
+ <DropdownMenu items={actionsFor(asset)} align="end">
565
+ {#snippet trigger(props)}
566
+ <IconButton
567
+ {...props}
568
+ icon="ellipsis"
569
+ size={28}
570
+ label={t('row_actions', isolated({ name: asset.name }))}
571
+ />
572
+ {/snippet}
573
+ </DropdownMenu>
574
+ </TableCell>
575
+ {/if}
576
+ </TableRow>
167
577
  {/each}
168
- </div>
578
+ </Table>
579
+
580
+ {#if assetsQuery.hasNextPage}
581
+ <div class="more">
582
+ <Button
583
+ size="sm"
584
+ variant="secondary"
585
+ disabled={assetsQuery.isFetchingNextPage}
586
+ onclick={() => assetsQuery.fetchNextPage()}
587
+ >
588
+ {assetsQuery.isFetchingNextPage ? t('common.loading') : t('load_more')}
589
+ </Button>
590
+ </div>
591
+ {/if}
169
592
  {/if}
170
- </section>
593
+ </Page>
171
594
 
172
- <AssetFormDialog bind:open={dialogOpen} {workspaceId} />
595
+ <AssetFormDialog bind:open={dialogOpen} {workspaceId} asset={editingAsset} />
596
+
597
+ <!--
598
+ The panel sits over the list rather than replacing it: the scroll position, the filters and the
599
+ page cursor are all still there when it closes. Editing is the list's own dialog, so the panel
600
+ asks for it rather than growing a second copy of the form.
601
+ -->
602
+ <AssetDetailPanel
603
+ {workspaceId}
604
+ assetId={openAssetId}
605
+ onclose={() => setAsset(null)}
606
+ onedit={(asset) => openEdit(asset)}
607
+ />
608
+
609
+ <!-- Archiving states what happens and to what, rather than asking "Are you sure?". -->
610
+ <Dialog
611
+ open={archiving !== null}
612
+ size="sm"
613
+ title={t('archive_title', isolated({ name: archiving?.name ?? '' }))}
614
+ onOpenChange={(next) => {
615
+ if (!next) archiving = null
616
+ }}
617
+ >
618
+ <p class="dialog-body">{t('archive_body')}</p>
619
+ {#snippet footer()}
620
+ <Button variant="ghost" onclick={() => (archiving = null)}>{t('common.cancel')}</Button>
621
+ <Button variant="danger" onclick={confirmArchive} loading={acting}>{t('common.archive')}</Button>
622
+ {/snippet}
623
+ </Dialog>
173
624
 
174
625
  <style>
175
- header {
626
+ .bar {
176
627
  display: flex;
177
628
  align-items: center;
178
629
  justify-content: space-between;
179
630
  gap: 12px;
180
631
  flex-wrap: wrap;
181
- }
182
- h1 {
183
- font-size: 18px;
184
- font-weight: 600;
185
- color: var(--kern-ink-900);
186
- }
187
- .actions {
188
- display: flex;
189
- align-items: center;
190
- gap: 8px;
191
- }
192
- .tiles {
193
- display: grid;
194
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
195
- gap: 10px;
196
- margin-top: 14px;
632
+ margin-bottom: 8px;
197
633
  }
198
634
  .count {
199
- margin: 14px 0 6px;
200
635
  font-size: 12px;
201
- color: var(--kern-ink-500);
636
+ color: var(--kern-ink-280);
202
637
  }
203
- .error {
204
- color: var(--kern-danger);
205
- font-size: 13px;
206
- }
207
- .table {
208
- margin-top: 4px;
209
- border: 1px solid var(--kern-line);
210
- border-radius: 10px;
211
- overflow: hidden;
212
- background: var(--kern-paper);
213
- }
214
- .thead,
215
- .trow {
216
- display: grid;
217
- grid-template-columns: 110px minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) 120px;
218
- gap: 10px;
638
+ .held-by {
639
+ display: flex;
219
640
  align-items: center;
220
- padding: 8px 12px;
221
- }
222
- .thead {
223
- font-size: 11px;
224
- text-transform: uppercase;
225
- letter-spacing: 0.04em;
226
- color: var(--kern-ink-500);
227
- background: var(--kern-canvas-subtle, transparent);
228
- border-bottom: 1px solid var(--kern-line);
229
- }
230
- .trow {
231
- border-bottom: 1px solid var(--kern-line);
641
+ justify-content: space-between;
642
+ gap: 12px;
643
+ flex-wrap: wrap;
644
+ margin-bottom: 10px;
645
+ /* Logical, never left/right: the extra room is beside the text, which in Persian is the other
646
+ side of the box. */
647
+ padding-block: 8px;
648
+ padding-inline: 12px 8px;
649
+ border-radius: var(--kern-r-md);
650
+ background: var(--kern-surface-chip);
232
651
  font-size: 13px;
652
+ /* A colour rather than opacity, and 600 rather than 500: this sentence is the reason the list
653
+ below it is short, so it is read rather than skimmed. */
654
+ color: var(--kern-ink-600);
233
655
  }
234
- .trow:last-child {
235
- border-bottom: none;
236
- }
237
- .cell {
238
- min-width: 0;
656
+ .muted {
657
+ color: var(--kern-ink-280);
239
658
  }
240
659
  code {
241
660
  font-size: 12px;
242
- color: var(--kern-ink-700);
661
+ color: var(--kern-ink-600);
662
+ }
663
+ /* A tag and a serial are Latin identifiers read character by character. Inside a Persian or
664
+ Arabic row the bidi algorithm will happily reorder a mixed letter-and-digit run, so they are
665
+ isolated and left-to-right — the characters have to match what is printed on the sticker. */
666
+ code,
667
+ .ltr {
668
+ direction: ltr;
669
+ /* `isolate` works on an inline box; `inline-block` would also work and would cost the
670
+ ellipsis on the line above it. */
671
+ unicode-bidi: isolate;
243
672
  }
244
- .who .stack {
673
+ .stack {
245
674
  display: flex;
246
675
  flex-direction: column;
247
676
  min-width: 0;
248
677
  }
249
678
  .name {
679
+ /* A button that reads as the row's title: no chrome, the row's own type, and the pointer the
680
+ design system already puts on every `button`. */
681
+ appearance: none;
682
+ background: none;
683
+ border: 0;
684
+ font: inherit;
250
685
  font-weight: 500;
251
686
  color: var(--kern-ink-900);
687
+ text-align: start;
252
688
  overflow: hidden;
253
689
  text-overflow: ellipsis;
254
690
  white-space: nowrap;
691
+ /* WCAG 2.5.8 wants 24px; a line of 13px text is about 18. The padding grows the hit area and
692
+ the equal negative margin gives the space back, so the margin box — and therefore the row —
693
+ is exactly where it was. */
694
+ padding-block: 4px;
695
+ margin-block: -4px;
696
+ /* So the global `:focus-visible` ring is rounded like every other control. */
697
+ border-radius: var(--kern-r-sm);
698
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
699
+ first strong character, so a Latin name inside a Persian screen reads left to right and
700
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
701
+ unicode-bidi: plaintext;
702
+ }
703
+ .name:hover {
704
+ text-decoration: underline;
255
705
  }
256
706
  .sub {
707
+ display: flex;
708
+ align-items: center;
709
+ gap: 6px;
710
+ min-width: 0;
257
711
  font-size: 11px;
258
- color: var(--kern-ink-500);
712
+ /* Muted with a colour, never with opacity: a faded row at 0.5 is unreadable whatever its token. */
713
+ color: var(--kern-ink-280);
714
+ }
715
+ .chip {
716
+ flex: none;
717
+ /* A value somebody typed decides its own direction: `plaintext` takes it from the value's
718
+ first strong character, so a Latin name inside a Persian screen reads left to right and
719
+ keeps its own trailing punctuation instead of donating it to the paragraph. */
720
+ unicode-bidi: plaintext;
721
+ padding: 1px 6px;
722
+ border-radius: var(--kern-r-sm);
723
+ background: var(--kern-surface-chip);
724
+ color: var(--kern-ink-600);
725
+ max-inline-size: 140px;
259
726
  overflow: hidden;
260
727
  text-overflow: ellipsis;
261
728
  white-space: nowrap;
262
729
  }
263
- .muted {
264
- color: var(--kern-ink-500);
730
+ .serial {
731
+ overflow: hidden;
732
+ text-overflow: ellipsis;
733
+ white-space: nowrap;
734
+ }
735
+ .skeleton {
736
+ display: flex;
737
+ flex-direction: column;
738
+ gap: 1px;
739
+ }
740
+ .srow {
741
+ display: grid;
742
+ align-items: center;
743
+ gap: 12px;
744
+ /* The row heights of `Table`, so nothing shifts when the real rows replace these. */
745
+ padding: 14px 12px;
746
+ border-bottom: 1px solid var(--kern-border-hairline);
747
+ }
748
+ .more {
749
+ display: flex;
750
+ justify-content: center;
751
+ padding: 16px 0;
752
+ }
753
+ .dialog-body {
754
+ margin: 0;
755
+ font-size: 13.5px;
756
+ line-height: 1.55;
757
+ color: var(--kern-ink-700);
265
758
  }
266
759
  </style>