@valentinkolb/cloud 0.4.0 → 0.5.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 (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,31 +1,48 @@
1
1
  import type { JSX } from "solid-js";
2
+ import { Show } from "solid-js";
3
+ import Chart from "./Chart";
4
+ import { type StatGridSize, useStatGridSize } from "./StatGrid";
2
5
 
3
6
  /**
4
- * Single cell in a stat-card row. See `skills/cloud-app/references/frontend.md`
5
- * § Stats and the live demos in
6
- * `packages/ui-lab/src/frontend/UiLabShowcase.island.tsx`.
7
+ * Single cell inside a {@link StatGrid}. Renders one stat: tiny
8
+ * uppercase label, prominent value, and an optional sub line that
9
+ * may carry an inline accent (pill with text, or a plain colored
10
+ * icon).
11
+ *
12
+ * The cell provides its own `bg-white dark:bg-zinc-900` so it works
13
+ * inside `StatGrid`'s hairline-bleed body (`gap-px bg-zinc-100`):
14
+ * each cell's background tile is what hides the bleed except at the
15
+ * 1px gaps, which become the inter-cell dividers. Don't strip the
16
+ * bg unless you're rendering the cell outside a StatGrid.
7
17
  *
8
- * Use inside a parent grid that frames the cells:
9
18
  * ```tsx
10
- * <div class="paper overflow-hidden">
11
- * <div class="grid grid-cols-3 gap-px p-px bg-zinc-100 dark:bg-zinc-800">
12
- * <StatCell label="Apps" value={17} sub="9 nav · 12 admin" />
13
- * <StatCell
14
- * label="Healthy"
15
- * value="17/17"
16
- * sub="all systems"
17
- * accent={{ tone: "emerald", icon: "ti ti-check" }}
18
- * />
19
- * </div>
20
- * </div>
19
+ * <StatGrid columns={3}>
20
+ * <StatCell label="Apps" value={17} sub="9 nav · 12 admin" />
21
+ * <StatCell
22
+ * label="Healthy"
23
+ * value="17/17"
24
+ * accent={{ tone: "emerald", icon: "ti ti-check", text: "ok" }}
25
+ * />
26
+ * <StatCell
27
+ * label="P99"
28
+ * value="89ms"
29
+ * valueClass="text-amber-600 dark:text-amber-400"
30
+ * accent={{ tone: "amber", icon: "ti ti-alert-triangle" }}
31
+ * />
32
+ * </StatGrid>
21
33
  * ```
22
34
  *
23
35
  * Accent rules:
24
36
  * - `accent.text` set → renders an icon-and-text pill (`.tag` with bg).
25
- * - `accent.text` omitted renders a plain colored icon (no bg). The `.tag`
26
- * background looks squished around a single icon, so we drop it.
27
- * - When the accent should also colour the value (warnings, errors), pass
28
- * `valueClass` like `text-amber-600 dark:text-amber-400`.
37
+ * Use for short labels like "+12%" or "ok".
38
+ * - `accent.text` omitted → renders a plain colored icon (no bg). The
39
+ * `.tag` background looks squished around a single icon, so we drop
40
+ * it. Use for status hints next to a colored value.
41
+ * - When the accent should also colour the value itself (warnings,
42
+ * errors), pass `valueClass` like `text-amber-600 dark:text-amber-400`.
43
+ *
44
+ * Pass `href` to make the whole cell a link — adds a subtle hover
45
+ * tint and keeps the cell visually identical when static.
29
46
  */
30
47
  export type StatCellAccent = {
31
48
  tone: "emerald" | "amber" | "red" | "blue" | "zinc";
@@ -33,16 +50,49 @@ export type StatCellAccent = {
33
50
  icon: string;
34
51
  /** Optional pill text. If set → tag with bg. If omitted → plain colored icon. */
35
52
  text?: string;
53
+ /**
54
+ * When set together with `text`, the pill renders as a link with
55
+ * a tone-matched hover state. Use for "drill into this status"
56
+ * affordances next to the value (e.g. an amber "open" pill that
57
+ * links to the requests page).
58
+ *
59
+ * Ignored when `text` is omitted — an icon-only accent is not a
60
+ * link target. Also incompatible with a cell-level `href`: the
61
+ * resulting `<a>` inside `<a>` is invalid HTML, so this is silently
62
+ * ignored when the parent cell is already a link.
63
+ */
64
+ href?: string;
36
65
  };
37
66
 
38
67
  export type StatCellProps = {
39
68
  label: string;
40
- value: string | number;
41
- /** Sub line under the value. Pass `" "` (non-breaking space) to keep cell heights equal when no sub exists. */
69
+ /**
70
+ * Value to display. Accepts JSX so callers can render formatted
71
+ * content (e.g. a number followed by an inline unit, or a mix of
72
+ * sizes). The default styling is `text-xl font-bold tabular-nums`.
73
+ */
74
+ value: string | number | JSX.Element;
75
+ /** Sub line under the value. */
42
76
  sub?: string;
43
77
  /** Override the default `text-primary` value colour for warning / error / success signals. */
44
78
  valueClass?: string;
45
79
  accent?: StatCellAccent;
80
+ /** When set, the whole cell becomes a link to this URL with a subtle hover state. */
81
+ href?: string;
82
+ /** Native `title` attribute on the value — useful when the value is truncated. */
83
+ title?: string;
84
+ /**
85
+ * Optional inline sparkline showing the value's recent history.
86
+ * Plain `number[]`, oldest → newest. Renders below the sub row at
87
+ * a fixed compact height; the line tone matches the cell's value
88
+ * tone (uses `currentColor` on a wrapper). Pass an empty array or
89
+ * omit to hide the sparkline.
90
+ */
91
+ trend?: number[];
92
+ /**
93
+ * Optional per-cell scale. Defaults to the parent StatGrid `size`.
94
+ */
95
+ size?: StatGridSize;
46
96
  };
47
97
 
48
98
  const ACCENT_PILL_CLASSES: Record<StatCellAccent["tone"], string> = {
@@ -61,28 +111,120 @@ const ACCENT_ICON_CLASSES: Record<StatCellAccent["tone"], string> = {
61
111
  zinc: "text-zinc-500 dark:text-zinc-400",
62
112
  };
63
113
 
64
- const StatCell = (props: StatCellProps): JSX.Element => {
114
+ /** Hover tone for a clickable accent pill — one shade darker than the
115
+ * resting state. Same tone family, no surprise jump to a different
116
+ * hue on hover. */
117
+ const ACCENT_PILL_HOVER_CLASSES: Record<StatCellAccent["tone"], string> = {
118
+ emerald: "hover:bg-emerald-200 dark:hover:bg-emerald-900/60",
119
+ amber: "hover:bg-amber-200 dark:hover:bg-amber-900/60",
120
+ red: "hover:bg-red-200 dark:hover:bg-red-900/60",
121
+ blue: "hover:bg-blue-200 dark:hover:bg-blue-900/60",
122
+ zinc: "hover:bg-zinc-200 dark:hover:bg-zinc-700",
123
+ };
124
+
125
+ /** Cell body — same markup whether the wrapper is a `<div>` or `<a>`.
126
+ * `cellIsLink` is set by the wrapper to suppress nested-link rendering
127
+ * (an `<a>` inside an `<a>` is invalid HTML). When true, an
128
+ * `accent.href` falls back to a static span pill. */
129
+ const Body = (props: StatCellProps & { cellIsLink: boolean }): JSX.Element => {
65
130
  const valueClass = props.valueClass ?? "text-primary";
66
- const sub = props.sub ?? " ";
131
+ const gridSize = useStatGridSize();
132
+ const size = () => props.size ?? gridSize;
133
+ const valueSizeClass = () => (size() === "sm" ? "text-base" : "text-xl");
134
+ const labelSizeClass = () => (size() === "sm" ? "text-[9px]" : "text-[10px]");
135
+ const subSizeClass = () => (size() === "sm" ? "text-[9px]" : "text-[10px]");
67
136
  return (
68
- <div class="bg-white dark:bg-zinc-900 px-4 py-4 flex flex-col gap-0.5">
69
- <span class="text-[10px] uppercase tracking-wider text-dimmed">{props.label}</span>
70
- <span class={`text-xl font-bold tabular-nums ${valueClass}`}>{props.value}</span>
71
- {props.accent ? (
72
- <div class="flex items-center gap-1.5">
73
- <span class="text-[10px] text-dimmed">{sub}</span>
74
- {props.accent.text ? (
75
- <span class={`tag ${ACCENT_PILL_CLASSES[props.accent.tone]}`}>
76
- <i class={`${props.accent.icon} text-[9px]`} />
77
- {props.accent.text}
78
- </span>
79
- ) : (
80
- <i class={`${props.accent.icon} ${ACCENT_ICON_CLASSES[props.accent.tone]} text-[11px]`} />
81
- )}
137
+ <>
138
+ <span class={`${labelSizeClass()} uppercase tracking-wider text-dimmed truncate`}>{props.label}</span>
139
+ <span class={`${valueSizeClass()} font-bold tabular-nums leading-tight truncate ${valueClass}`} title={props.title}>
140
+ {props.value}
141
+ </span>
142
+ {/* Optional trend sparkline. Sits inline between the value and
143
+ the sub row so the eye lands on it right after parsing the
144
+ headline number.
145
+
146
+ Sizing: inline `style` for the height (Tailwind's h-8 class
147
+ didn't survive every hot-reload / cache combo in the wild;
148
+ inline style is immune). Full-bleed horizontally via -mx-4
149
+ to cancel the cell's px-4 padding so the line spans the
150
+ card edge-to-edge — the inset visual reads as "tucked-in
151
+ line in the middle", which the user reported as "doesn't
152
+ fit" against the surrounding card.
153
+
154
+ `showLast` + `showMinMax` add dots at the most-recent point
155
+ plus the min/max points so a flat-with-spikes series still
156
+ has visible anchor points (otherwise sparse data renders
157
+ as nearly-invisible horizontal lines). */}
158
+ <Show when={props.trend && props.trend.length > 1}>
159
+ <Chart kind="sparkline" class="-mx-4 self-stretch block" style={{ height: "32px" }} data={props.trend ?? []} showLast showMinMax />
160
+ </Show>
161
+ {/* Sub row: rendered only when there's actual content. Keeping
162
+ the row out entirely when both sub and accent are absent
163
+ lets the grid's row-height shrink naturally — callers that
164
+ want forced equal heights should pass `sub=" "`. */}
165
+ {props.sub || props.accent ? (
166
+ <div class="flex items-center gap-1.5 min-w-0">
167
+ {props.sub ? <span class={`${subSizeClass()} text-dimmed truncate`}>{props.sub}</span> : null}
168
+ {props.accent ? (
169
+ props.accent.text ? (
170
+ // Pill variant. `accent.href` upgrades the span to an
171
+ // anchor with a tone-matched hover background, but only
172
+ // when the surrounding cell isn't already a link — the
173
+ // browser refuses to nest `<a>` and silently flattens
174
+ // the inner one, which would look broken on hover.
175
+ props.accent.href && !props.cellIsLink ? (
176
+ <a
177
+ href={props.accent.href}
178
+ class={`tag shrink-0 transition-colors ${ACCENT_PILL_CLASSES[props.accent.tone]} ${ACCENT_PILL_HOVER_CLASSES[props.accent.tone]}`}
179
+ >
180
+ <i class={`${props.accent.icon} text-[9px]`} />
181
+ {props.accent.text}
182
+ </a>
183
+ ) : (
184
+ <span class={`tag shrink-0 ${ACCENT_PILL_CLASSES[props.accent.tone]}`}>
185
+ <i class={`${props.accent.icon} text-[9px]`} />
186
+ {props.accent.text}
187
+ </span>
188
+ )
189
+ ) : (
190
+ <i class={`${props.accent.icon} ${ACCENT_ICON_CLASSES[props.accent.tone]} text-[11px] shrink-0`} />
191
+ )
192
+ ) : null}
82
193
  </div>
83
- ) : (
84
- <span class="text-[10px] text-dimmed">{sub}</span>
85
- )}
194
+ ) : null}
195
+ </>
196
+ );
197
+ };
198
+
199
+ const StatCell = (props: StatCellProps): JSX.Element => {
200
+ // Static layout classes — shared between link and non-link wrapper.
201
+ // `bg-white` (and dark equivalent) is what tiles over the parent
202
+ // grid's `bg-zinc-100` bleed; without it the cell would look
203
+ // transparent on top of the divider colour.
204
+ const gridSize = useStatGridSize();
205
+ const size = () => props.size ?? gridSize;
206
+ const baseClass = () => `bg-white dark:bg-zinc-900 flex flex-col gap-0.5 min-w-0 ${size() === "sm" ? "px-3 py-2.5" : "px-4 py-4"}`;
207
+ if (props.href) {
208
+ // Link variant: adds a subtle top-right `external-link` icon as
209
+ // an affordance — sits in dimmed zinc by default, shifts to the
210
+ // link-blue colour on cell hover. `group` lets the icon respond
211
+ // to the whole cell's hover state, not just its own. `pr-7`
212
+ // reserves space so a long truncate'd label can't slide under
213
+ // the icon (the icon is absolute, so it doesn't take a column
214
+ // in the flex layout).
215
+ return (
216
+ <a href={props.href} class={`${baseClass()} group relative pr-7 hover:bg-zinc-50 dark:hover:bg-zinc-800/40 transition-colors`}>
217
+ <i
218
+ class="ti ti-external-link absolute top-2 right-2 text-[11px] text-dimmed group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
219
+ aria-hidden="true"
220
+ />
221
+ <Body {...props} cellIsLink />
222
+ </a>
223
+ );
224
+ }
225
+ return (
226
+ <div class={baseClass()}>
227
+ <Body {...props} cellIsLink={false} />
86
228
  </div>
87
229
  );
88
230
  };
@@ -0,0 +1,149 @@
1
+ import type { JSX } from "solid-js";
2
+ import { Show, createContext, useContext } from "solid-js";
3
+
4
+ /**
5
+ * StatGrid — paper-framed container for a row of {@link StatCell}s.
6
+ *
7
+ * Replaces the inline `paper + grid + gap-px + p-px + bg-zinc` pattern
8
+ * that previously lived in every consumer. The two visual bugs that
9
+ * pattern produced are fixed here:
10
+ *
11
+ * 1. **No more doubled outer border.** The old pattern used
12
+ * `p-px bg-zinc-100` to draw a 1px ring around the cells, which
13
+ * overlapped the `paper` border (also 1px zinc-100) and made the
14
+ * outer edge look thicker than the inner dividers. We drop the
15
+ * `p-px` entirely — the cells touch the paper's inner edge
16
+ * directly, and `paper`'s own border is the only outer line.
17
+ * 2. **No more squashed inner corners.** Without the inner ring, the
18
+ * cells are simply clipped by `paper`'s `rounded-lg overflow-hidden`,
19
+ * so the cell corners match the outer radius cleanly.
20
+ *
21
+ * The hairline dividers between cells come from the standard
22
+ * `gap-px bg-zinc-100` bleed trick: the body's background shows
23
+ * through the 1px gaps between cell `bg-white` tiles. This is why
24
+ * `StatCell` ships its own `bg-white` — don't strip it.
25
+ *
26
+ * ## API
27
+ *
28
+ * Composition-based, mirroring `Widget` / `WidgetStat` and most other
29
+ * platform containers:
30
+ *
31
+ * ```tsx
32
+ * <StatGrid
33
+ * columns={3}
34
+ * title="View totals"
35
+ * action={{ label: "Open full view", href: "/app/grids/abc" }}
36
+ * >
37
+ * <StatCell label="Apps" value={17} />
38
+ * <StatCell label="Routes" value={106} />
39
+ * <StatCell label="Search" value={5} sub="providers" />
40
+ * </StatGrid>
41
+ * ```
42
+ *
43
+ * ## Columns
44
+ *
45
+ * `columns` (1-6) picks a responsive grid track count from a static
46
+ * map below — Tailwind's JIT only compiles class names it can find
47
+ * literally in source, so interpolated `grid-cols-${n}` strings get
48
+ * stripped silently. Pass any number outside 1-6 and we fall back to
49
+ * the same `grid-cols-2 sm:grid-cols-3 md:grid-cols-6` ladder the
50
+ * grids app uses for its view-stats rows.
51
+ *
52
+ * When `columns` is omitted, callers get a sensible default for a
53
+ * mixed-count row. Pass it explicitly when you know the cell count
54
+ * statically — that's almost always.
55
+ */
56
+ type StatGridAction = {
57
+ label: string;
58
+ href: string;
59
+ };
60
+
61
+ export type StatGridSize = "md" | "sm";
62
+
63
+ const StatGridSizeContext = createContext<StatGridSize>("md");
64
+
65
+ export const useStatGridSize = () => useContext(StatGridSizeContext);
66
+
67
+ type StatGridProps = {
68
+ children: JSX.Element;
69
+ /**
70
+ * Optional title shown in a small header bar above the cells.
71
+ * When set, the header gets a `border-b` divider — same colour as
72
+ * the cell hairlines, so it visually continues the grid.
73
+ */
74
+ title?: string;
75
+ /**
76
+ * Optional right-aligned link in the header. Shows up only when
77
+ * `title` is also set (a lone link with no title would float
78
+ * orphaned).
79
+ */
80
+ action?: StatGridAction;
81
+ /**
82
+ * Number of columns at the widest breakpoint. Maps to a static
83
+ * responsive class set (see {@link GRID_COLS_CLASS}). Values
84
+ * outside 1-6 fall back to the 6-column ladder.
85
+ */
86
+ columns?: number;
87
+ /**
88
+ * Compact cells for secondary stats inside dense app surfaces. The
89
+ * default keeps the established admin/dashboard scale.
90
+ */
91
+ size?: StatGridSize;
92
+ /**
93
+ * Extra classes on the outer paper element — primarily for sizing
94
+ * (`h-full`, `flex-1`) when the grid needs to fill a parent
95
+ * container rather than collapse to its natural content height.
96
+ */
97
+ class?: string;
98
+ };
99
+
100
+ /**
101
+ * Static responsive column classes. Keys 1-6 map to the responsive
102
+ * ladders used across the platform (matches the grids `StatsRow` /
103
+ * `ViewStatsRow` originals so the visual rhythm is unchanged).
104
+ *
105
+ * The values are literal class strings so Tailwind's JIT picks them
106
+ * up — never inline an interpolation like `md:grid-cols-${n}`.
107
+ */
108
+ const GRID_COLS_CLASS: Record<number, string> = {
109
+ 1: "grid-cols-1",
110
+ 2: "grid-cols-2",
111
+ 3: "grid-cols-1 sm:grid-cols-3",
112
+ 4: "grid-cols-2 md:grid-cols-4",
113
+ 5: "grid-cols-2 sm:grid-cols-3 md:grid-cols-5",
114
+ 6: "grid-cols-2 sm:grid-cols-3 md:grid-cols-6",
115
+ };
116
+
117
+ const DEFAULT_GRID_COLS = "grid-cols-2 sm:grid-cols-3 md:grid-cols-6";
118
+
119
+ const StatGrid = (props: StatGridProps): JSX.Element => {
120
+ const gridCols = () => (props.columns ? (GRID_COLS_CLASS[props.columns] ?? DEFAULT_GRID_COLS) : DEFAULT_GRID_COLS);
121
+
122
+ return (
123
+ <div class={`paper overflow-hidden flex flex-col ${props.class ?? ""}`}>
124
+ <Show when={props.title}>
125
+ <header class="px-3 py-2 flex items-center justify-between gap-2 border-b border-zinc-100 dark:border-zinc-800/60">
126
+ <span class="text-xs font-semibold text-primary truncate">{props.title}</span>
127
+ <Show when={props.action}>
128
+ {(action) => (
129
+ <a href={action().href} class="text-[11px] text-dimmed hover:text-primary inline-flex items-center gap-1 shrink-0">
130
+ <span>{action().label}</span>
131
+ <i class="ti ti-arrow-up-right text-[10px]" />
132
+ </a>
133
+ )}
134
+ </Show>
135
+ </header>
136
+ </Show>
137
+ {/* Cell grid: `gap-px` carves 1px channels between cells, the
138
+ body `bg-zinc-100` bleeds through those channels, and each
139
+ cell's own `bg-white` covers the rest. No `p-px` — see the
140
+ docblock for why. `flex-1` lets the grid expand to fill the
141
+ paper when the caller passes a sizing class like `h-full`. */}
142
+ <StatGridSizeContext.Provider value={props.size ?? "md"}>
143
+ <div class={`grid ${gridCols()} gap-px bg-zinc-100 dark:bg-zinc-800 flex-1`}>{props.children}</div>
144
+ </StatGridSizeContext.Provider>
145
+ </div>
146
+ );
147
+ };
148
+
149
+ export default StatGrid;
@@ -0,0 +1,107 @@
1
+ import { createMemo, createSignal, For, Show } from "solid-js";
2
+ import CopyButton from "./CopyButton";
3
+
4
+ export type StructuredDataPreviewMode = "formatted" | "raw";
5
+
6
+ export type StructuredDataPreviewProps = {
7
+ title?: string;
8
+ data: unknown;
9
+ defaultMode?: StructuredDataPreviewMode;
10
+ copy?: boolean;
11
+ empty?: string;
12
+ maxRows?: number;
13
+ class?: string;
14
+ };
15
+
16
+ type Row = {
17
+ key: string;
18
+ value: unknown;
19
+ };
20
+
21
+ const toRows = (data: unknown): Row[] => {
22
+ if (Array.isArray(data)) return data.map((value, index) => ({ key: String(index), value }));
23
+ if (data && typeof data === "object") return Object.entries(data as Record<string, unknown>).map(([key, value]) => ({ key, value }));
24
+ if (data === null || data === undefined) return [];
25
+ return [{ key: "value", value: data }];
26
+ };
27
+
28
+ const formatInlineValue = (value: unknown): string => {
29
+ if (value === null || value === undefined) return "null";
30
+ if (typeof value === "string") return value;
31
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
32
+ return JSON.stringify(value);
33
+ };
34
+
35
+ const formatJson = (data: unknown): string => JSON.stringify(data ?? null, null, 2);
36
+
37
+ export default function StructuredDataPreview(props: StructuredDataPreviewProps) {
38
+ const [mode, setMode] = createSignal<StructuredDataPreviewMode>(props.defaultMode ?? "formatted");
39
+ const rows = createMemo(() => toRows(props.data));
40
+ const visibleRows = createMemo(() => rows().slice(0, props.maxRows ?? rows().length));
41
+ const hiddenCount = createMemo(() => Math.max(0, rows().length - visibleRows().length));
42
+ const raw = createMemo(() => formatJson(props.data));
43
+ const hasData = createMemo(() => rows().length > 0);
44
+ const showRaw = createMemo(() => mode() === "raw");
45
+
46
+ return (
47
+ <div class={["flex flex-col gap-2", props.class].filter(Boolean).join(" ")}>
48
+ <Show when={props.title}>
49
+ {(title) => <h3 class="text-xs font-semibold uppercase tracking-wider text-secondary">{title()}</h3>}
50
+ </Show>
51
+
52
+ <Show
53
+ when={!showRaw()}
54
+ fallback={
55
+ <div class="relative rounded-lg bg-zinc-100 px-3 py-2 text-secondary dark:bg-zinc-900/80">
56
+ <pre class="max-h-72 overflow-auto whitespace-pre-wrap break-all pr-16 font-mono text-[11px] leading-relaxed">{raw()}</pre>
57
+ <Show when={props.copy !== false}>
58
+ <div class="absolute right-2 top-2">
59
+ <CopyButton text={raw()} label="Copy" class="text-[11px] text-dimmed transition-colors hover:text-secondary" />
60
+ </div>
61
+ </Show>
62
+ </div>
63
+ }
64
+ >
65
+ <div class="rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-900/80">
66
+ <Show
67
+ when={hasData()}
68
+ fallback={<p class="text-xs text-dimmed">{props.empty ?? "No data."}</p>}
69
+ >
70
+ <div class="grid grid-cols-[minmax(7rem,auto)_1fr] gap-x-4 gap-y-1.5 text-xs">
71
+ <For each={visibleRows()}>
72
+ {(row) => {
73
+ const complex = typeof row.value === "object" && row.value !== null;
74
+ return (
75
+ <>
76
+ <span class="min-w-0 truncate font-medium text-dimmed" title={row.key}>
77
+ {row.key}
78
+ </span>
79
+ <span class={`min-w-0 break-all text-secondary ${complex ? "font-mono text-[11px]" : ""}`}>
80
+ {formatInlineValue(row.value)}
81
+ </span>
82
+ </>
83
+ );
84
+ }}
85
+ </For>
86
+ </div>
87
+ <Show when={hiddenCount() > 0}>
88
+ <p class="mt-2 text-[11px] text-dimmed">{hiddenCount()} more row{hiddenCount() === 1 ? "" : "s"} hidden.</p>
89
+ </Show>
90
+ </Show>
91
+ </div>
92
+ </Show>
93
+
94
+ <div class="flex items-center gap-2">
95
+ <Show when={hasData()}>
96
+ <button
97
+ type="button"
98
+ class="text-[11px] text-dimmed transition-colors hover:text-secondary"
99
+ onClick={() => setMode(showRaw() ? "formatted" : "raw")}
100
+ >
101
+ {showRaw() ? "View formatted" : "View raw"}
102
+ </button>
103
+ </Show>
104
+ </div>
105
+ </div>
106
+ );
107
+ }