@kahitsan/ksui 0.32.0 → 0.34.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 (65) hide show
  1. package/package.json +4 -1
  2. package/src/components/base/AccountAvatar.tsx +1 -1
  3. package/src/components/base/AddAttachmentTile.tsx +4 -4
  4. package/src/components/base/BadgeSelect.tsx +10 -10
  5. package/src/components/base/Button.tsx +70 -38
  6. package/src/components/base/CameraCapture.tsx +8 -8
  7. package/src/components/base/ChartLegend.tsx +3 -3
  8. package/src/components/base/CopyButton.tsx +1 -1
  9. package/src/components/base/DataTable.tsx +68 -72
  10. package/src/components/base/DatePicker.tsx +77 -82
  11. package/src/components/base/DateTile.tsx +7 -7
  12. package/src/components/base/DetailRow.tsx +2 -2
  13. package/src/components/base/Dropdown.tsx +15 -15
  14. package/src/components/base/ExistingAttachmentTile.tsx +6 -6
  15. package/src/components/base/EyebrowBadge.tsx +15 -15
  16. package/src/components/base/FormErrorBanner.tsx +1 -1
  17. package/src/components/base/FormField.tsx +2 -2
  18. package/src/components/base/ImageCropper.tsx +19 -19
  19. package/src/components/base/ImageViewer.tsx +7 -11
  20. package/src/components/base/KpiCard.tsx +7 -7
  21. package/src/components/base/Modal.tsx +10 -14
  22. package/src/components/base/ProgressBar.tsx +63 -59
  23. package/src/components/base/RadioCardGroup.tsx +13 -6
  24. package/src/components/base/SectionHeading.tsx +4 -4
  25. package/src/components/base/SegmentedFilter.tsx +60 -4
  26. package/src/components/base/SocialLinksBar.tsx +3 -15
  27. package/src/components/base/StatusIndicator.tsx +11 -11
  28. package/src/components/base/StatusPill.tsx +15 -15
  29. package/src/components/base/TagPill.tsx +1 -1
  30. package/src/components/base/ThemeToggle.tsx +12 -20
  31. package/src/components/base/Tooltip.tsx +2 -2
  32. package/src/components/composite/AccountRadioPicker.tsx +8 -4
  33. package/src/components/composite/ComboBox.tsx +51 -44
  34. package/src/components/composite/CustomRenderer.tsx +6 -13
  35. package/src/components/composite/FileField.tsx +10 -17
  36. package/src/components/composite/FlowGraph.tsx +29 -36
  37. package/src/components/composite/FlowRunner.tsx +11 -18
  38. package/src/components/composite/FormAdvancedSection.tsx +31 -31
  39. package/src/components/composite/LiveTimer.tsx +3 -3
  40. package/src/components/composite/MarkdownNotes.tsx +11 -11
  41. package/src/components/composite/MentionTextarea.tsx +10 -10
  42. package/src/components/composite/NotFound.tsx +7 -7
  43. package/src/components/composite/PaymentAccountPicker.tsx +19 -19
  44. package/src/components/composite/SalesBodyEditor.tsx +26 -26
  45. package/src/components/composite/SearchableSelect.tsx +14 -14
  46. package/src/components/composite/SecretReveal.tsx +5 -5
  47. package/src/components/composite/TransactionAccountFields.tsx +78 -0
  48. package/src/components/composite/TransactionForm.tsx +83 -169
  49. package/src/components/composite/TransactionPayableFields.tsx +97 -0
  50. package/src/components/composite/TransferAccountsPicker.tsx +20 -20
  51. package/src/components/composite/TransferFeeChip.tsx +2 -2
  52. package/src/components/composite/VoucherPicker.tsx +25 -25
  53. package/src/components/composite/resource/ResourceForm.tsx +1 -1
  54. package/src/components/composite/resource/ResourcePage.tsx +8 -8
  55. package/src/components/composite/resource/cells.tsx +6 -3
  56. package/src/components/composite/resource/form-spec.ts +141 -0
  57. package/src/components/composite/resource/route-adapter.ts +63 -0
  58. package/src/components/composite/resource/route-spec.test.ts +125 -0
  59. package/src/components/composite/resource/route-spec.ts +158 -0
  60. package/src/index.ts +8 -0
  61. package/src/utils/INPUT_CLASS.ts +6 -3
  62. package/src/utils/account-icons.ts +29 -7
  63. package/src/utils/highlight.tsx +5 -6
  64. package/src/utils/inject-css.ts +13 -0
  65. package/tailwind.js +11 -7
@@ -40,11 +40,11 @@ function AccountTile(p: AccountTileProps): JSX.Element {
40
40
  onClick={p.onEditRequest}
41
41
  class={`group flex flex-1 min-w-0 items-center gap-3 border px-4 py-3 text-left transition-colors cursor-pointer ${p.clipClass}`}
42
42
  classList={{
43
- "border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10":
43
+ "border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_40%,transparent)] bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_5%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_10%,transparent)]":
44
44
  isFilled() && isFrom(),
45
- "border-amber-500/40 bg-amber-500/5 hover:bg-amber-500/10":
45
+ "border-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_40%,transparent)] bg-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_5%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_10%,transparent)]":
46
46
  isFilled() && !isFrom(),
47
- "border-dashed border-zinc-700 bg-zinc-900/40 hover:border-amber-500/50 hover:bg-zinc-900/60":
47
+ "border-dashed border-[var(--ks-input-border,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_50%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)]":
48
48
  !isFilled(),
49
49
  }}
50
50
  aria-label={
@@ -57,7 +57,7 @@ function AccountTile(p: AccountTileProps): JSX.Element {
57
57
  when={p.account}
58
58
  keyed
59
59
  fallback={
60
- <span class="flex h-8 w-8 items-center justify-center text-zinc-500">
60
+ <span class="flex h-8 w-8 items-center justify-center text-[var(--ks-fg-subtle,#71717a)]">
61
61
  <Plus size={18} />
62
62
  </span>
63
63
  }
@@ -66,7 +66,7 @@ function AccountTile(p: AccountTileProps): JSX.Element {
66
66
  <AccountAvatar
67
67
  account={a}
68
68
  size={28}
69
- iconClass={isFrom() ? "text-blue-300" : "text-amber-300"}
69
+ iconClass={isFrom() ? "text-[var(--ks-info,#38bdf8)]" : "text-[var(--ks-accent-hover,#fcd34d)]"}
70
70
  />
71
71
  )}
72
72
  </Show>
@@ -74,9 +74,9 @@ function AccountTile(p: AccountTileProps): JSX.Element {
74
74
  <span
75
75
  class="text-[10px] font-semibold uppercase tracking-widest"
76
76
  classList={{
77
- "text-blue-300": isFilled() && isFrom(),
78
- "text-amber-300": isFilled() && !isFrom(),
79
- "text-zinc-500": !isFilled(),
77
+ "text-[var(--ks-info,#38bdf8)]": isFilled() && isFrom(),
78
+ "text-[var(--ks-accent-hover,#fcd34d)]": isFilled() && !isFrom(),
79
+ "text-[var(--ks-fg-subtle,#71717a)]": !isFilled(),
80
80
  }}
81
81
  >
82
82
  {isFrom() ? "From" : "To"}
@@ -85,23 +85,23 @@ function AccountTile(p: AccountTileProps): JSX.Element {
85
85
  when={p.account}
86
86
  keyed
87
87
  fallback={
88
- <span class="text-sm text-zinc-500">{p.emptyHint}</span>
88
+ <span class="text-sm text-[var(--ks-fg-subtle,#71717a)]">{p.emptyHint}</span>
89
89
  }
90
90
  >
91
91
  {(a) => (
92
92
  <>
93
- <span class="truncate text-sm font-semibold text-zinc-100">
93
+ <span class="truncate text-sm font-semibold text-[var(--ks-fg,#ffffff)]">
94
94
  {a.name}
95
95
  </span>
96
96
  <Show when={a.balance != null}>
97
- <span class="flex items-baseline gap-1 text-[11px] tabular-nums text-zinc-400">
97
+ <span class="flex items-baseline gap-1 text-[11px] tabular-nums text-[var(--ks-fg-muted,#a1a1aa)]">
98
98
  <span class="truncate">{formatPHP(a.balance!)}</span>
99
99
  <Show when={p.delta !== 0}>
100
100
  <span
101
101
  class="whitespace-nowrap font-semibold"
102
102
  classList={{
103
- "text-red-400": p.delta < 0,
104
- "text-emerald-400": p.delta > 0,
103
+ "text-[var(--ks-danger-fg,#f87171)]": p.delta < 0,
104
+ "text-[var(--ks-success-fg,#34d399)]": p.delta > 0,
105
105
  }}
106
106
  data-testid={`transactions-form-transfer-delta-${p.role}`}
107
107
  >
@@ -168,7 +168,7 @@ export default function TransferAccountsPicker(
168
168
  type="button"
169
169
  data-testid="transactions-form-transfer-swap"
170
170
  onClick={swap}
171
- class="flex h-9 w-9 shrink-0 items-center justify-center self-center border border-zinc-700 bg-zinc-900 text-zinc-300 transition-colors hover:border-amber-500/60 hover:text-amber-300 cursor-pointer ks-hud-clip-button max-sm:rotate-90"
171
+ class="flex h-9 w-9 shrink-0 items-center justify-center self-center border border-[var(--ks-input-border,#3f3f46)] bg-[var(--ks-input-bg,#18181b)] text-[var(--ks-fg,#ffffff)] transition-colors hover:border-[color-mix(in_srgb,var(--ks-warning,#f59e0b)_60%,transparent)] hover:text-[var(--ks-accent-hover,#fcd34d)] cursor-pointer ks-hud-clip-button max-sm:rotate-90"
172
172
  aria-label="Swap source and destination"
173
173
  title="Swap source and destination"
174
174
  >
@@ -193,8 +193,8 @@ export default function TransferAccountsPicker(
193
193
  x2="56"
194
194
  y2="7"
195
195
  >
196
- <stop offset="0" stop-color="#3b82f6" />
197
- <stop offset="1" stop-color="#f59e0b" />
196
+ <stop offset="0" style={{ "stop-color": "var(--ks-info, #38bdf8)" }} />
197
+ <stop offset="1" style={{ "stop-color": "var(--ks-warning, #f59e0b)" }} />
198
198
  </linearGradient>
199
199
  </defs>
200
200
  <line
@@ -206,7 +206,7 @@ export default function TransferAccountsPicker(
206
206
  stroke-width="2"
207
207
  class="fin-flow-dash"
208
208
  />
209
- <path d="M44 1 L56 7 L44 13 Z" fill="#f59e0b" />
209
+ <path d="M44 1 L56 7 L44 13 Z" style={{ fill: "var(--ks-warning, #f59e0b)" }} />
210
210
  </svg>
211
211
  <svg
212
212
  viewBox="0 0 14 40"
@@ -221,8 +221,8 @@ export default function TransferAccountsPicker(
221
221
  x2="7"
222
222
  y2="40"
223
223
  >
224
- <stop offset="0" stop-color="#3b82f6" />
225
- <stop offset="1" stop-color="#f59e0b" />
224
+ <stop offset="0" style={{ "stop-color": "var(--ks-info, #38bdf8)" }} />
225
+ <stop offset="1" style={{ "stop-color": "var(--ks-warning, #f59e0b)" }} />
226
226
  </linearGradient>
227
227
  </defs>
228
228
  <line
@@ -234,7 +234,7 @@ export default function TransferAccountsPicker(
234
234
  stroke-width="2"
235
235
  class="fin-flow-dash"
236
236
  />
237
- <path d="M1 30 L13 30 L7 40 Z" fill="#f59e0b" />
237
+ <path d="M1 30 L13 30 L7 40 Z" style={{ fill: "var(--ks-warning, #f59e0b)" }} />
238
238
  </svg>
239
239
  </div>
240
240
  </Show>
@@ -13,9 +13,9 @@ export default function TransferFeeChip(props: TransferFeeChipProps) {
13
13
  onClick={props.onToggle}
14
14
  class="flex items-center gap-1.5 self-center rounded-md border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider transition-colors cursor-pointer"
15
15
  classList={{
16
- "border-blue-500/50 bg-blue-500/15 text-blue-300 hover:bg-blue-500/25":
16
+ "border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_50%,transparent)] bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_15%,transparent)] text-[var(--ks-info-fg,#7dd3fc)] hover:bg-[color-mix(in_srgb,var(--ks-info,#38bdf8)_25%,transparent)]":
17
17
  props.enabled,
18
- "border-zinc-700 bg-zinc-950/60 text-zinc-400 hover:border-blue-500/40 hover:text-blue-300":
18
+ "border-[var(--ks-input-border,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-surface,#0f0f0f)_60%,transparent)] text-[var(--ks-fg-muted,#a1a1aa)] hover:border-[color-mix(in_srgb,var(--ks-info,#38bdf8)_40%,transparent)] hover:text-[var(--ks-info-fg,#7dd3fc)]":
19
19
  !props.enabled,
20
20
  }}
21
21
  aria-pressed={props.enabled}
@@ -229,15 +229,15 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
229
229
  onClick={() => !props.disabled && setOpen((o) => !o)}
230
230
  class={`${props.compact ? "inline-flex" : "w-full flex"} items-center gap-2 ${
231
231
  props.compact ? "px-2.5 py-2" : "px-3 py-2.5"
232
- } rounded-lg bg-zinc-800/30 border border-zinc-700/50 hover:border-amber-500/40 hover:bg-amber-500/5 transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60`}
232
+ } rounded-lg bg-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_30%,transparent)] border border-[color-mix(in_srgb,var(--ks-border-strong,#3f3f46)_50%,transparent)] hover:border-[color-mix(in_srgb,var(--ks-primary,#c9a961)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-primary,#c9a961)_5%,transparent)] transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60`}
233
233
  aria-haspopup="listbox"
234
234
  aria-expanded={open()}
235
235
  >
236
- <Ticket size={16} class="shrink-0 text-zinc-400" />
237
- <Show when={props.selected} fallback={<span class="text-zinc-500 italic">No voucher</span>}>
236
+ <Ticket size={16} class="shrink-0 text-[var(--ks-fg-muted,#a1a1aa)]" />
237
+ <Show when={props.selected} fallback={<span class="text-[var(--ks-fg-subtle,#71717a)] italic">No voucher</span>}>
238
238
  <span class="flex-1 min-w-0">
239
- <span class="block truncate text-zinc-100 font-medium">{props.selected!.code}</span>
240
- <span class="block truncate text-[11px] text-emerald-400">
239
+ <span class="block truncate text-[var(--ks-fg,#ffffff)] font-medium">{props.selected!.code}</span>
240
+ <span class="block truncate text-[11px] text-[var(--ks-success-fg,#34d399)]">
241
241
  {formatVoucherDescription(props.selected!)}
242
242
  <Show when={previewDiscount() > 0}> · {formatCurrency(previewDiscount())} off</Show>
243
243
  </span>
@@ -246,7 +246,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
246
246
  type="button"
247
247
  data-testid="voucher-picker-clear"
248
248
  onClick={clear}
249
- class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
249
+ class="shrink-0 p-1 rounded text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-danger-fg,#f87171)] hover:bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] transition-colors cursor-pointer"
250
250
  title="Remove voucher"
251
251
  aria-label="Remove voucher"
252
252
  >
@@ -260,14 +260,14 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
260
260
  <div
261
261
  ref={popupRef}
262
262
  data-testid="voucher-picker-popup"
263
- class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
263
+ class="z-[100] rounded-md border border-[var(--ks-input-border,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_95%,transparent)] backdrop-blur shadow-xl overflow-hidden flex flex-col"
264
264
  style={popupStyle()}
265
265
  >
266
- <div class="px-3 py-2 border-b border-zinc-800 flex items-center gap-2">
267
- <Ticket size={14} class="text-zinc-500 shrink-0" />
268
- <span class="text-xs uppercase tracking-widest text-zinc-500 font-bold">Vouchers</span>
266
+ <div class="px-3 py-2 border-b border-[var(--ks-border-strong,#3f3f46)] flex items-center gap-2">
267
+ <Ticket size={14} class="text-[var(--ks-fg-subtle,#71717a)] shrink-0" />
268
+ <span class="text-xs uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-bold">Vouchers</span>
269
269
  <Show when={loading()}>
270
- <Loader2 size={14} class="animate-spin text-zinc-500 ml-auto shrink-0" />
270
+ <Loader2 size={14} class="animate-spin text-[var(--ks-fg-subtle,#71717a)] ml-auto shrink-0" />
271
271
  </Show>
272
272
  </div>
273
273
  <ul
@@ -277,7 +277,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
277
277
  >
278
278
  <Show when={error()}>
279
279
  <li>
280
- <div role="status" class="px-3 py-2 text-xs text-red-400">
280
+ <div role="status" class="px-3 py-2 text-xs text-[var(--ks-danger-fg,#f87171)]">
281
281
  {error()}
282
282
  </div>
283
283
  </li>
@@ -286,7 +286,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
286
286
  when={!loading() && !error() && applicable().length === 0 && inapplicable().length === 0}
287
287
  >
288
288
  <li>
289
- <div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
289
+ <div role="status" class="px-3 py-4 text-xs text-[var(--ks-fg-subtle,#71717a)] text-center">
290
290
  No vouchers available.
291
291
  </div>
292
292
  </li>
@@ -301,20 +301,20 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
301
301
  type="button"
302
302
  data-testid={`voucher-picker-result-${v.id}`}
303
303
  onClick={() => select(v)}
304
- class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-start gap-2 cursor-pointer"
304
+ class="w-full text-left px-3 py-2 hover:bg-[color-mix(in_srgb,var(--ks-primary,#c9a961)_10%,transparent)] transition-colors flex items-start gap-2 cursor-pointer"
305
305
  >
306
- <Ticket size={14} class="shrink-0 mt-0.5 text-emerald-400" aria-hidden="true" />
306
+ <Ticket size={14} class="shrink-0 mt-0.5 text-[var(--ks-success-fg,#34d399)]" aria-hidden="true" />
307
307
  <span class="flex-1 min-w-0">
308
- <span class="block text-sm text-zinc-100 truncate">{v.code}</span>
309
- <span class="block text-[11px] text-zinc-500 truncate">
308
+ <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">{v.code}</span>
309
+ <span class="block text-[11px] text-[var(--ks-fg-subtle,#71717a)] truncate">
310
310
  {formatVoucherDescription(v)}
311
311
  </span>
312
312
  </span>
313
- <span class="text-xs text-emerald-300 shrink-0 mt-0.5 font-mono">
313
+ <span class="text-xs text-[var(--ks-success-fg,#34d399)] shrink-0 mt-0.5 font-mono">
314
314
  {formatCurrency(discount())}
315
315
  </span>
316
316
  <Show when={selected()}>
317
- <span class="text-amber-400 shrink-0 mt-0.5">✓</span>
317
+ <span class="text-[var(--ks-accent,#fbbf24)] shrink-0 mt-0.5">✓</span>
318
318
  </Show>
319
319
  </button>
320
320
  </li>
@@ -323,7 +323,7 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
323
323
  </For>
324
324
  <Show when={inapplicable().length > 0}>
325
325
  <li>
326
- <div class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-widest text-zinc-600 font-semibold border-t border-zinc-800 mt-1">
326
+ <div class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-semibold border-t border-[var(--ks-border-strong,#3f3f46)] mt-1">
327
327
  Not applicable to this cart
328
328
  </div>
329
329
  </li>
@@ -335,10 +335,10 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
335
335
  class="w-full text-left px-3 py-2 flex items-start gap-2 opacity-50 cursor-not-allowed"
336
336
  aria-disabled="true"
337
337
  >
338
- <Ticket size={14} class="shrink-0 mt-0.5 text-zinc-500" aria-hidden="true" />
338
+ <Ticket size={14} class="shrink-0 mt-0.5 text-[var(--ks-fg-subtle,#71717a)]" aria-hidden="true" />
339
339
  <span class="flex-1 min-w-0">
340
- <span class="block text-sm text-zinc-300 truncate">{v.code}</span>
341
- <span class="block text-[11px] text-zinc-500 truncate">
340
+ <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">{v.code}</span>
341
+ <span class="block text-[11px] text-[var(--ks-fg-subtle,#71717a)] truncate">
342
342
  {formatVoucherDescription(v)}
343
343
  </span>
344
344
  </span>
@@ -349,12 +349,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
349
349
  </Show>
350
350
  </ul>
351
351
  <Show when={props.selected}>
352
- <div class="border-t border-zinc-800">
352
+ <div class="border-t border-[var(--ks-border-strong,#3f3f46)]">
353
353
  <button
354
354
  type="button"
355
355
  data-testid="voucher-picker-clear-from-list"
356
356
  onClick={() => select(null)}
357
- class="w-full text-left px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 transition-colors flex items-center gap-2 cursor-pointer"
357
+ class="w-full text-left px-3 py-2 text-xs text-[var(--ks-danger-fg,#f87171)] hover:bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] transition-colors flex items-center gap-2 cursor-pointer"
358
358
  >
359
359
  <X size={12} />
360
360
  <span>Remove voucher</span>
@@ -76,7 +76,7 @@ export function ResourceForm(props: ResourceFormProps) {
76
76
  <Show when={props.error}>
77
77
  <div
78
78
  data-testid={`${props.spec.testIdPrefix}-form-error`}
79
- class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400"
79
+ class="rounded-lg border border-[color-mix(in_srgb,var(--ks-danger,#ef4444)_30%,transparent)] bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] px-3 py-2 text-sm text-[var(--ks-danger-fg,#f87171)]"
80
80
  >
81
81
  {props.error}
82
82
  </div>
@@ -316,7 +316,7 @@ export function ResourcePage<T extends ResourceRow>(
316
316
  onChange={(e) =>
317
317
  setFilterState(f.param, e.currentTarget.value)
318
318
  }
319
- class="rounded-lg border border-zinc-800/50 bg-zinc-900 px-3 py-1.5 text-xs text-zinc-400 cursor-pointer"
319
+ class="rounded-lg border border-[var(--ks-border,rgba(39,39,42,0.5))] bg-[var(--ks-input-bg,#18181b)] px-3 py-1.5 text-xs text-[var(--ks-fg-muted,#a1a1aa)] cursor-pointer"
320
320
  >
321
321
  <For each={f.options}>
322
322
  {(o) => <option value={o.value}>{o.label}</option>}
@@ -344,7 +344,7 @@ export function ResourcePage<T extends ResourceRow>(
344
344
  >
345
345
  <div data-testid={`${spec.testIdPrefix}-create-modal`}>
346
346
  <div class="flex items-center justify-between mb-6">
347
- <h2 class="text-lg font-semibold text-zinc-100">
347
+ <h2 class="text-lg font-semibold text-[var(--ks-fg,#ffffff)]">
348
348
  {spec.labels.createTitle}
349
349
  </h2>
350
350
  <button
@@ -352,7 +352,7 @@ export function ResourcePage<T extends ResourceRow>(
352
352
  setCreateOpen(false);
353
353
  resetForm();
354
354
  }}
355
- class="text-zinc-500 hover:text-zinc-300 cursor-pointer"
355
+ class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-fg,#ffffff)] cursor-pointer"
356
356
  aria-label="Close"
357
357
  >
358
358
  <X size={20} />
@@ -387,7 +387,7 @@ export function ResourcePage<T extends ResourceRow>(
387
387
  >
388
388
  <div data-testid={`${spec.testIdPrefix}-detail-modal`}>
389
389
  <div class="flex items-center justify-between mb-6">
390
- <h2 class="text-lg font-semibold text-zinc-100">
390
+ <h2 class="text-lg font-semibold text-[var(--ks-fg,#ffffff)]">
391
391
  {editing()
392
392
  ? spec.labels.editTitle
393
393
  : String(row()[spec.labels.titleField] ?? "")}
@@ -396,7 +396,7 @@ export function ResourcePage<T extends ResourceRow>(
396
396
  <Show when={!editing() && canEdit()}>
397
397
  <button
398
398
  onClick={startEdit}
399
- class="text-zinc-500 hover:text-amber-400 cursor-pointer p-1"
399
+ class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-accent,#fbbf24)] cursor-pointer p-1"
400
400
  title="Edit"
401
401
  aria-label="Edit"
402
402
  >
@@ -407,7 +407,7 @@ export function ResourcePage<T extends ResourceRow>(
407
407
  {row()[spec.softDeleteField] ? (
408
408
  <button
409
409
  onClick={() => handleArchive(row().id)}
410
- class="text-zinc-500 hover:text-red-400 cursor-pointer p-1"
410
+ class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-danger-fg,#f87171)] cursor-pointer p-1"
411
411
  title="Archive"
412
412
  aria-label="Archive"
413
413
  >
@@ -416,7 +416,7 @@ export function ResourcePage<T extends ResourceRow>(
416
416
  ) : (
417
417
  <button
418
418
  onClick={() => handleRestore(row().id)}
419
- class="text-zinc-500 hover:text-emerald-400 cursor-pointer p-1"
419
+ class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-success-fg,#34d399)] cursor-pointer p-1"
420
420
  title="Restore"
421
421
  aria-label="Restore"
422
422
  >
@@ -429,7 +429,7 @@ export function ResourcePage<T extends ResourceRow>(
429
429
  setDetailRow(null);
430
430
  setEditing(false);
431
431
  }}
432
- class="text-zinc-500 hover:text-zinc-300 cursor-pointer p-1"
432
+ class="text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-fg,#ffffff)] cursor-pointer p-1"
433
433
  aria-label="Close"
434
434
  >
435
435
  <X size={20} />
@@ -18,7 +18,7 @@ export function renderCell(
18
18
  return (
19
19
  <button
20
20
  data-testid={`${spec.testIdPrefix}-row-${row.id}`}
21
- class="text-left text-zinc-200 hover:text-amber-400 transition-colors cursor-pointer"
21
+ class="text-left text-[var(--ks-fg,#ffffff)] hover:text-[var(--ks-accent,#fbbf24)] transition-colors cursor-pointer"
22
22
  onClick={() => onTitleClick(row.id)}
23
23
  >
24
24
  {String(raw ?? "")}
@@ -26,7 +26,7 @@ export function renderCell(
26
26
  );
27
27
  case "enum": {
28
28
  const key = String(raw ?? "");
29
- return <span class="text-zinc-400 text-sm capitalize">{r.labels[key] || key}</span>;
29
+ return <span class="text-[var(--ks-fg-muted,#a1a1aa)] text-sm capitalize">{r.labels[key] || key}</span>;
30
30
  }
31
31
  case "status": {
32
32
  const active = Boolean(raw);
@@ -35,7 +35,10 @@ export function renderCell(
35
35
  }
36
36
  case "text":
37
37
  default: {
38
- const cls = (r.type === "text" && r.muted) ? "text-zinc-500 text-sm" : "text-zinc-400 text-sm";
38
+ const cls =
39
+ r.type === "text" && r.muted
40
+ ? "text-[var(--ks-fg-subtle,#71717a)] text-sm"
41
+ : "text-[var(--ks-fg-muted,#a1a1aa)] text-sm";
39
42
  const text = raw === null || raw === undefined || raw === "" ? "—" : String(raw);
40
43
  return <span class={cls}>{text}</span>;
41
44
  }
@@ -0,0 +1,141 @@
1
+ // Pure builder for a declarative form descriptor. field.* helpers compile DOWN
2
+ // to the EXISTING UiField union, so ResourceForm renders them with zero new
3
+ // path. defineForm(...).fields plugs straight into the route adapter as
4
+ // spec.fields. No solid-js — data only.
5
+ import type { FieldTransform, UiField, UiFieldSelectOption } from "./spec.js";
6
+
7
+ // ---- field defs ------------------------------------------------------------
8
+
9
+ // FieldDef carries the lowered UiField body minus the key (supplied at the
10
+ // fields-record key) and label (supplied per builder). `kind` is the discriminant.
11
+ export interface FieldDefText {
12
+ readonly kind: "text";
13
+ readonly label: string;
14
+ readonly required?: boolean;
15
+ readonly transform: FieldTransform;
16
+ readonly placeholder?: string;
17
+ }
18
+ export interface FieldDefTextarea {
19
+ readonly kind: "textarea";
20
+ readonly label: string;
21
+ readonly required?: boolean;
22
+ readonly transform: FieldTransform;
23
+ readonly placeholder?: string;
24
+ readonly rows?: number;
25
+ }
26
+ export interface FieldDefSelect {
27
+ readonly kind: "select";
28
+ readonly label: string;
29
+ readonly required?: boolean;
30
+ readonly default?: string;
31
+ readonly options: readonly UiFieldSelectOption[];
32
+ }
33
+ // Declared-but-unwired kinds: present in the union so the contract is whole,
34
+ // but no field.* builder produces them — an unwired kind is a tsc error at the
35
+ // call site, never a silent runtime hole (the adapter also throws on it).
36
+ export interface FieldDefUnwired {
37
+ readonly kind: "currency" | "date" | "relation" | "file";
38
+ readonly label: string;
39
+ }
40
+ export type FieldDef = FieldDefText | FieldDefTextarea | FieldDefSelect | FieldDefUnwired;
41
+
42
+ export const field = {
43
+ text: (opts: {
44
+ label: string;
45
+ required?: boolean;
46
+ transform: FieldTransform;
47
+ placeholder?: string;
48
+ }): FieldDefText =>
49
+ Object.freeze({
50
+ kind: "text",
51
+ label: opts.label,
52
+ transform: opts.transform,
53
+ ...(opts.required ? { required: opts.required } : {}),
54
+ ...(opts.placeholder !== undefined ? { placeholder: opts.placeholder } : {}),
55
+ }),
56
+ textarea: (opts: {
57
+ label: string;
58
+ required?: boolean;
59
+ transform: FieldTransform;
60
+ placeholder?: string;
61
+ rows?: number;
62
+ }): FieldDefTextarea =>
63
+ Object.freeze({
64
+ kind: "textarea",
65
+ label: opts.label,
66
+ transform: opts.transform,
67
+ ...(opts.required ? { required: opts.required } : {}),
68
+ ...(opts.rows !== undefined ? { rows: opts.rows } : {}),
69
+ ...(opts.placeholder !== undefined ? { placeholder: opts.placeholder } : {}),
70
+ }),
71
+ select: (opts: {
72
+ label: string;
73
+ required?: boolean;
74
+ default?: string;
75
+ options: readonly UiFieldSelectOption[];
76
+ }): FieldDefSelect =>
77
+ Object.freeze({
78
+ kind: "select",
79
+ label: opts.label,
80
+ options: opts.options,
81
+ ...(opts.default !== undefined ? { default: opts.default } : {}),
82
+ ...(opts.required ? { required: opts.required } : {}),
83
+ }),
84
+ } as const;
85
+
86
+ // ---- the form spec ---------------------------------------------------------
87
+
88
+ export interface FormSpec {
89
+ readonly name?: string;
90
+ readonly title?: string;
91
+ readonly fields: Readonly<Record<string, FieldDef>>;
92
+ // Binding seam: STRING command ids, not hardcoded verbs, so a flow runtime
93
+ // can slot in without recontracting the form.
94
+ readonly submit: { readonly create?: string; readonly update?: string; readonly label?: string };
95
+ readonly layout?: unknown;
96
+ }
97
+
98
+ export function defineForm(cfg: FormSpec): FormSpec {
99
+ return Object.freeze(cfg);
100
+ }
101
+
102
+ // ---- lowering (consumed by ./route-adapter) ---------------------------------
103
+
104
+ /** Fold one FieldDef + its key into the existing UiField. THROWS on an
105
+ * unwired kind so the wired subset fails loudly at build/test. */
106
+ export function fieldToUiField(key: string, def: FieldDef): UiField {
107
+ switch (def.kind) {
108
+ case "text":
109
+ return {
110
+ key,
111
+ label: def.label,
112
+ type: "text",
113
+ ...(def.required ? { required: def.required } : {}),
114
+ transform: def.transform,
115
+ ...(def.placeholder !== undefined ? { placeholder: def.placeholder } : {}),
116
+ };
117
+ case "textarea":
118
+ return {
119
+ key,
120
+ label: def.label,
121
+ type: "textarea",
122
+ ...(def.required ? { required: def.required } : {}),
123
+ transform: def.transform,
124
+ ...(def.rows !== undefined ? { rows: def.rows } : {}),
125
+ ...(def.placeholder !== undefined ? { placeholder: def.placeholder } : {}),
126
+ };
127
+ case "select":
128
+ return {
129
+ key,
130
+ label: def.label,
131
+ type: "select",
132
+ ...(def.default !== undefined ? { default: def.default } : {}),
133
+ options: def.options,
134
+ ...(def.required ? { required: def.required } : {}),
135
+ };
136
+ default:
137
+ throw new Error(
138
+ `form-spec: field "${key}" uses unwired kind "${(def as { kind: string }).kind}" (only text/textarea/select are wired)`,
139
+ );
140
+ }
141
+ }
@@ -0,0 +1,63 @@
1
+ // The compile-down core: routeToResourceSpec folds a RouteSpec DOWN to the
2
+ // EXISTING ResourceUiSpec byte-identically, so ResourcePage (untouched) is
3
+ // the single render engine. THROWS on unrepresentable config (a 2nd view mode,
4
+ // a cards body, an unwired field kind) so the table-only subset is enforced at
5
+ // author/build time, never silently dropped. Pure — no solid-js.
6
+ import type { ResourceUiSpec, UiColumn, UiField } from "./spec.js";
7
+ import type { BodyView, RouteSpec } from "./route-spec.js";
8
+ import { fieldToUiField } from "./form-spec.js";
9
+
10
+ /** Resolve the single body view, rejecting the multi-view / cards cases the
11
+ * table-only subset does not render yet. */
12
+ function resolveTableView(view: RouteSpec["view"]): BodyView {
13
+ if ("views" in view) {
14
+ throw new Error(
15
+ "route-adapter: multi-view routes are not supported (a second view mode is deferred)",
16
+ );
17
+ }
18
+ if (view.kind !== "table") {
19
+ throw new Error(
20
+ `route-adapter: body view "${view.kind}" is not wired (only "table" lowers to ResourcePage)`,
21
+ );
22
+ }
23
+ return view;
24
+ }
25
+
26
+ function routeColumns(view: BodyView): UiColumn[] {
27
+ if (view.kind !== "table") {
28
+ throw new Error(`route-adapter: cannot lower a "${view.kind}" body to columns`);
29
+ }
30
+ return view.columns.map((c) =>
31
+ c.orderable === undefined
32
+ ? { key: c.key, title: c.title, render: c.render }
33
+ : { key: c.key, title: c.title, orderable: c.orderable, render: c.render },
34
+ );
35
+ }
36
+
37
+ function routeFields(route: RouteSpec): UiField[] {
38
+ if (!route.form) return [];
39
+ return Object.entries(route.form.fields).map(([key, def]) => fieldToUiField(key, def));
40
+ }
41
+
42
+ export function routeToResourceSpec(route: RouteSpec): ResourceUiSpec {
43
+ const view = resolveTableView(route.view);
44
+
45
+ const spec: ResourceUiSpec = {
46
+ basePath: route.basePath,
47
+ title: route.title,
48
+ ...(route.subtitle !== undefined ? { subtitle: route.subtitle } : {}),
49
+ permissions: {
50
+ view: route.permissions.view,
51
+ edit: route.permissions.edit,
52
+ delete: route.permissions.delete,
53
+ },
54
+ softDeleteField: route.softDeleteField,
55
+ testIdPrefix: route.testIdPrefix,
56
+ columns: routeColumns(view),
57
+ fields: routeFields(route),
58
+ ...(route.toolbar?.filters !== undefined ? { filters: route.toolbar.filters } : {}),
59
+ detail: route.detail,
60
+ labels: route.labels,
61
+ };
62
+ return spec;
63
+ }