@prenta/admin 2.7.0 → 2.8.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 (163) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/design-system/tokens.css +17 -0
  3. package/design-system/tokens.json +35 -1
  4. package/design-system/tokens.scss +15 -0
  5. package/design-system/tokens.tailwind.cjs +14 -0
  6. package/dist/AdminRoot.d.ts.map +1 -1
  7. package/dist/AdminRoot.js +2 -2
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
  10. package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/batch-wizard.test.js +144 -0
  12. package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
  13. package/dist/__tests__/lib/job-drawer.test.js +2 -0
  14. package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
  15. package/dist/__tests__/lib/job-view.test.js +100 -3
  16. package/dist/__tests__/lib/job-view.test.js.map +1 -1
  17. package/dist/__tests__/lib/jobs-service.test.js +56 -2
  18. package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
  19. package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
  22. package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
  23. package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
  24. package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
  25. package/dist/__tests__/views/header-footer-form.test.js +266 -0
  26. package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
  27. package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
  28. package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
  29. package/dist/__tests__/views/jobs-list.render.test.js +7 -5
  30. package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
  31. package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
  34. package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
  35. package/dist/lib/api.d.ts +2 -0
  36. package/dist/lib/api.d.ts.map +1 -1
  37. package/dist/lib/api.js +8 -1
  38. package/dist/lib/api.js.map +1 -1
  39. package/dist/lib/batch-wizard.d.ts +84 -0
  40. package/dist/lib/batch-wizard.d.ts.map +1 -0
  41. package/dist/lib/batch-wizard.js +122 -0
  42. package/dist/lib/batch-wizard.js.map +1 -0
  43. package/dist/lib/chrome-service.d.ts +69 -0
  44. package/dist/lib/chrome-service.d.ts.map +1 -0
  45. package/dist/lib/chrome-service.js +108 -0
  46. package/dist/lib/chrome-service.js.map +1 -0
  47. package/dist/lib/job-view.d.ts +43 -0
  48. package/dist/lib/job-view.d.ts.map +1 -1
  49. package/dist/lib/job-view.js +57 -1
  50. package/dist/lib/job-view.js.map +1 -1
  51. package/dist/lib/jobs-service.d.ts +19 -1
  52. package/dist/lib/jobs-service.d.ts.map +1 -1
  53. package/dist/lib/jobs-service.js +41 -0
  54. package/dist/lib/jobs-service.js.map +1 -1
  55. package/dist/prenta-admin.css +1 -1
  56. package/dist/views/AppearanceChrome.d.ts +22 -4
  57. package/dist/views/AppearanceChrome.d.ts.map +1 -1
  58. package/dist/views/AppearanceChrome.js +451 -15
  59. package/dist/views/AppearanceChrome.js.map +1 -1
  60. package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
  61. package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
  62. package/dist/views/Jobs/CancelJobDialog.js +13 -3
  63. package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
  64. package/dist/views/Jobs/DrawerReview.d.ts +7 -0
  65. package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
  66. package/dist/views/Jobs/DrawerReview.js +1 -1
  67. package/dist/views/Jobs/DrawerReview.js.map +1 -1
  68. package/dist/views/Jobs/JobDrawer.d.ts +23 -3
  69. package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
  70. package/dist/views/Jobs/JobDrawer.js +30 -5
  71. package/dist/views/Jobs/JobDrawer.js.map +1 -1
  72. package/dist/views/Jobs/JobsTable.d.ts +4 -2
  73. package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
  74. package/dist/views/Jobs/JobsTable.js +6 -5
  75. package/dist/views/Jobs/JobsTable.js.map +1 -1
  76. package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
  77. package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
  78. package/dist/views/Jobs/NewBatchJob.js +282 -0
  79. package/dist/views/Jobs/NewBatchJob.js.map +1 -0
  80. package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
  81. package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
  82. package/dist/views/Jobs/WorkerStrip.js +2 -2
  83. package/dist/views/Jobs/WorkerStrip.js.map +1 -1
  84. package/dist/views/Jobs/focus.d.ts +6 -0
  85. package/dist/views/Jobs/focus.d.ts.map +1 -1
  86. package/dist/views/Jobs/focus.js +10 -0
  87. package/dist/views/Jobs/focus.js.map +1 -1
  88. package/dist/views/Jobs.d.ts +12 -3
  89. package/dist/views/Jobs.d.ts.map +1 -1
  90. package/dist/views/Jobs.js +109 -31
  91. package/dist/views/Jobs.js.map +1 -1
  92. package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
  93. package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
  94. package/dist/views/header-footer/AnnouncementTab.js +43 -0
  95. package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
  96. package/dist/views/header-footer/ChromeField.d.ts +25 -0
  97. package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
  98. package/dist/views/header-footer/ChromeField.js +99 -0
  99. package/dist/views/header-footer/ChromeField.js.map +1 -0
  100. package/dist/views/header-footer/ChromePreview.d.ts +31 -0
  101. package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
  102. package/dist/views/header-footer/ChromePreview.js +195 -0
  103. package/dist/views/header-footer/ChromePreview.js.map +1 -0
  104. package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
  105. package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
  106. package/dist/views/header-footer/DeveloperTab.js +98 -0
  107. package/dist/views/header-footer/DeveloperTab.js.map +1 -0
  108. package/dist/views/header-footer/RegionTab.d.ts +27 -0
  109. package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
  110. package/dist/views/header-footer/RegionTab.js +80 -0
  111. package/dist/views/header-footer/RegionTab.js.map +1 -0
  112. package/dist/views/header-footer/chrome-form.d.ts +137 -0
  113. package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
  114. package/dist/views/header-footer/chrome-form.js +518 -0
  115. package/dist/views/header-footer/chrome-form.js.map +1 -0
  116. package/dist/views/header-footer/code-samples.d.ts +30 -0
  117. package/dist/views/header-footer/code-samples.d.ts.map +1 -0
  118. package/dist/views/header-footer/code-samples.js +127 -0
  119. package/dist/views/header-footer/code-samples.js.map +1 -0
  120. package/dist/views/header-footer/controls.d.ts +57 -0
  121. package/dist/views/header-footer/controls.d.ts.map +1 -0
  122. package/dist/views/header-footer/controls.js +43 -0
  123. package/dist/views/header-footer/controls.js.map +1 -0
  124. package/package.json +3 -3
  125. package/src/AdminRoot.tsx +16 -2
  126. package/src/__tests__/lib/batch-wizard.test.ts +199 -0
  127. package/src/__tests__/lib/job-drawer.test.ts +2 -0
  128. package/src/__tests__/lib/job-view.test.ts +123 -2
  129. package/src/__tests__/lib/jobs-service.test.ts +72 -3
  130. package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
  131. package/src/__tests__/views/header-footer-form.test.ts +333 -0
  132. package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
  133. package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
  134. package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
  135. package/src/lib/api.ts +12 -1
  136. package/src/lib/batch-wizard.ts +188 -0
  137. package/src/lib/chrome-service.ts +200 -0
  138. package/src/lib/job-view.ts +95 -1
  139. package/src/lib/jobs-service.ts +56 -1
  140. package/src/styles/prenta-tokens.css +17 -0
  141. package/src/styles/theme.css +15 -0
  142. package/src/views/AppearanceChrome.tsx +704 -46
  143. package/src/views/Jobs/CancelJobDialog.tsx +20 -2
  144. package/src/views/Jobs/DrawerReview.tsx +1 -1
  145. package/src/views/Jobs/JobDrawer.tsx +83 -3
  146. package/src/views/Jobs/JobsTable.tsx +11 -4
  147. package/src/views/Jobs/NewBatchJob.tsx +857 -0
  148. package/src/views/Jobs/WorkerStrip.tsx +16 -11
  149. package/src/views/Jobs/focus.ts +11 -0
  150. package/src/views/Jobs.tsx +163 -49
  151. package/src/views/header-footer/AnnouncementTab.tsx +231 -0
  152. package/src/views/header-footer/ChromeField.tsx +358 -0
  153. package/src/views/header-footer/ChromePreview.tsx +497 -0
  154. package/src/views/header-footer/DeveloperTab.tsx +331 -0
  155. package/src/views/header-footer/RegionTab.tsx +398 -0
  156. package/src/views/header-footer/chrome-form.ts +603 -0
  157. package/src/views/header-footer/code-samples.ts +146 -0
  158. package/src/views/header-footer/controls.tsx +166 -0
  159. package/dist/views/Jobs/placeholders.d.ts +0 -6
  160. package/dist/views/Jobs/placeholders.d.ts.map +0 -1
  161. package/dist/views/Jobs/placeholders.js +0 -20
  162. package/dist/views/Jobs/placeholders.js.map +0 -1
  163. package/src/views/Jobs/placeholders.tsx +0 -66
@@ -0,0 +1,333 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RegionSchema } from '@prenta/core'
3
+
4
+ import {
5
+ announcementForm,
6
+ announcementProblem,
7
+ announcementSaveBody,
8
+ fieldProblem,
9
+ fromLocalInput,
10
+ humanizeKey,
11
+ menuListSlots,
12
+ overrideChips,
13
+ previewChrome,
14
+ regionForm,
15
+ regionSaveBody,
16
+ repoFileUrl,
17
+ timeAgo,
18
+ toLocalInput,
19
+ } from '../../views/header-footer/chrome-form.js'
20
+ import {
21
+ CODE_SAMPLES,
22
+ LOCAL_DEV_SAMPLE,
23
+ highlight,
24
+ } from '../../views/header-footer/code-samples.js'
25
+
26
+ const source = { repo: 'actuate/website', branch: 'main', commit: 'a41f9c2', readAt: '' }
27
+
28
+ const header: RegionSchema = {
29
+ region: 'header',
30
+ component: './src/components/site/Header.astro',
31
+ variants: { default: 'Default', minimal: 'Logo + CTA' },
32
+ fields: {
33
+ logo: { type: 'brand', key: 'logo' },
34
+ menu: { type: 'menu', default: 'main' },
35
+ cols: { type: 'menu', list: true, max: 2 },
36
+ topMenu: { type: 'menu', optional: true },
37
+ cta: { type: 'link', optional: true },
38
+ contact: { type: 'link' },
39
+ phone: { type: 'text', optional: true, format: 'tel' },
40
+ email: { type: 'text', format: 'email' },
41
+ copy: { type: 'text', tokens: ['{year}'] },
42
+ tone: { type: 'select', options: ['dark', 'light'] },
43
+ sticky: { type: 'boolean', default: true },
44
+ social: { type: 'social' },
45
+ },
46
+ source,
47
+ }
48
+
49
+ describe('regionForm', () => {
50
+ it('fills every field with its default, in schema order, and skips brand fields', () => {
51
+ const form = regionForm(header, null)
52
+ expect(form.variant).toBe('default')
53
+ expect(Object.keys(form.fields)).toEqual([
54
+ 'menu',
55
+ 'cols',
56
+ 'topMenu',
57
+ 'cta',
58
+ 'contact',
59
+ 'phone',
60
+ 'email',
61
+ 'copy',
62
+ 'tone',
63
+ 'sticky',
64
+ 'social',
65
+ ])
66
+ expect(form.fields).toMatchObject({
67
+ menu: 'main',
68
+ cols: [],
69
+ topMenu: { on: false, value: '' },
70
+ cta: { on: false, label: '', url: '' },
71
+ contact: { label: '', url: '' },
72
+ phone: { on: false, value: '' },
73
+ tone: 'dark',
74
+ sticky: true,
75
+ social: [],
76
+ })
77
+ })
78
+
79
+ it('keeps saved values that fit and falls back for those that do not', () => {
80
+ const form = regionForm(header, {
81
+ region: 'header',
82
+ variant: 'gone',
83
+ fields: { menu: 'other', tone: 'purple', sticky: false, phone: { on: true, value: '555' } },
84
+ orphans: [],
85
+ updatedBy: null,
86
+ updatedAt: null,
87
+ })
88
+ expect(form.variant).toBe('default')
89
+ expect(form.fields.menu).toBe('other')
90
+ expect(form.fields.tone).toBe('dark')
91
+ expect(form.fields.sticky).toBe(false)
92
+ expect(form.fields.phone).toEqual({ on: true, value: '555' })
93
+ })
94
+ })
95
+
96
+ describe('regionSaveBody', () => {
97
+ it('sends what PUT /chrome/:region accepts', () => {
98
+ const form = regionForm(header, null)
99
+ form.fields.menu = ''
100
+ form.fields.cols = ['company', '', 'company', 'legal']
101
+ form.fields.social = [
102
+ { platform: 'LinkedIn', url: 'https://linkedin.com/x' },
103
+ { platform: '', url: '' },
104
+ ]
105
+ const body = regionSaveBody(header, form)
106
+ expect(body.variant).toBe('default')
107
+ // An empty required menu or link is left out; brand fields never go.
108
+ expect('menu' in body.fields).toBe(false)
109
+ expect('contact' in body.fields).toBe(false)
110
+ expect('logo' in body.fields).toBe(false)
111
+ expect(body.fields.cols).toEqual(['company', 'legal'])
112
+ expect(body.fields.social).toEqual([{ platform: 'LinkedIn', url: 'https://linkedin.com/x' }])
113
+ })
114
+
115
+ it('leaves out untouched defaults, so the code keeps owning them', () => {
116
+ const form = regionForm(header, null)
117
+ form.fields.tone = 'light'
118
+ const body = regionSaveBody(header, form, {})
119
+ expect(body.fields).toEqual({ tone: 'light' })
120
+
121
+ // A saved value is sent back even when it equals the default.
122
+ const saved = { sticky: true, topMenu: { on: false, value: '' } }
123
+ const again = regionSaveBody(
124
+ header,
125
+ regionForm(header, {
126
+ region: 'header',
127
+ variant: 'default',
128
+ fields: saved,
129
+ orphans: [],
130
+ updatedBy: null,
131
+ updatedAt: null,
132
+ }),
133
+ saved,
134
+ )
135
+ expect(again.fields).toEqual(saved)
136
+ })
137
+ })
138
+
139
+ describe('menuListSlots', () => {
140
+ it('starts at three, grows as the list fills, and stops at max', () => {
141
+ expect(menuListSlots({ max: 3 }, 0)).toBe(3)
142
+ expect(menuListSlots({ max: 3 }, 3)).toBe(3)
143
+ expect(menuListSlots({ max: 2 }, 0)).toBe(2)
144
+ expect(menuListSlots({}, 0)).toBe(3)
145
+ expect(menuListSlots({}, 3)).toBe(4)
146
+ expect(menuListSlots({}, 20)).toBe(20)
147
+ // Never fewer than are saved, so each can be cleared.
148
+ expect(menuListSlots({ max: 2 }, 4)).toBe(4)
149
+ })
150
+ })
151
+
152
+ describe('fieldProblem', () => {
153
+ const fields = header.fields
154
+ it('mirrors the API checks', () => {
155
+ expect(fieldProblem(fields.topMenu!, { on: true, value: '' })).toBe(
156
+ 'Pick a menu, or turn this off.',
157
+ )
158
+ expect(fieldProblem(fields.topMenu!, { on: false, value: '' })).toBeNull()
159
+ expect(fieldProblem(fields.cols!, ['a', 'b', 'c'])).toBe('Pick at most 2 menus.')
160
+ expect(fieldProblem({ type: 'menu', list: true }, ['a', 'b', 'c', 'd'])).toBeNull()
161
+ expect(fieldProblem(fields.cta!, { on: true, label: 'Go', url: '' })).toBe('Add the link URL.')
162
+ expect(fieldProblem(fields.cta!, { on: false, label: 'Go', url: '' })).toBeNull()
163
+ expect(fieldProblem(fields.cta!, { on: true, label: 'Go', url: 'javascript:x' })).toBe(
164
+ 'Links can be a site path or an http(s), mailto or tel link.',
165
+ )
166
+ expect(fieldProblem(fields.contact!, { label: '', url: '' })).toBeNull()
167
+ expect(fieldProblem(fields.phone!, { on: true, value: 'call me' })).toBe(
168
+ 'Enter a phone number.',
169
+ )
170
+ expect(fieldProblem(fields.email!, 'nope')).toBe('Enter an email address.')
171
+ expect(fieldProblem(fields.email!, 'a@b.co')).toBeNull()
172
+ expect(fieldProblem({ type: 'textarea', max: 3 }, 'abcd')).toBe('Keep it to 3 characters.')
173
+ expect(fieldProblem(fields.social!, [{ platform: '', url: 'https://x.com' }])).toBe(
174
+ 'Pick a platform for each link.',
175
+ )
176
+ })
177
+ })
178
+
179
+ describe('announcement', () => {
180
+ const saved = {
181
+ id: 'ann_1',
182
+ on: true,
183
+ text: 'Hello',
184
+ link: null,
185
+ tone: 'brand' as const,
186
+ dismissible: true,
187
+ schedule: { startsAt: '2026-11-03T17:00:00.000Z', endsAt: null },
188
+ }
189
+
190
+ it('round-trips the schedule through datetime-local in the local zone', () => {
191
+ const form = announcementForm(saved)
192
+ expect(form.scheduled).toBe(true)
193
+ expect(form.startsAt).toBe(toLocalInput('2026-11-03T17:00:00.000Z'))
194
+ expect(fromLocalInput(form.startsAt)).toBe('2026-11-03T17:00:00.000Z')
195
+ expect(fromLocalInput('')).toBeNull()
196
+ expect(toLocalInput(null)).toBe('')
197
+ const body = announcementSaveBody(form)
198
+ expect(body).toEqual({
199
+ on: true,
200
+ text: 'Hello',
201
+ link: null,
202
+ tone: 'brand',
203
+ dismissible: true,
204
+ schedule: { startsAt: '2026-11-03T17:00:00.000Z', endsAt: null },
205
+ })
206
+ expect(announcementSaveBody({ ...form, scheduled: false }).schedule).toBeNull()
207
+ })
208
+
209
+ it('sends an untouched time back as saved, even in a repeated clock-change hour', () => {
210
+ // Each of these is the second of two equal local times somewhere: the
211
+ // fall-back hour in Los Angeles, New York and London.
212
+ for (const iso of [
213
+ '2026-11-01T09:30:00.000Z',
214
+ '2026-11-01T06:30:00.000Z',
215
+ '2026-10-25T01:30:00.000Z',
216
+ ]) {
217
+ const form = announcementForm({ ...saved, schedule: { startsAt: iso, endsAt: null } })
218
+ expect(announcementSaveBody(form).schedule?.startsAt).toBe(iso)
219
+ }
220
+ })
221
+
222
+ it('says what the API would refuse', () => {
223
+ const form = announcementForm(saved)
224
+ expect(announcementProblem(form)).toBeNull()
225
+ expect(announcementProblem({ ...form, text: ' ' })).toBe(
226
+ 'Add a message before turning the bar on.',
227
+ )
228
+ expect(announcementProblem({ ...form, on: false, text: '' })).toBeNull()
229
+ expect(announcementProblem({ ...form, linkLabel: 'Book' })).toBe(
230
+ 'Add the link URL, or clear the link text.',
231
+ )
232
+ expect(announcementProblem({ ...form, text: 'x'.repeat(111) })).toBe(
233
+ 'Keep the message to 110 characters.',
234
+ )
235
+ expect(announcementProblem({ ...form, startsAt: '', endsAt: '' })).toBe(
236
+ 'Pick when the bar starts or ends, or turn the schedule off.',
237
+ )
238
+ })
239
+ })
240
+
241
+ describe('overrideChips', () => {
242
+ const page = { id: 'p', collection: 'pages', title: 'T', path: '/t', locale: null }
243
+ it('writes the kit chips', () => {
244
+ expect(overrideChips({ ...page, header: { variant: 'minimal' } }, 'header')).toEqual([
245
+ 'header:minimal',
246
+ ])
247
+ expect(
248
+ overrideChips({ ...page, header: { fields: { cta: { on: false }, menu: 'x' } } }, 'header'),
249
+ ).toEqual(['header.cta: off', 'header.menu'])
250
+ expect(overrideChips({ ...page, footer: null }, 'footer')).toEqual(['footer: none'])
251
+ expect(overrideChips({ ...page, footer: {} }, 'footer')).toEqual(['footer: inherits'])
252
+ expect(overrideChips({ ...page, footer: null }, 'header')).toEqual([])
253
+ })
254
+ })
255
+
256
+ describe('source card helpers', () => {
257
+ it('links the component in the repo', () => {
258
+ expect(repoFileUrl(source, './src/components/site/Header.astro')).toBe(
259
+ 'https://github.com/actuate/website/blob/a41f9c2/src/components/site/Header.astro',
260
+ )
261
+ expect(
262
+ repoFileUrl({ ...source, repo: 'https://gitlab.com/acme/site.git' }, 'Header.astro'),
263
+ ).toBe('https://gitlab.com/acme/site/blob/a41f9c2/Header.astro')
264
+ expect(repoFileUrl({ ...source, repo: 'javascript:alert(1)' }, 'x')).toBeNull()
265
+ expect(repoFileUrl({ ...source, repo: '' }, 'x')).toBeNull()
266
+ })
267
+
268
+ it('says how long ago', () => {
269
+ const now = Date.parse('2026-10-21T12:00:00Z')
270
+ expect(timeAgo('2026-10-21T11:59:30Z', now)).toBe('just now')
271
+ expect(timeAgo('2026-10-21T11:59:00Z', now)).toBe('1 minute ago')
272
+ expect(timeAgo('2026-10-21T10:00:00Z', now)).toBe('2 hours ago')
273
+ expect(timeAgo('2026-10-18T12:00:00Z', now)).toBe('3 days ago')
274
+ expect(timeAgo(null, now)).toBe('never')
275
+ })
276
+
277
+ it('names keys', () => {
278
+ expect(humanizeKey('topMenu')).toBe('Top menu')
279
+ expect(humanizeKey('legal_menu')).toBe('Legal menu')
280
+ })
281
+ })
282
+
283
+ describe('previewChrome', () => {
284
+ it('sends the draft in the /public/chrome shape', () => {
285
+ const form = regionForm(header, null)
286
+ form.fields.cta = { on: false, label: 'Go', url: '/go' }
287
+ form.fields.copy = '© {year} Acme'
288
+ form.fields.cols = ['company']
289
+ const chrome = previewChrome({ header: { schema: header, form } }, null, {
290
+ menus: [
291
+ {
292
+ slug: 'company',
293
+ name: 'Company',
294
+ items: [{ id: '1', label: 'About', url: '/about', button: false, children: [] }],
295
+ },
296
+ ],
297
+ siteTitle: 'Acme',
298
+ now: new Date('2026-10-21T12:00:00Z'),
299
+ })
300
+ expect(chrome.announcement).toBeNull()
301
+ expect(chrome.header?.variant).toBe('default')
302
+ expect(chrome.header?.fields).toMatchObject({
303
+ logo: null,
304
+ cta: null,
305
+ copy: '© 2026 Acme',
306
+ cols: [{ id: 'company', name: 'Company', items: [{ label: 'About', url: '/about' }] }],
307
+ menu: null,
308
+ sticky: true,
309
+ })
310
+ })
311
+ })
312
+
313
+ describe('highlight', () => {
314
+ it('gives back each sample exactly, with comments, strings and keywords marked', () => {
315
+ for (const sample of [...CODE_SAMPLES.map((s) => s.src), LOCAL_DEV_SAMPLE]) {
316
+ expect(
317
+ highlight(sample)
318
+ .map((token) => token.text)
319
+ .join(''),
320
+ ).toBe(sample)
321
+ }
322
+ const tokens = highlight("import { a } from 'x'; // note")
323
+ expect(tokens).toEqual([
324
+ { kind: 'keyword', text: 'import' },
325
+ { kind: 'plain', text: ' { a } ' },
326
+ { kind: 'keyword', text: 'from' },
327
+ { kind: 'plain', text: ' ' },
328
+ { kind: 'string', text: "'x'" },
329
+ { kind: 'plain', text: '; ' },
330
+ { kind: 'comment', text: '// note' },
331
+ ])
332
+ })
333
+ })