@vespercms/admin 0.0.0-stage → 0.1.0-next.1

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 (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-4fVeej3c.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. package/src/widgets/union.tsx +233 -0
package/src/studio.css ADDED
@@ -0,0 +1,168 @@
1
+ /*
2
+ * The studio's stylesheet: Tailwind plus Vesper's token budget. It is compiled
3
+ * when @vespercms/admin is published (dist/studio.css), so sites need no
4
+ * Tailwind of their own. In this workspace the source is used directly.
5
+ */
6
+ @import 'tailwindcss' source(none);
7
+ @import '@vespercms/ui/tokens.css';
8
+
9
+ @source '../../ui/src';
10
+ @source '.';
11
+
12
+ /*
13
+ * Figtree, self-hosted (DECISIONS.md, "The studio self-hosts Figtree"): two
14
+ * weights, latin only. The published build inlines both files into
15
+ * dist/studio.css, so /admin never asks Google or a CDN for anything.
16
+ * Characters outside the range fall back to the system stack.
17
+ */
18
+ @font-face {
19
+ font-family: Figtree;
20
+ font-style: normal;
21
+ font-weight: 400;
22
+ font-display: swap;
23
+ src: url('@fontsource/figtree/files/figtree-latin-400-normal.woff2') format('woff2');
24
+ unicode-range:
25
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
26
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
27
+ }
28
+
29
+ @font-face {
30
+ font-family: Figtree;
31
+ font-style: normal;
32
+ font-weight: 600;
33
+ font-display: swap;
34
+ src: url('@fontsource/figtree/files/figtree-latin-600-normal.woff2') format('woff2');
35
+ unicode-range:
36
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
37
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
38
+ }
39
+
40
+ @layer base {
41
+ /* The studio fills the page and never inherits the site's own styles. */
42
+ #vesper-studio {
43
+ min-height: 100vh;
44
+ }
45
+
46
+ h1,
47
+ h2,
48
+ h3 {
49
+ font-weight: var(--font-weight-semibold);
50
+ }
51
+ }
52
+
53
+ @layer components {
54
+ /* The geo field's map pin: a real button, so it has a visible focus ring. */
55
+ .vesper-map-pin {
56
+ width: 1.5rem;
57
+ height: 1.5rem;
58
+ border-radius: 9999px 9999px 9999px 0;
59
+ transform: rotate(-45deg);
60
+ background-color: var(--color-accent);
61
+ border: 2px solid var(--color-on-accent);
62
+ box-shadow: var(--shadow-overlay);
63
+ cursor: grab;
64
+ }
65
+
66
+ .vesper-map-pin:focus-visible {
67
+ outline: 3px solid var(--color-focus);
68
+ outline-offset: 3px;
69
+ }
70
+
71
+ /* An empty date field shows no date until it's used: Safari's is today's (widgets/blank-date.ts). */
72
+ input[data-blank]::-webkit-datetime-edit {
73
+ opacity: 0;
74
+ }
75
+ }
76
+
77
+ @layer components {
78
+ /* The body's writing view: hierarchy from size and weight, not boxes (spec §10.2). */
79
+ .vesper-body > * + * {
80
+ margin-top: 0.75em;
81
+ }
82
+
83
+ .vesper-body h1 {
84
+ font-size: 1.5rem;
85
+ font-weight: var(--font-weight-semibold);
86
+ }
87
+
88
+ .vesper-body h2 {
89
+ font-size: 1.25rem;
90
+ font-weight: var(--font-weight-semibold);
91
+ }
92
+
93
+ /* Each level a step smaller, down to the text's own size; the last is muted to tell it apart. */
94
+ .vesper-body h3,
95
+ .vesper-body h4,
96
+ .vesper-body h5,
97
+ .vesper-body h6 {
98
+ font-size: 1.0625rem;
99
+ font-weight: var(--font-weight-semibold);
100
+ }
101
+
102
+ .vesper-body h4 {
103
+ font-size: 1rem;
104
+ }
105
+
106
+ .vesper-body h5,
107
+ .vesper-body h6 {
108
+ font-size: 0.9375rem;
109
+ }
110
+
111
+ .vesper-body h6 {
112
+ color: var(--color-content-muted);
113
+ }
114
+
115
+ .vesper-body ul {
116
+ list-style: disc;
117
+ padding-left: 1.5em;
118
+ }
119
+
120
+ .vesper-body ol {
121
+ list-style: decimal;
122
+ padding-left: 1.5em;
123
+ }
124
+
125
+ .vesper-body blockquote {
126
+ border-left: 1px solid var(--color-border-strong);
127
+ padding-left: 1em;
128
+ color: var(--color-content-muted);
129
+ }
130
+
131
+ .vesper-body code {
132
+ font-family: var(--font-mono);
133
+ font-size: 0.9em;
134
+ }
135
+
136
+ .vesper-body pre {
137
+ font-family: var(--font-mono);
138
+ font-size: 0.875rem;
139
+ background-color: var(--color-surface);
140
+ border: 1px solid var(--color-border);
141
+ border-radius: 0.375rem;
142
+ padding: 0.75em;
143
+ overflow-x: auto;
144
+ }
145
+
146
+ .vesper-body a {
147
+ color: var(--color-accent);
148
+ text-decoration: underline;
149
+ text-underline-offset: 2px;
150
+ }
151
+
152
+ .vesper-body img {
153
+ display: inline-block;
154
+ max-height: 12rem;
155
+ border-radius: 0.25rem;
156
+ }
157
+
158
+ .vesper-body hr {
159
+ border: 0;
160
+ border-top: 1px solid var(--color-border);
161
+ }
162
+
163
+ /* Only while the text has focus, as for the blocks' own outlines (views.tsx). */
164
+ .vesper-body.ProseMirror-focused .ProseMirror-selectednode {
165
+ outline: 2px solid var(--color-focus);
166
+ outline-offset: 2px;
167
+ }
168
+ }
package/src/types.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { Manifest, UrlEntry, WidgetProps } from '@vespercms/core';
2
+ import type { CloudConnection } from './cloud/connection.ts';
3
+ import type { CloudSettings } from './cloud/session.ts';
4
+ import type { GitHubStudioSettings } from './github/session.ts';
5
+ import type { ComponentType } from 'react';
6
+ import type { StudioApi } from './app/api.ts';
7
+
8
+ /** What a widget receives (spec §6.3.3); defined in core so sites can type widgets without the studio. */
9
+ export type { WidgetProps };
10
+
11
+ // Widgets are registered by name and receive values of many shapes, so the
12
+ // registry's component type can't be narrower than this.
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ export type WidgetComponent = ComponentType<WidgetProps<any>>;
15
+
16
+ /** A custom widget from vesper.config.ts, loaded only when a field uses it. */
17
+ export type WidgetLoader = () => Promise<{ default: WidgetComponent }>;
18
+
19
+ export type StudioOptions = {
20
+ manifest: Manifest;
21
+ /**
22
+ * The storage back end, or `null` when this build has none yet: a deployed
23
+ * site before GitHub mode (Phase 3). The studio then explains where to edit.
24
+ */
25
+ api: StudioApi | null;
26
+ /** Custom widgets by name (spec §6.3.3). */
27
+ widgets?: Record<string, WidgetLoader>;
28
+ /**
29
+ * Loads declared MDX components' Zod props schemas from vesper.config.ts,
30
+ * so props are checked exactly as the site checks them (spec §6.5.2). On
31
+ * first use: Zod stays out of the studio's first load.
32
+ */
33
+ loadMdxProps?: () => Promise<Record<string, unknown>>;
34
+ /** Shown in the top bar and the page title. */
35
+ siteName?: string;
36
+ /** The site's home page, for "View site". */
37
+ siteUrl?: string;
38
+ /**
39
+ * GitHub mode in a built site (spec §6.8): with no `api`, the studio
40
+ * signs the editor in with GitHub and saves to this repository.
41
+ */
42
+ github?: GitHubStudioSettings | null;
43
+ /** The site's static routes at build, for GitHub mode's collision checks. */
44
+ routes?: readonly (readonly [string, string])[];
45
+ /** Function-form URL patterns from vesper.config.ts, for GitHub mode's moves and links. */
46
+ loadUrlFunctions?: () => Promise<Readonly<Record<string, (entry: UrlEntry) => unknown>>>;
47
+ /** Who is signed in, set by GitHub mode and the cloud. */
48
+ account?: Account;
49
+ /**
50
+ * The cloud in a built site (spec §6.9): with no `api`, the studio signs
51
+ * the editor in to Vesper, and saves become drafts.
52
+ */
53
+ cloud?: CloudSettings | null;
54
+ /** The studio connected to the cloud, set by the cloud's gate once signed in. */
55
+ connection?: CloudConnection;
56
+ };
57
+
58
+ /** The signed-in editor, in GitHub mode or the cloud: the user menu shows it. */
59
+ export type Account = {
60
+ /** The user menu's button: `@login` on GitHub, the person's name on Vesper. */
61
+ label: string;
62
+ /** The person's name, at the top of the open menu and for their initials. */
63
+ name: string;
64
+ /** Their picture's address, beside the name; without one, their initials show. */
65
+ picture?: string;
66
+ /** The menu's name: "Signed in to GitHub as @login". */
67
+ description: string;
68
+ /** The menu's way out: "Sign out of GitHub", "Sign out of Vesper". */
69
+ signOutLabel: string;
70
+ /** The cloud's dashboard, for "Open dashboard". */
71
+ dashboardUrl?: string;
72
+ /**
73
+ * GitHub mode: the repository is public, so a change scheduled there can
74
+ * be read before it goes live (spec §6.17). The Schedule dialog says so.
75
+ */
76
+ publicRepository?: boolean;
77
+ signOut: () => void;
78
+ /** Signs in again after the session expired, coming back to the same screen. */
79
+ signInAgain: () => void;
80
+ /** True once GitHub has said the session expired. */
81
+ expired: boolean;
82
+ };
@@ -0,0 +1,315 @@
1
+ import {
2
+ formatYamlSnippet,
3
+ parseYamlSnippet,
4
+ type DataValue,
5
+ type FieldModel,
6
+ type FieldOption,
7
+ } from '@vespercms/core';
8
+ import { MultiCombobox, RadioGroup, Select, Switch, cx } from '@vespercms/ui';
9
+ import { useState } from 'react';
10
+ import type { WidgetProps } from '../types.ts';
11
+ import { useBlankWhenEmpty } from './blank-date.ts';
12
+
13
+ /*
14
+ * The simple built-in widgets (spec §6.3.2). Each follows the custom widget
15
+ * contract: the control takes `id`, `aria-describedby` and `aria-invalid`,
16
+ * and the editor renders the label. None of them validate.
17
+ */
18
+
19
+ /** Native inputs share the ui kit's control look. */
20
+ export const inputClass = cx(
21
+ 'min-h-10 w-full rounded-md border border-border-strong bg-surface-sunken px-3 py-2 text-body text-content',
22
+ 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus',
23
+ 'focus-visible:ring-6 focus-visible:ring-(color:--glow-focus)',
24
+ 'aria-[invalid=true]:border-danger disabled:opacity-60',
25
+ );
26
+
27
+ /** Clearing an optional field removes its key; a required one keeps `''` so the schema can say why. */
28
+ function emptyAs(field: FieldModel): '' | undefined {
29
+ return field.required ? '' : undefined;
30
+ }
31
+
32
+ function textProps(props: WidgetProps<string>) {
33
+ const { onChange, field, id, describedBy, invalid, disabled } = props;
34
+ // A file can hold a number where the schema expects text; show it rather than hide it.
35
+ const value: unknown = props.value;
36
+ return {
37
+ id,
38
+ value:
39
+ typeof value === 'string'
40
+ ? value
41
+ : typeof value === 'number' || typeof value === 'boolean'
42
+ ? String(value)
43
+ : '',
44
+ onChange: (event: { target: { value: string } }) => {
45
+ onChange(event.target.value === '' ? emptyAs(field) : event.target.value);
46
+ },
47
+ 'aria-describedby': describedBy,
48
+ 'aria-invalid': invalid || undefined,
49
+ 'aria-required': field.required || undefined,
50
+ disabled,
51
+ placeholder: field.placeholder,
52
+ autoComplete: field.autocomplete,
53
+ className: inputClass,
54
+ };
55
+ }
56
+
57
+ export function TextWidget(props: WidgetProps<string>) {
58
+ return <input type="text" {...textProps(props)} />;
59
+ }
60
+
61
+ export function EmailWidget(props: WidgetProps<string>) {
62
+ const input = textProps(props);
63
+ return (
64
+ <input type="email" inputMode="email" {...input} autoComplete={input.autoComplete ?? 'email'} />
65
+ );
66
+ }
67
+
68
+ export function TextAreaWidget(props: WidgetProps<string>) {
69
+ const rows = props.field.widget === 'markdown' ? 8 : 3;
70
+ return <textarea rows={rows} {...textProps(props)} className={cx(inputClass, 'resize-y')} />;
71
+ }
72
+
73
+ export function NumberWidget({
74
+ value,
75
+ onChange,
76
+ field,
77
+ id,
78
+ describedBy,
79
+ invalid,
80
+ disabled,
81
+ }: WidgetProps<number>) {
82
+ // The text is kept separately so "1." or "-" can be typed on the way to a number.
83
+ const [text, setText] = useState(value === undefined ? '' : String(value));
84
+ const c = field.constraints;
85
+ return (
86
+ <input
87
+ id={id}
88
+ type="number"
89
+ inputMode={c?.integer ? 'numeric' : 'decimal'}
90
+ value={text}
91
+ min={c?.min}
92
+ max={c?.max}
93
+ step={c?.step ?? (c?.integer ? 1 : 'any')}
94
+ onChange={(event) => {
95
+ setText(event.target.value);
96
+ if (event.target.value === '') onChange(undefined);
97
+ else if (Number.isFinite(event.target.valueAsNumber)) onChange(event.target.valueAsNumber);
98
+ }}
99
+ aria-describedby={describedBy}
100
+ aria-invalid={invalid || undefined}
101
+ aria-required={field.required || undefined}
102
+ disabled={disabled}
103
+ className={cx(inputClass, 'max-w-48')}
104
+ />
105
+ );
106
+ }
107
+
108
+ export function SwitchWidget({
109
+ value,
110
+ onChange,
111
+ id,
112
+ describedBy,
113
+ invalid,
114
+ disabled,
115
+ }: WidgetProps<boolean>) {
116
+ return (
117
+ <Switch
118
+ id={id}
119
+ isSelected={value === true}
120
+ onChange={onChange}
121
+ isDisabled={disabled}
122
+ isInvalid={invalid}
123
+ aria-labelledby={`${id}-label`}
124
+ aria-describedby={describedBy}
125
+ >
126
+ {null}
127
+ </Switch>
128
+ );
129
+ }
130
+
131
+ /** YYYY-MM-DD of a date as stored: date-only values are midnight UTC (YAML timestamps). */
132
+ function dateText(value: DataValue): string {
133
+ if (value instanceof Date && !Number.isNaN(value.getTime()))
134
+ return value.toISOString().slice(0, 10);
135
+ if (typeof value === 'string') return value.slice(0, 10);
136
+ return '';
137
+ }
138
+
139
+ /**
140
+ * A calendar date. The value keeps its type: a Date stays a Date (written
141
+ * back as `2026-10-17`), and a quoted string stays a string.
142
+ */
143
+ export function DateWidget({
144
+ value,
145
+ onChange,
146
+ field,
147
+ id,
148
+ describedBy,
149
+ invalid,
150
+ disabled,
151
+ }: WidgetProps<Date | string>) {
152
+ const text = dateText(value);
153
+ const blank = useBlankWhenEmpty(text);
154
+ return (
155
+ <input
156
+ id={id}
157
+ type="date"
158
+ value={text}
159
+ {...blank}
160
+ onChange={(event) => {
161
+ const text = event.target.value;
162
+ if (text === '')
163
+ onChange(emptyAs(field) === '' && typeof value === 'string' ? '' : undefined);
164
+ else onChange(typeof value === 'string' ? text : new Date(`${text}T00:00:00.000Z`));
165
+ }}
166
+ aria-describedby={describedBy}
167
+ aria-invalid={invalid || undefined}
168
+ aria-required={field.required || undefined}
169
+ disabled={disabled}
170
+ className={cx(inputClass, 'max-w-56')}
171
+ />
172
+ );
173
+ }
174
+
175
+ function optionsOf(field: FieldModel): FieldOption[] {
176
+ return field.constraints?.options ?? field.children?.[0]?.constraints?.options ?? [];
177
+ }
178
+
179
+ export function SelectWidget({
180
+ value,
181
+ onChange,
182
+ field,
183
+ id,
184
+ describedBy,
185
+ invalid,
186
+ disabled,
187
+ }: WidgetProps<string>) {
188
+ return (
189
+ <Select
190
+ id={id}
191
+ aria-labelledby={`${id}-label`}
192
+ aria-describedby={describedBy}
193
+ options={optionsOf(field)}
194
+ value={value ?? null}
195
+ onChange={(next) => {
196
+ onChange(next ?? undefined);
197
+ }}
198
+ isInvalid={invalid}
199
+ isDisabled={disabled}
200
+ />
201
+ );
202
+ }
203
+
204
+ /** Up to four options, all visible. Rendered inside the field's fieldset, which names it. */
205
+ export function RadioWidget({
206
+ value,
207
+ onChange,
208
+ field,
209
+ id,
210
+ describedBy,
211
+ invalid,
212
+ disabled,
213
+ }: WidgetProps<string>) {
214
+ return (
215
+ <RadioGroup
216
+ id={id}
217
+ aria-labelledby={`${id}-label`}
218
+ aria-describedby={describedBy}
219
+ options={optionsOf(field)}
220
+ value={value ?? null}
221
+ onChange={onChange}
222
+ isInvalid={invalid}
223
+ isDisabled={disabled}
224
+ orientation={optionsOf(field).length <= 3 ? 'horizontal' : 'vertical'}
225
+ />
226
+ );
227
+ }
228
+
229
+ /** A list of strings: type and press Enter to add, each with a named remove button. */
230
+ export function TagsWidget({
231
+ value,
232
+ onChange,
233
+ field,
234
+ id,
235
+ describedBy,
236
+ invalid,
237
+ disabled,
238
+ }: WidgetProps<string[]>) {
239
+ const values = Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
240
+ const options = optionsOf(field);
241
+ const multiple = field.children?.[0]?.kind === 'enum';
242
+ return (
243
+ <MultiCombobox
244
+ id={id}
245
+ aria-labelledby={`${id}-label`}
246
+ aria-describedby={describedBy}
247
+ options={multiple ? options : values.map((v) => ({ value: v, label: v }))}
248
+ values={values}
249
+ onChange={(next) => {
250
+ onChange(next.length === 0 && !field.required ? undefined : next);
251
+ }}
252
+ allowsCustomValue={!multiple}
253
+ itemName={multiple ? 'option' : 'tag'}
254
+ isInvalid={invalid}
255
+ isDisabled={disabled}
256
+ />
257
+ );
258
+ }
259
+
260
+ /**
261
+ * The raw field (spec §6.3 step 4): any value Vesper can't map to a control,
262
+ * edited as a small YAML snippet and parsed the way Astro parses it. The
263
+ * text is kept while it's invalid, so nothing typed is lost.
264
+ */
265
+ export function RawWidget({
266
+ value,
267
+ onChange,
268
+ field,
269
+ id,
270
+ describedBy,
271
+ invalid,
272
+ disabled,
273
+ }: WidgetProps<DataValue>) {
274
+ const [text, setText] = useState(() => formatYamlSnippet(value));
275
+ const [problem, setProblem] = useState<string | null>(null);
276
+ const problemId = `${id}-yaml`;
277
+ return (
278
+ <>
279
+ <textarea
280
+ id={id}
281
+ rows={Math.min(12, Math.max(3, text.split('\n').length + 1))}
282
+ value={text}
283
+ spellCheck={false}
284
+ onChange={(event) => {
285
+ setText(event.target.value);
286
+ const parsed = parseYamlSnippet(event.target.value);
287
+ if (parsed.ok) {
288
+ setProblem(null);
289
+ onChange(parsed.value);
290
+ } else {
291
+ setProblem(parsed.message);
292
+ }
293
+ }}
294
+ aria-describedby={[describedBy, `${id}-raw`, problem ? problemId : undefined]
295
+ .filter(Boolean)
296
+ .join(' ')}
297
+ aria-invalid={invalid || problem !== null || undefined}
298
+ aria-required={field.required || undefined}
299
+ disabled={disabled}
300
+ className={cx(inputClass, 'resize-y font-mono text-small')}
301
+ />
302
+ <p id={`${id}-raw`} className="text-small text-content-muted">
303
+ Edited as YAML
304
+ {field.unknownReason
305
+ ? `: Vesper has no control for this ${field.unknownReason} field.`
306
+ : '.'}
307
+ </p>
308
+ {problem && (
309
+ <p id={problemId} className="text-small text-danger">
310
+ {problem}
311
+ </p>
312
+ )}
313
+ </>
314
+ );
315
+ }
@@ -0,0 +1,32 @@
1
+ import { useState, type FocusEvent, type KeyboardEvent } from 'react';
2
+
3
+ /*
4
+ * Safari draws today's date and time, greyed, in an empty date field, and
5
+ * editors took it for a time already chosen: "Comes down" looked set when it
6
+ * wasn't (WebKit bug 233215). An empty field is drawn blank until it's
7
+ * clicked or typed in, and blank again when it's left with nothing in it (studio.css
8
+ * hides `::-webkit-datetime-edit` under `data-blank`). Chrome's
9
+ * "mm/dd/yyyy" goes with it; the label says what goes there, and Firefox,
10
+ * which draws neither, is unchanged.
11
+ */
12
+
13
+ /** Keys that pass through or leave a field without starting to fill it in. */
14
+ const PASSING = new Set(['Tab', 'Shift', 'Escape', 'Enter', 'Meta', 'Control', 'Alt']);
15
+
16
+ /** Props for a `date` or `datetime-local` input whose value is `value`. */
17
+ export function useBlankWhenEmpty(value: string) {
18
+ const [engaged, setEngaged] = useState(false);
19
+ return {
20
+ 'data-blank': value === '' && !engaged ? '' : undefined,
21
+ onPointerDown: () => {
22
+ setEngaged(true);
23
+ },
24
+ onKeyDown: (event: KeyboardEvent) => {
25
+ if (!PASSING.has(event.key)) setEngaged(true);
26
+ },
27
+ // A date typed partway has no value yet, but its parts must stay in sight.
28
+ onBlur: (event: FocusEvent<HTMLInputElement>) => {
29
+ if (!event.currentTarget.validity.badInput) setEngaged(false);
30
+ },
31
+ };
32
+ }