@quoin-cms/admin 0.1.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 (110) hide show
  1. package/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. package/vite.config.ts +80 -0
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import { AlertTriangle } from 'lucide-svelte'
3
+
4
+ let {
5
+ open = $bindable(false),
6
+ title = 'Confirm Delete',
7
+ description = 'Are you sure? This action cannot be undone.',
8
+ isDeleting = false,
9
+ onConfirm,
10
+ }: {
11
+ open: boolean
12
+ title?: string
13
+ description?: string
14
+ isDeleting?: boolean
15
+ onConfirm: () => void
16
+ } = $props()
17
+ </script>
18
+
19
+ {#if open}
20
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
21
+ <div
22
+ class="fixed inset-0 z-50 flex items-center justify-center"
23
+ onkeydown={(e) => e.key === 'Escape' && (open = false)}
24
+ >
25
+ <!-- Backdrop -->
26
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
27
+ <div class="fixed inset-0 bg-black/40 backdrop-blur-[2px]" onclick={() => (open = false)}></div>
28
+
29
+ <!-- Dialog -->
30
+ <div class="relative z-10 w-full max-w-md rounded-xl border border-border/60 bg-card p-6 shadow-lg">
31
+ <div class="flex items-start gap-4">
32
+ <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-destructive/10">
33
+ <AlertTriangle class="h-5 w-5 text-destructive" />
34
+ </div>
35
+ <div>
36
+ <h3 class="font-semibold" style="font-family: var(--font-display);">{title}</h3>
37
+ <p class="mt-1.5 text-sm leading-relaxed text-muted-foreground">{description}</p>
38
+ </div>
39
+ </div>
40
+ <div class="mt-6 flex justify-end gap-2.5">
41
+ <button
42
+ onclick={() => (open = false)}
43
+ class="h-9 rounded-lg border border-border/60 bg-card px-4 text-sm font-medium shadow-sm transition-all hover:bg-secondary"
44
+ disabled={isDeleting}
45
+ >
46
+ Cancel
47
+ </button>
48
+ <button
49
+ onclick={onConfirm}
50
+ disabled={isDeleting}
51
+ class="h-9 rounded-lg bg-destructive px-4 text-sm font-medium text-destructive-foreground shadow-sm transition-all hover:bg-destructive/90 disabled:opacity-50"
52
+ >
53
+ {isDeleting ? 'Deleting...' : 'Delete'}
54
+ </button>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ {/if}
@@ -0,0 +1,263 @@
1
+ <script lang="ts">
2
+ import DocHeader from './doc/DocHeader.svelte'
3
+ import Autosave from './doc/Autosave.svelte'
4
+ import ApiView from './doc/ApiView.svelte'
5
+ import VersionHistory from './doc/VersionHistory.svelte'
6
+ import ScheduleModal from './doc/ScheduleModal.svelte'
7
+ import DynamicForm from './DynamicForm.svelte'
8
+ import { resolveFieldComponent } from './fields/index.js'
9
+ import { useDirtyState } from '$lib/utils/dirty.svelte.js'
10
+ import { unpublishRecord } from '$lib/api/records.js'
11
+ import { toast } from 'svelte-sonner'
12
+ import type { CollectionSchema, FieldSchema } from '$lib/types/schema.js'
13
+
14
+ let {
15
+ collection,
16
+ record = null,
17
+ mode,
18
+ isSubmitting = false,
19
+ errors = {},
20
+ onSave,
21
+ }: {
22
+ collection: CollectionSchema
23
+ record?: Record<string, any> | null
24
+ mode: 'create' | 'edit'
25
+ isSubmitting?: boolean
26
+ errors?: Record<string, string>
27
+ onSave: (data: Record<string, any>) => void
28
+ } = $props()
29
+
30
+ function flatten(flds: FieldSchema[]): FieldSchema[] {
31
+ const out: FieldSchema[] = []
32
+ for (const f of flds) {
33
+ if (f.type === 'tabs') {
34
+ const tabs = (f as any).tabs ?? []
35
+ for (const t of tabs) out.push(...flatten(t.fields ?? []))
36
+ continue
37
+ }
38
+ if (f.type === 'row') {
39
+ out.push(...flatten((f as any).fields ?? []))
40
+ continue
41
+ }
42
+ out.push(f)
43
+ }
44
+ return out
45
+ }
46
+
47
+ function buildInitial(flds: FieldSchema[], src: Record<string, any>): Record<string, any> {
48
+ const data: Record<string, any> = {}
49
+ for (const f of flatten(flds)) {
50
+ if (src[f.name] !== undefined && src[f.name] !== null) {
51
+ let val = src[f.name]
52
+ if (f.type === 'tags' && typeof val === 'string') {
53
+ try { val = JSON.parse(val) } catch { val = [] }
54
+ }
55
+ data[f.name] = val
56
+ } else if ((f as any).defaultValue !== undefined) {
57
+ data[f.name] = (f as any).defaultValue
58
+ } else if (f.type === 'checkbox') {
59
+ data[f.name] = false
60
+ } else if (f.type === 'tags') {
61
+ data[f.name] = []
62
+ } else if (f.type === 'number' || f.type === 'file' || f.type === 'relationship' || f.type === 'richtext') {
63
+ data[f.name] = null
64
+ } else {
65
+ data[f.name] = ''
66
+ }
67
+ }
68
+ return data
69
+ }
70
+
71
+ // Build formData synchronously so child fields (RichTextField etc.) mount with
72
+ // real values, not stale defaults. The $effect below handles reactive updates
73
+ // when the user navigates to a different record.
74
+ // svelte-ignore state_referenced_locally
75
+ let formData = $state<Record<string, any>>(buildInitial(collection.fields, record ?? {}))
76
+ // svelte-ignore state_referenced_locally
77
+ let initialData = $state<Record<string, any>>(buildInitial(collection.fields, record ?? {}))
78
+ // svelte-ignore state_referenced_locally
79
+ let baselineKey = $state<string | null>(record?.id ?? '__new__')
80
+ let headerMode = $state<'edit' | 'api'>('edit')
81
+ function setHeaderMode(m: 'edit' | 'api') {
82
+ headerMode = m
83
+ }
84
+
85
+ // Rebuild only when navigating to a different record.
86
+ $effect(() => {
87
+ const key = record?.id ?? '__new__'
88
+ if (baselineKey !== key) {
89
+ formData = buildInitial(collection.fields, record ?? {})
90
+ initialData = buildInitial(collection.fields, record ?? {})
91
+ baselineKey = key
92
+ }
93
+ })
94
+
95
+ const dirty = useDirtyState(
96
+ () => formData,
97
+ () => initialData
98
+ )
99
+
100
+ let liveTitle = $derived.by(() => {
101
+ const titleField = collection.admin?.useAsTitle
102
+ if (titleField && formData[titleField]) return String(formData[titleField])
103
+ return mode === 'create' ? `New ${collection.label}` : (record?.id ?? collection.label)
104
+ })
105
+
106
+ let hasDrafts = $derived(collection.versions?.drafts != null)
107
+ let hasAutosave = $derived(collection.versions?.drafts?.autosave != null)
108
+ let hasSchedulePublish = $derived(collection.versions?.drafts?.schedulePublish === true)
109
+ let hasStatusField = $derived(hasDrafts)
110
+
111
+ let scheduleModalOpen = $state(false)
112
+ let isScheduling = $state(false)
113
+
114
+ let status = $derived(formData._status ?? record?._status ?? 'draft')
115
+ let saveLabel = $derived.by(() => {
116
+ if (!hasDrafts) return mode === 'create' ? 'Save' : 'Save'
117
+ if (mode === 'create') return status === 'published' ? 'Publish' : 'Create draft'
118
+ return status === 'published' ? 'Publish changes' : 'Save draft'
119
+ })
120
+
121
+ // Tabs collected from a field of type "tabs" if present
122
+ let tabsField = $derived(
123
+ collection.fields.find((f: FieldSchema) => f.type === 'tabs') as any
124
+ )
125
+ let tabLabels = $derived<{ label: string }[]>(
126
+ tabsField ? (tabsField.tabs ?? []).map((t: any) => ({ label: t.label })) : []
127
+ )
128
+ let activeTab = $state(0)
129
+
130
+ let sidebarFields = $derived(collection.admin?.sidebarFields ?? [])
131
+ let sidebarSet = $derived(new Set(sidebarFields))
132
+ let sideFields = $derived(collection.fields.filter((f: FieldSchema) => !f.hidden && sidebarSet.has(f.name)))
133
+
134
+ function handleSave() {
135
+ const out: Record<string, any> = {}
136
+ for (const k of Object.keys(formData)) {
137
+ const v = formData[k]
138
+ if (v === undefined || v === null || v === '') continue
139
+ out[k] = v
140
+ }
141
+ initialData = { ...formData }
142
+ dirty.allowNavigation()
143
+ onSave(out)
144
+ }
145
+
146
+ function handleSaveAs(newStatus: 'draft' | 'published' | 'archived') {
147
+ if (hasDrafts) formData._status = newStatus
148
+ handleSave()
149
+ }
150
+
151
+ async function handleUnpublish() {
152
+ if (!record?.id) return
153
+ const result = await unpublishRecord(collection.key, record.id)
154
+ if (result.ok) {
155
+ formData._status = 'draft'
156
+ initialData = { ...formData }
157
+ record = result.data
158
+ toast.success('Record unpublished')
159
+ } else {
160
+ toast.error(result.error)
161
+ }
162
+ }
163
+
164
+ function openSchedule() {
165
+ scheduleModalOpen = true
166
+ }
167
+
168
+ function handleSchedule(isoDate: string) {
169
+ isScheduling = true
170
+ formData.publishAt = isoDate
171
+ formData._status = 'draft'
172
+ scheduleModalOpen = false
173
+ try {
174
+ handleSave()
175
+ toast.success(`Scheduled to publish at ${new Date(isoDate).toLocaleString()}`)
176
+ } finally {
177
+ isScheduling = false
178
+ }
179
+ }
180
+ </script>
181
+
182
+ {#snippet autosaveMeta()}
183
+ {#if hasAutosave && headerMode === 'edit' && record?.id}
184
+ <Autosave
185
+ collectionKey={collection.key}
186
+ recordId={record.id}
187
+ {formData}
188
+ interval={collection.versions?.drafts?.autosave?.interval ?? 2000}
189
+ />
190
+ {/if}
191
+ {/snippet}
192
+
193
+ <DocHeader
194
+ {collection}
195
+ {liveTitle}
196
+ {record}
197
+ {mode}
198
+ {status}
199
+ {saveLabel}
200
+ isDirty={dirty.isDirty}
201
+ {isSubmitting}
202
+ onSave={handleSave}
203
+ onSaveAs={handleSaveAs}
204
+ onUnpublish={handleUnpublish}
205
+ onSchedule={openSchedule}
206
+ tabs={tabLabels}
207
+ bind:activeTab
208
+ {headerMode}
209
+ {setHeaderMode}
210
+ {hasDrafts}
211
+ {hasSchedulePublish}
212
+ metaExtra={autosaveMeta}
213
+ />
214
+
215
+ <ScheduleModal
216
+ bind:open={scheduleModalOpen}
217
+ initialValue={formData.publishAt ?? ''}
218
+ isSubmitting={isScheduling}
219
+ onSchedule={handleSchedule}
220
+ />
221
+
222
+
223
+ {#if headerMode === 'api'}
224
+ <ApiView
225
+ bind:formData
226
+ collectionKey={collection.key}
227
+ recordId={record?.id ?? null}
228
+ {mode}
229
+ onApply={() => (headerMode = 'edit')}
230
+ />
231
+ {:else}
232
+ <div class="grid gap-9 px-7 py-8 {sideFields.length > 0 || (collection.versions && mode === 'edit' && record?.id) ? 'lg:grid-cols-[1fr_320px]' : ''}">
233
+ <div class="min-w-0">
234
+ <DynamicForm
235
+ fields={collection.fields}
236
+ initialData={record ?? {}}
237
+ {errors}
238
+ {sidebarFields}
239
+ {tabsField}
240
+ {activeTab}
241
+ bind:formData
242
+ />
243
+ </div>
244
+
245
+ {#if sideFields.length > 0 || (collection.versions && mode === 'edit' && record?.id)}
246
+ <aside class="space-y-5 lg:sticky lg:top-[260px] lg:self-start">
247
+ {#each sideFields as f (f.name)}
248
+ {@const FieldComponent = resolveFieldComponent(f)}
249
+ {#if FieldComponent}
250
+ <FieldComponent field={f} bind:value={formData[f.name]} error={errors[f.name]} {formData} />
251
+ {/if}
252
+ {/each}
253
+
254
+ {#if collection.versions && mode === 'edit' && record?.id}
255
+ <VersionHistory
256
+ collectionKey={collection.key}
257
+ recordId={record.id}
258
+ />
259
+ {/if}
260
+ </aside>
261
+ {/if}
262
+ </div>
263
+ {/if}
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import type { FieldSchema } from '$lib/types/schema.js'
3
+ import { resolveFieldComponent } from './fields/index.js'
4
+
5
+ let {
6
+ fields,
7
+ initialData = {},
8
+ errors = {},
9
+ sidebarFields = [],
10
+ tabsField = undefined,
11
+ activeTab = 0,
12
+ formData = $bindable<Record<string, any>>({}),
13
+ }: {
14
+ fields: FieldSchema[]
15
+ initialData?: Record<string, any>
16
+ errors?: Record<string, string>
17
+ sidebarFields?: string[]
18
+ tabsField?: any
19
+ activeTab?: number
20
+ formData?: Record<string, any>
21
+ } = $props()
22
+
23
+ let sidebarFieldSet = $derived(new Set(sidebarFields))
24
+ let mainFields = $derived(
25
+ fields.filter((f) => !f.hidden && !sidebarFieldSet.has(f.name))
26
+ )
27
+
28
+ // formData is owned by the parent (DocumentEditLayout) and bound in.
29
+ // DynamicForm is now a pure renderer.
30
+ let localErrors = $state<Record<string, string>>({})
31
+ let mergedErrors = $derived({ ...errors, ...localErrors })
32
+ </script>
33
+
34
+ {#snippet fieldRenderer(f: FieldSchema)}
35
+ {@const FieldComponent = resolveFieldComponent(f)}
36
+ {#if FieldComponent}
37
+ <FieldComponent
38
+ field={f}
39
+ bind:value={formData[f.name]}
40
+ error={mergedErrors[f.name]}
41
+ {formData}
42
+ />
43
+ {:else}
44
+ <p class="text-sm text-muted-foreground">Unknown field type: {f.type}</p>
45
+ {/if}
46
+ {/snippet}
47
+
48
+ <div class="space-y-6">
49
+ {#each mainFields as f, i (f.name || `${f.type}-${i}`)}
50
+ {#if f.type === 'tabs'}
51
+ {#if tabsField && tabsField.tabs && tabsField.tabs[activeTab]}
52
+ {#each (tabsField.tabs[activeTab].fields ?? []) as cf (cf.name)}
53
+ {#if cf.type === 'row'}
54
+ <div class="grid gap-4" style="grid-template-columns: repeat({(cf.fields ?? []).length}, minmax(0, 1fr));">
55
+ {#each (cf.fields ?? []).filter((x: FieldSchema) => !x.hidden) as rf (rf.name)}
56
+ {@render fieldRenderer(rf)}
57
+ {/each}
58
+ </div>
59
+ {:else}
60
+ {@render fieldRenderer(cf)}
61
+ {/if}
62
+ {/each}
63
+ {/if}
64
+ {:else if f.type === 'row'}
65
+ <div class="grid gap-4" style="grid-template-columns: repeat({(f.fields ?? []).length}, minmax(0, 1fr));">
66
+ {#each (f.fields ?? []).filter((x: FieldSchema) => !x.hidden) as rf (rf.name)}
67
+ {@render fieldRenderer(rf)}
68
+ {/each}
69
+ </div>
70
+ {:else}
71
+ {@render fieldRenderer(f)}
72
+ {/if}
73
+ {/each}
74
+ </div>
@@ -0,0 +1,75 @@
1
+ <script lang="ts">
2
+ type Format = 'int' | 'percent' | 'pp'
3
+
4
+ let {
5
+ label,
6
+ current,
7
+ previous,
8
+ format = 'int',
9
+ }: {
10
+ label: string
11
+ current: number | null
12
+ previous: number | null
13
+ format?: Format
14
+ } = $props()
15
+
16
+ const intFmt = new Intl.NumberFormat()
17
+
18
+ const headline = $derived.by(() => {
19
+ if (current === null) return '—'
20
+ if (format === 'int') return intFmt.format(current)
21
+ if (format === 'percent') return (current * 100).toFixed(2) + '%'
22
+ // pp: treat current as percentage-point value already
23
+ return current.toFixed(2) + 'pp'
24
+ })
25
+
26
+ type Delta = {
27
+ text: string
28
+ tone: 'up' | 'down' | 'flat' | 'none'
29
+ }
30
+
31
+ const delta = $derived.by<Delta>(() => {
32
+ if (previous === null || previous === 0 || current === null) {
33
+ return { text: '—', tone: 'none' }
34
+ }
35
+ if (format === 'pp') {
36
+ const diff = current - previous
37
+ if (diff === 0) return { text: '—', tone: 'flat' }
38
+ const sign = diff > 0 ? '+' : ''
39
+ return {
40
+ text: sign + diff.toFixed(2) + 'pp',
41
+ tone: diff > 0 ? 'up' : 'down',
42
+ }
43
+ }
44
+ const pct = ((current - previous) / previous) * 100
45
+ if (pct === 0) return { text: '—', tone: 'flat' }
46
+ const sign = pct > 0 ? '+' : ''
47
+ return {
48
+ text: sign + pct.toFixed(1) + '%',
49
+ tone: pct > 0 ? 'up' : 'down',
50
+ }
51
+ })
52
+
53
+ const arrow = $derived(delta.tone === 'up' ? '▲' : delta.tone === 'down' ? '▼' : '')
54
+
55
+ const chipClass = $derived(
56
+ delta.tone === 'up'
57
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
58
+ : delta.tone === 'down'
59
+ ? 'border-red-200 bg-red-50 text-red-700'
60
+ : 'border-border/60 bg-muted text-muted-foreground',
61
+ )
62
+ </script>
63
+
64
+ <div class="rounded-lg border border-border/60 bg-card p-4 shadow-sm">
65
+ <p class="text-sm font-medium text-muted-foreground">{label}</p>
66
+ <p class="mt-2 text-3xl font-semibold tracking-tight text-foreground">{headline}</p>
67
+ <div class="mt-2">
68
+ <span
69
+ class="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium {chipClass}"
70
+ >
71
+ {#if arrow}<span aria-hidden="true">{arrow}</span>{/if}
72
+ <span>{delta.text}</span>
73
+ </span>
74
+ </div>
75
+ </div>