@the-smithy/create-hearth-site 0.19.0 → 0.21.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 (84) hide show
  1. package/bin/create-hearth-site.js +5 -0
  2. package/package.json +2 -2
  3. package/template/AGENTS.md +11 -2
  4. package/template/CLAUDE.md +87 -20
  5. package/template/README.md +55 -24
  6. package/template/SITE.md +17 -0
  7. package/template/package.json +3 -1
  8. package/template/scripts/hearth.mjs +194 -14
  9. package/template/src/app/[[...slug]]/page.tsx +2 -54
  10. package/template/src/app/api/preview/exit/route.ts +1 -1
  11. package/template/src/app/api/preview/route.ts +1 -1
  12. package/template/src/app/api/revalidate/route.ts +1 -1
  13. package/template/src/app/apple-icon.tsx +1 -1
  14. package/template/src/app/donate/page.tsx +2 -35
  15. package/template/src/app/donate/thank-you/page.tsx +2 -22
  16. package/template/src/app/events/[slug]/page.tsx +2 -26
  17. package/template/src/app/events/page.tsx +2 -29
  18. package/template/src/app/fundraisers/[slug]/page.tsx +2 -55
  19. package/template/src/app/fundraisers/page.tsx +2 -37
  20. package/template/src/app/globals.css +3 -74
  21. package/template/src/app/icon.tsx +1 -1
  22. package/template/src/app/layout.tsx +2 -36
  23. package/template/src/app/not-found.tsx +2 -14
  24. package/template/src/app/registered/page.tsx +2 -22
  25. package/template/src/app/robots.ts +1 -1
  26. package/template/src/app/sitemap.ts +2 -2
  27. package/template/src/{app → hearth}/actions.ts +15 -1
  28. package/template/src/hearth/blocks/OpenMap.tsx +104 -0
  29. package/template/src/hearth/blocks/collections.tsx +74 -0
  30. package/template/src/hearth/blocks/content.tsx +252 -0
  31. package/template/src/hearth/blocks/custom.tsx +12 -0
  32. package/template/src/hearth/blocks/events.tsx +130 -0
  33. package/template/src/hearth/blocks/giving.tsx +123 -0
  34. package/template/src/{components → hearth}/blocks/index.tsx +17 -11
  35. package/template/src/hearth/blocks/info.tsx +84 -0
  36. package/template/src/{components → hearth}/blocks/media.tsx +30 -27
  37. package/template/src/hearth/components/EventView.tsx +120 -0
  38. package/template/src/{components → hearth/components}/Registration.tsx +14 -14
  39. package/template/src/hearth/components/Share.tsx +102 -0
  40. package/template/src/hearth/format.ts +31 -0
  41. package/template/src/hearth/forms/ContactForm.tsx +27 -0
  42. package/template/src/hearth/forms/DonateForm.tsx +69 -0
  43. package/template/src/{components/blocks → hearth/forms}/EmberFormBlock.tsx +6 -5
  44. package/template/src/hearth/forms/RegistrationForm.tsx +68 -0
  45. package/template/src/hearth/forms/VolunteerForm.tsx +58 -0
  46. package/template/src/hearth/forms/fields.tsx +137 -0
  47. package/template/src/hearth/hearth.css +52 -0
  48. package/template/src/hearth/layout/MobileNav.tsx +49 -0
  49. package/template/src/hearth/layout/NavLink.tsx +16 -0
  50. package/template/src/hearth/layout/RootLayout.tsx +40 -0
  51. package/template/src/{components → hearth/layout}/SiteFooter.tsx +19 -14
  52. package/template/src/hearth/layout/SiteHeader.tsx +53 -0
  53. package/template/src/hearth/manifest.json +36 -0
  54. package/template/src/hearth/pages/donate.tsx +36 -0
  55. package/template/src/hearth/pages/event.tsx +26 -0
  56. package/template/src/hearth/pages/events.tsx +30 -0
  57. package/template/src/hearth/pages/fundraiser.tsx +77 -0
  58. package/template/src/hearth/pages/fundraisers.tsx +38 -0
  59. package/template/src/hearth/pages/not-found.tsx +15 -0
  60. package/template/src/hearth/pages/page.tsx +54 -0
  61. package/template/src/hearth/pages/registered.tsx +19 -0
  62. package/template/src/hearth/pages/thank-you.tsx +19 -0
  63. package/template/src/hearth/theme/defaults.ts +510 -0
  64. package/template/src/hearth/theme/index.ts +31 -0
  65. package/template/src/hearth/ui.tsx +144 -0
  66. package/template/src/theme/custom-blocks.tsx +11 -0
  67. package/template/src/theme/fonts.ts +9 -0
  68. package/template/src/theme/index.ts +13 -0
  69. package/template/src/theme/overrides.tsx +16 -0
  70. package/template/src/theme/theme.css +29 -0
  71. package/template/src/components/DonateForm.tsx +0 -104
  72. package/template/src/components/EventView.tsx +0 -48
  73. package/template/src/components/RegistrationForm.tsx +0 -103
  74. package/template/src/components/SiteHeader.tsx +0 -37
  75. package/template/src/components/blocks/Form.tsx +0 -92
  76. package/template/src/components/blocks/collections.tsx +0 -75
  77. package/template/src/components/blocks/content.tsx +0 -257
  78. package/template/src/components/blocks/custom.tsx +0 -20
  79. package/template/src/components/blocks/events.tsx +0 -104
  80. package/template/src/components/blocks/giving.tsx +0 -120
  81. package/template/src/components/blocks/info.tsx +0 -52
  82. package/template/src/components/ui.tsx +0 -128
  83. /package/template/src/{lib/hearth.ts → hearth/client.ts} +0 -0
  84. /package/template/src/{lib → hearth}/site.ts +0 -0
@@ -0,0 +1,510 @@
1
+ /*
2
+ * Every slot Hearth's code draws, and the Tailwind classes it starts with. Don't edit this file in a site: set the
3
+ * slots you want to change in src/theme/index.ts, which replaces them (or extends them with a function).
4
+ *
5
+ * Each slot styles one element on its own, so a slot's classes never fight another's. Variants come from the
6
+ * element's state, set as attributes by the code, so they're plain Tailwind variants in the slot:
7
+ * on-dark: on an inverse or accent ground (sections with a dark tone, the hero, the footer)
8
+ * data-[size=full]: data-[size=small]: data-[width=narrow]: data-[columns=4]: data-[fit=contain]:
9
+ * data-highlight: data-cancelled: data-full: data-flip: aria-invalid: aria-[current=page]: has-checked:
10
+ * in-data-[…]: the same, set on an ancestor (Tailwind's in-* variant)
11
+ * Slots named `tone` aren't classes: they pick the ground (default, muted, inverse or accent) a block sits on.
12
+ * Slots under `text` are the words the code shows (labels, buttons), for sites that word them differently.
13
+ */
14
+
15
+ const card = "rounded-2xl bg-surface ring-1 ring-line on-dark:bg-white/10 on-dark:ring-0";
16
+ const kicker = "text-sm font-semibold uppercase tracking-[0.18em] opacity-75";
17
+ const sectionTitle = "text-[clamp(28px,3.4vw,40px)] font-bold leading-tight tracking-tight";
18
+ const pageTitle = "text-[clamp(32px,4vw,48px)] font-bold tracking-tight";
19
+ const primary =
20
+ "inline-block w-fit rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90 on-dark:bg-inverse-ink on-dark:text-inverse data-[size=small]:px-4 data-[size=small]:py-2 data-[size=small]:text-sm";
21
+ const secondary =
22
+ "inline-block w-fit rounded-lg border-[1.5px] border-current/40 px-6 py-3.5 font-medium hover:border-current data-[size=small]:px-4 data-[size=small]:py-2 data-[size=small]:text-sm";
23
+ const more = "font-semibold underline underline-offset-4 after:content-['_→']";
24
+ const inlineLink = "font-semibold text-highlight underline underline-offset-4 on-dark:text-current";
25
+ const prose = "prose text-lg";
26
+ const divided = "divide-y divide-line border-y border-line";
27
+ const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none aria-invalid:border-danger";
28
+ const choice = "size-5 accent-accent";
29
+
30
+ export const defaults = {
31
+ /* ─── The page around the content ─── */
32
+ page: {
33
+ body: "flex min-h-dvh flex-col bg-bg font-sans text-ink antialiased",
34
+ skipLink: "sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-surface focus:px-4 focus:py-2",
35
+ main: "flex-1",
36
+ /** Headings of the pages the code serves: /events, /fundraisers, /donate, thank-you pages, 404. */
37
+ title: `mb-10 ${pageTitle}`,
38
+ lead: "-mt-6 mb-10 max-w-2xl text-lg opacity-85",
39
+ back: more,
40
+ notFound: "py-12",
41
+ },
42
+ header: {
43
+ /** The header's ground: `tones` colors it, and inverse or accent turn on on-dark: inside it. */
44
+ tone: "default",
45
+ root: "relative z-30 border-b border-line",
46
+ announcement: "bg-accent px-4 py-2 text-center text-sm font-semibold text-accent-ink",
47
+ inner: "mx-auto flex max-w-site items-center justify-between gap-x-8 gap-y-3 px-4 py-4 sm:px-6",
48
+ brand: "flex min-w-0 items-center gap-3 text-xl font-bold",
49
+ logo: "h-10 w-auto",
50
+ names: "flex flex-col",
51
+ tagline: "text-sm font-normal text-muted-ink on-dark:text-current on-dark:opacity-75",
52
+ nav: "hidden flex-wrap items-center justify-end gap-x-6 gap-y-1 font-medium md:flex",
53
+ link: "py-2 hover:text-highlight aria-[current=page]:text-highlight",
54
+ cta: "rounded-lg bg-accent px-4 py-2 font-semibold text-accent-ink hover:opacity-90",
55
+ },
56
+ /** The menu on phones: a button that opens the links full screen. */
57
+ mobileNav: {
58
+ root: "md:hidden",
59
+ toggle: "inline-flex min-h-11 items-center rounded-lg px-4 font-semibold ring-1 ring-line",
60
+ dialog: "m-0 h-dvh max-h-none w-full max-w-none bg-bg p-0 text-ink backdrop:bg-black/40",
61
+ top: "flex items-center justify-between gap-4 border-b border-line px-4 py-4",
62
+ title: "text-xl font-bold",
63
+ close: "inline-flex min-h-11 items-center rounded-lg px-4 font-semibold ring-1 ring-line",
64
+ nav: "flex flex-col px-4 py-4",
65
+ link: "border-b border-line py-4 text-2xl font-semibold aria-[current=page]:text-highlight",
66
+ cta: "mt-8 inline-block rounded-lg bg-accent px-6 py-3.5 text-center font-semibold text-accent-ink",
67
+ },
68
+ footer: {
69
+ /** The footer's ground (it sets on-dark: for everything in it). */
70
+ tone: "inverse",
71
+ root: "",
72
+ inner: "mx-auto grid max-w-site gap-10 px-4 py-12 sm:grid-cols-3 sm:px-6",
73
+ about: "flex flex-col gap-2",
74
+ name: "text-lg font-bold",
75
+ tagline: "opacity-80",
76
+ address: "whitespace-pre-line not-italic opacity-80",
77
+ contact: "opacity-80 hover:opacity-100",
78
+ nav: "flex flex-col gap-2",
79
+ link: "opacity-80 hover:opacity-100",
80
+ social: "flex flex-col gap-2",
81
+ socialLink: "opacity-80 hover:opacity-100",
82
+ copyright: "mx-auto max-w-site px-4 pb-8 text-sm opacity-60 sm:px-6",
83
+ },
84
+
85
+ /* ─── Shared pieces ─── */
86
+ /** A full-width band: `tones` colors it, `inner` holds the content width and spacing. */
87
+ section: {
88
+ root: "",
89
+ inner: "mx-auto max-w-site px-4 py-16 sm:px-6 sm:py-20",
90
+ },
91
+ tones: {
92
+ default: "bg-bg text-ink",
93
+ muted: "bg-muted text-ink",
94
+ inverse: "bg-inverse text-inverse-ink",
95
+ accent: "bg-accent text-accent-ink",
96
+ },
97
+ /** Eyebrow, heading and intro over most blocks. */
98
+ heading: {
99
+ root: "mb-10 max-w-3xl",
100
+ eyebrow: `mb-3 ${kicker}`,
101
+ title: sectionTitle,
102
+ intro: "mt-4 text-lg leading-relaxed opacity-85",
103
+ },
104
+ /** Words marked *like this* in headings. */
105
+ emphasis: {
106
+ mark: "text-highlight on-dark:text-current on-dark:underline on-dark:decoration-2 on-dark:underline-offset-4",
107
+ },
108
+ button: { primary, secondary },
109
+ /** Rich text from Hearth anywhere it isn't given its own slot; `.prose` in hearth.css styles what's inside. */
110
+ markdown: { root: prose },
111
+
112
+ /* ─── Blocks ─── */
113
+ hero: {
114
+ root: "relative overflow-hidden bg-inverse text-inverse-ink",
115
+ image: "absolute inset-0 size-full object-cover opacity-35",
116
+ inner: "relative mx-auto max-w-site px-4 py-16 sm:px-6 sm:py-20 data-[size=full]:py-28 sm:data-[size=full]:py-36",
117
+ logo: "mb-6 h-20 w-auto sm:h-24",
118
+ eyebrow: "mb-4 text-sm font-semibold uppercase tracking-[0.18em] opacity-80",
119
+ title: "max-w-4xl text-[clamp(36px,5vw,64px)] font-bold leading-[1.05] tracking-tight",
120
+ emphasis: "underline decoration-2 underline-offset-[6px]",
121
+ body: "mt-5 max-w-2xl text-lg leading-relaxed opacity-85 sm:text-xl",
122
+ actions: "mt-8 flex flex-wrap gap-3",
123
+ },
124
+ richText: { body: `${prose} data-[width=narrow]:max-w-3xl` },
125
+ mediaText: {
126
+ grid: "grid items-center gap-10 md:grid-cols-2",
127
+ text: "md:in-data-[image=left]:order-2",
128
+ body: prose,
129
+ actions: "mt-6",
130
+ image: "aspect-[4/3] w-full rounded-xl object-cover data-[fit=contain]:aspect-auto data-[fit=contain]:max-h-96 data-[fit=contain]:object-contain",
131
+ },
132
+ stats: {
133
+ tone: "inverse",
134
+ list: "grid gap-8 sm:grid-cols-2 lg:grid-cols-4",
135
+ item: "flex flex-col-reverse",
136
+ label: "mt-2 opacity-80",
137
+ value: "text-5xl font-bold",
138
+ },
139
+ cardGrid: {
140
+ list: "grid gap-5 sm:grid-cols-2 lg:grid-cols-3",
141
+ card: `flex flex-col gap-3 p-7 ${card}`,
142
+ image: "mb-2 aspect-[16/10] w-full rounded-lg object-cover",
143
+ title: "text-xl font-semibold",
144
+ body: "leading-relaxed opacity-80",
145
+ link: `mt-auto pt-2 ${more}`,
146
+ },
147
+ projects: {
148
+ showcaseList: "grid gap-14",
149
+ gridList: "grid gap-5 sm:grid-cols-2 lg:grid-cols-3",
150
+ showcaseItem: "grid items-center gap-8 md:grid-cols-2",
151
+ gridItem: `flex flex-col gap-3 p-7 ${card}`,
152
+ /** Showcase items alternate sides: odd ones carry data-flip. */
153
+ image: "aspect-[16/10] w-full rounded-xl object-cover object-top in-data-[layout=grid]:mb-2 md:in-data-[flip]:order-2",
154
+ text: "flex flex-col gap-3",
155
+ top: "flex flex-wrap items-center gap-3",
156
+ name: "text-xl font-semibold",
157
+ status: "rounded-full bg-accent px-3 py-0.5 text-sm font-semibold text-accent-ink",
158
+ role: "text-sm font-semibold uppercase tracking-[0.12em] opacity-75",
159
+ description: "prose text-lg leading-relaxed opacity-80",
160
+ stack: "flex flex-wrap gap-2",
161
+ stackItem: "rounded-md bg-surface px-2.5 py-1 text-sm ring-1 ring-line on-dark:bg-white/10 on-dark:ring-0",
162
+ link: `mt-auto pt-2 ${more}`,
163
+ },
164
+ pricing: {
165
+ list: "grid gap-5 sm:grid-cols-2 lg:grid-cols-3 data-[count=many]:lg:grid-cols-4",
166
+ plan: `flex flex-col gap-4 p-7 ${card} data-highlight:ring-2 data-highlight:ring-accent`,
167
+ highlight: "self-start rounded-full bg-accent px-3 py-0.5 text-sm font-semibold text-accent-ink",
168
+ name: "text-xl font-semibold",
169
+ price: "",
170
+ amount: "text-4xl font-bold tracking-tight",
171
+ period: "ml-1 opacity-75",
172
+ description: "leading-relaxed opacity-80",
173
+ features: "grid gap-2 opacity-90",
174
+ feature: "flex gap-2.5",
175
+ check: "text-highlight before:content-['✓'] on-dark:text-current",
176
+ actions: "mt-auto pt-2",
177
+ footnote: "prose mt-8 max-w-3xl text-sm opacity-80",
178
+ },
179
+ cta: {
180
+ row: "flex flex-wrap items-center justify-between gap-8",
181
+ text: "max-w-2xl",
182
+ title: "text-[clamp(28px,3.4vw,40px)] font-bold leading-tight",
183
+ body: "mt-3 text-lg opacity-85",
184
+ actions: "flex flex-wrap gap-3",
185
+ /** With an image: the image fills the band under a dark overlay. */
186
+ imageRoot: "relative overflow-hidden text-white",
187
+ image: "absolute inset-0 size-full object-cover",
188
+ overlay: "relative bg-black/55",
189
+ overlayInner: "mx-auto max-w-site px-4 py-20 sm:px-6 sm:py-24",
190
+ },
191
+ faq: {
192
+ list: `max-w-3xl ${divided}`,
193
+ item: "group",
194
+ question: "flex min-h-14 cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg font-semibold [&::-webkit-details-marker]:hidden",
195
+ icon: "text-highlight transition before:content-['+'] group-open:rotate-45",
196
+ answer: "prose pb-5 text-lg opacity-85",
197
+ },
198
+ quote: {
199
+ figure: "flex max-w-3xl flex-col gap-6",
200
+ text: "text-2xl font-semibold leading-snug before:content-['“'] after:content-['”'] sm:text-3xl",
201
+ caption: "flex items-center gap-4",
202
+ image: "size-14 rounded-full object-cover",
203
+ name: "block font-semibold",
204
+ role: "block opacity-75",
205
+ },
206
+ gallery: {
207
+ list: "grid gap-4 sm:grid-cols-2 data-[columns=3]:lg:grid-cols-3 data-[columns=4]:lg:grid-cols-4",
208
+ image: "aspect-[4/3] w-full rounded-xl object-cover",
209
+ caption: "mt-2 text-sm opacity-75",
210
+ },
211
+ video: {
212
+ figure: "max-w-4xl",
213
+ frame: "aspect-video overflow-hidden rounded-xl bg-inverse",
214
+ caption: "mt-3 opacity-75",
215
+ },
216
+ embed: {
217
+ figure: "data-[width=narrow]:max-w-2xl data-[width=wide]:max-w-4xl",
218
+ frame: "rounded-xl",
219
+ caption: "mt-3 opacity-75",
220
+ },
221
+ people: {
222
+ list: "grid gap-8 sm:grid-cols-2 lg:grid-cols-3",
223
+ person: "flex flex-col gap-3",
224
+ image: "aspect-square w-full rounded-2xl object-cover",
225
+ name: "text-xl font-semibold",
226
+ role: "opacity-75",
227
+ bio: "leading-relaxed opacity-85",
228
+ },
229
+ logos: {
230
+ list: "flex flex-wrap items-center gap-x-12 gap-y-6",
231
+ link: "block opacity-85 hover:opacity-100",
232
+ image: "h-10 w-auto",
233
+ name: "text-lg font-semibold",
234
+ },
235
+ location: {
236
+ grid: "grid gap-10 md:grid-cols-2",
237
+ details: "flex flex-col gap-4 text-lg",
238
+ name: "text-xl font-semibold",
239
+ address: "whitespace-pre-line not-italic",
240
+ hours: "whitespace-pre-line",
241
+ notes: "prose text-lg opacity-80",
242
+ directions: inlineLink,
243
+ map: "aspect-[4/3]",
244
+ },
245
+ locations: {
246
+ grid: "grid gap-10 data-map:md:grid-cols-[2fr_3fr]",
247
+ map: "aspect-[4/3] md:sticky md:top-24 md:order-2 md:aspect-auto md:h-[min(36rem,80vh)]",
248
+ list: divided,
249
+ /** The list when no place has a map pin. */
250
+ listNoMap: `${divided} grid gap-x-10 sm:grid-cols-2 sm:divide-y-0 sm:border-y-0`,
251
+ item: "flex flex-col gap-2 py-6",
252
+ name: "text-xl font-semibold",
253
+ address: "whitespace-pre-line not-italic",
254
+ hours: "whitespace-pre-line",
255
+ notes: "whitespace-pre-line opacity-80",
256
+ links: "mt-1 flex flex-wrap gap-x-6 gap-y-2",
257
+ link: inlineLink,
258
+ },
259
+ /** The map in Location and Locations blocks (MapLibre with OpenStreetMap tiles). */
260
+ map: {
261
+ /** A MapLibre style URL: swap it to restyle the map itself. */
262
+ style: "https://tiles.openfreemap.org/styles/liberty",
263
+ frame: "relative overflow-hidden rounded-xl bg-muted [&_.maplibregl-map]:font-sans [&_.maplibregl-popup-close-button]:size-8 [&_.maplibregl-popup-close-button]:text-xl",
264
+ pin: "block size-6 rounded-full border-[3px] border-white bg-highlight shadow-md",
265
+ popup: "grid gap-1 pr-4 font-sans text-sm text-ink",
266
+ popupLink: "font-semibold text-highlight underline underline-offset-4",
267
+ failed: "absolute inset-0 grid place-items-center p-6 text-center opacity-80",
268
+ },
269
+ schedule: {
270
+ list: `max-w-3xl ${divided}`,
271
+ item: "grid gap-1 py-5 sm:grid-cols-[10rem_1fr] sm:gap-6",
272
+ time: "font-semibold text-highlight on-dark:text-current",
273
+ title: "text-lg font-semibold",
274
+ details: "mt-1 opacity-80",
275
+ },
276
+ formBlock: { wrap: "max-w-2xl" },
277
+ collectionList: {
278
+ cards: "grid gap-5 sm:grid-cols-2 lg:grid-cols-3",
279
+ card: "relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line",
280
+ image: "aspect-[16/10] w-full object-cover",
281
+ body: "flex flex-1 flex-col gap-2 p-6",
282
+ date: "text-sm font-semibold text-highlight",
283
+ title: "text-xl font-semibold",
284
+ /** The title link covers the whole card. */
285
+ link: "after:absolute after:inset-0 hover:underline",
286
+ summary: "leading-relaxed opacity-85",
287
+ tags: "mt-auto flex flex-wrap gap-2 pt-2",
288
+ tag: "rounded-full bg-muted px-3 py-1 text-xs font-semibold",
289
+ list: `max-w-3xl ${divided}`,
290
+ listItem: "py-5",
291
+ listLink: "text-lg font-semibold hover:underline",
292
+ listMeta: "mt-1 opacity-80",
293
+ listDate: "font-semibold",
294
+ empty: "text-lg opacity-75",
295
+ more: `mt-8 inline-block ${more}`,
296
+ },
297
+
298
+ /* ─── Events ─── */
299
+ eventCard: {
300
+ list: "grid gap-5 sm:grid-cols-2 lg:grid-cols-3",
301
+ card: "relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line",
302
+ image: "aspect-[16/9] w-full object-cover",
303
+ body: "flex flex-1 flex-col gap-2 p-6",
304
+ date: "text-sm font-semibold text-highlight",
305
+ cancelled: "mr-2 rounded bg-danger px-1.5 py-0.5 text-white",
306
+ title: "text-xl font-semibold",
307
+ link: "after:absolute after:inset-0 hover:underline",
308
+ place: "opacity-75",
309
+ summary: "leading-relaxed opacity-85",
310
+ actions: "relative z-10 mt-auto pt-2",
311
+ empty: "text-lg opacity-75",
312
+ },
313
+ upcomingEvents: { more: `mt-8 inline-block ${more}` },
314
+ pastEvents: { tone: "muted" },
315
+ eventSpotlight: {
316
+ tone: "muted",
317
+ grid: "grid items-center gap-10 md:grid-cols-2",
318
+ label: `mb-3 ${kicker}`,
319
+ title: "text-[clamp(28px,3.4vw,40px)] font-bold leading-tight",
320
+ date: "mt-3 text-lg font-semibold text-highlight",
321
+ place: "mt-1 opacity-80",
322
+ summary: "mt-4 text-lg opacity-85",
323
+ actions: "mt-6",
324
+ image: "aspect-[4/3] w-full rounded-xl object-cover",
325
+ },
326
+ /** An event's page (/events/… and events in collections). */
327
+ event: {
328
+ header: "bg-muted",
329
+ headerInner: "mx-auto grid max-w-site items-center gap-10 px-4 py-16 sm:px-6 md:grid-cols-2",
330
+ details: "flex flex-col gap-4",
331
+ collection: kicker,
332
+ title: "text-[clamp(32px,4.4vw,52px)] font-bold leading-tight tracking-tight",
333
+ image: "aspect-[4/3] w-full rounded-xl object-cover",
334
+ /** When the event's blocks start with a hero: the band under it with the date, place and buttons. */
335
+ band: "bg-muted",
336
+ bandInner: "mx-auto flex max-w-site flex-wrap items-center justify-between gap-6 px-4 py-8 sm:px-6",
337
+ bandDetails: "flex flex-col gap-2",
338
+ cancelled: "w-fit rounded bg-danger px-2 py-1 text-sm font-semibold text-white",
339
+ when: "text-lg font-semibold text-highlight",
340
+ where: "",
341
+ placeName: "block font-semibold",
342
+ address: "block opacity-80",
343
+ online: "block font-semibold",
344
+ summary: "text-lg opacity-85",
345
+ actions: "flex flex-wrap items-center gap-3",
346
+ /** The Volunteer button beside Register. */
347
+ volunteer: "w-fit rounded-lg px-6 py-3.5 font-semibold ring-2 ring-current hover:opacity-80",
348
+ /** The Register and Volunteer sections at the foot of the page. */
349
+ section: "mx-auto max-w-site scroll-mt-8 px-4 py-16 sm:px-6",
350
+ sectionInner: "max-w-2xl",
351
+ sectionTitle: "mb-2 text-2xl font-bold",
352
+ sectionNote: "mb-2 opacity-80",
353
+ sectionIntro: "mb-2 whitespace-pre-line opacity-85",
354
+ form: "mt-6",
355
+ },
356
+ /** The Register button (or why there isn't one) on event cards and pages. */
357
+ registration: {
358
+ note: "font-semibold opacity-75 data-[size=small]:text-sm",
359
+ },
360
+ volunteerSignup: {
361
+ wrap: "max-w-2xl",
362
+ title: "mb-2 text-2xl font-bold",
363
+ event: "mb-2 font-semibold",
364
+ eventLink: "underline underline-offset-4 hover:opacity-80",
365
+ eventDate: "text-highlight on-dark:text-current",
366
+ intro: "mb-2 whitespace-pre-line opacity-85",
367
+ note: "mb-2 opacity-80",
368
+ form: "mt-6",
369
+ closed: "opacity-80",
370
+ },
371
+
372
+ /* ─── Giving ─── */
373
+ progress: {
374
+ root: "flex flex-col gap-2",
375
+ amounts: "text-base data-[size=large]:text-2xl",
376
+ raised: "font-bold",
377
+ goal: "opacity-75",
378
+ bar: "h-2.5 overflow-hidden rounded-full bg-line",
379
+ fill: "h-full rounded-full bg-accent",
380
+ donors: "text-sm opacity-75",
381
+ },
382
+ fundraiserCard: {
383
+ list: "grid gap-5 sm:grid-cols-2 lg:grid-cols-3",
384
+ card: "relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line",
385
+ image: "aspect-[16/9] w-full object-cover",
386
+ body: "flex flex-1 flex-col gap-3 p-6",
387
+ completed: "w-fit rounded bg-muted px-2 py-0.5 text-sm font-semibold",
388
+ title: "text-xl font-semibold",
389
+ link: "after:absolute after:inset-0 hover:underline",
390
+ summary: "leading-relaxed opacity-85",
391
+ progress: "mt-auto pt-2",
392
+ empty: "text-lg opacity-75",
393
+ },
394
+ fundraiserGrid: { more: `mt-8 inline-block ${more}` },
395
+ fundraiserSpotlight: {
396
+ tone: "muted",
397
+ grid: "grid items-center gap-10 md:grid-cols-2",
398
+ details: "flex flex-col gap-4",
399
+ label: kicker,
400
+ title: "text-[clamp(28px,3.4vw,40px)] font-bold leading-tight",
401
+ summary: "text-lg opacity-85",
402
+ actions: "flex flex-wrap gap-3 pt-2",
403
+ learnMore: "rounded-lg px-6 py-3.5 font-semibold ring-1 ring-line hover:bg-surface",
404
+ image: "aspect-[4/3] w-full rounded-xl object-cover",
405
+ },
406
+ donate: {
407
+ wrap: "max-w-2xl",
408
+ supporting: "mb-6 font-semibold",
409
+ },
410
+ /** A fundraiser's page (/fundraisers/…). */
411
+ fundraiser: {
412
+ header: "bg-muted",
413
+ headerInner: "mx-auto grid max-w-site items-center gap-10 px-4 py-16 sm:px-6 md:grid-cols-2",
414
+ details: "flex flex-col gap-4",
415
+ label: kicker,
416
+ title: "text-[clamp(32px,4.4vw,52px)] font-bold leading-tight tracking-tight",
417
+ summary: "text-lg opacity-85",
418
+ actions: "flex flex-wrap gap-3",
419
+ image: "aspect-[4/3] w-full rounded-xl object-cover",
420
+ give: "max-w-2xl",
421
+ giveTitle: "mb-6 text-2xl font-bold",
422
+ completedTitle: "mb-8 text-2xl font-bold",
423
+ },
424
+
425
+ /* ─── Forms (contact form, volunteer sign-up, registration, donations) ─── */
426
+ form: {
427
+ root: "space-y-6",
428
+ alert: "rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-danger",
429
+ success: "rounded-xl bg-accent p-6 text-lg font-semibold text-accent-ink",
430
+ row: "grid gap-5 sm:grid-cols-2",
431
+ field: "block",
432
+ label: "mb-1.5 block font-semibold",
433
+ optional: "ml-1 font-normal opacity-70",
434
+ input,
435
+ error: "mt-1.5 block text-sm text-danger",
436
+ check: "flex items-center gap-3",
437
+ checkbox: choice,
438
+ legend: "mb-3 font-semibold",
439
+ /** Shifts and tickets: a card per choice. */
440
+ options: "space-y-3",
441
+ option: "flex cursor-pointer items-start gap-3 rounded-lg p-4 ring-1 ring-line has-checked:ring-2 has-checked:ring-accent has-disabled:cursor-not-allowed has-disabled:opacity-50",
442
+ optionInput: `mt-1 ${choice}`,
443
+ optionText: "flex-1",
444
+ optionTitle: "flex flex-wrap justify-between gap-2 font-semibold",
445
+ optionMeta: "font-normal opacity-80",
446
+ optionDetail: "mt-0.5 block opacity-80",
447
+ /** Donation amounts: radio buttons drawn as pills. */
448
+ amounts: "flex flex-wrap gap-3",
449
+ amount:
450
+ "cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-field has-checked:bg-accent has-checked:text-accent-ink has-checked:ring-accent has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-accent",
451
+ customAmount: "mt-4 block max-w-xs",
452
+ hint: "mt-2 text-sm opacity-75",
453
+ submit: "rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90 disabled:opacity-60",
454
+ note: "text-sm opacity-75",
455
+ },
456
+
457
+ /** A Form block set to an Ember form: Ember draws it in its own frame, so it takes values, not classes (colors come from the tokens). */
458
+ emberForm: { radius: "8px", fontSize: "16px", gap: "20px" },
459
+
460
+ /* ─── Share (events and fundraisers) ─── */
461
+ share: {
462
+ button: "inline-flex w-fit items-center gap-2 rounded-lg px-5 py-3 font-semibold ring-1 ring-current/40 hover:ring-current",
463
+ dialog: "m-auto w-[min(32rem,calc(100%-2rem))] rounded-2xl bg-surface p-0 text-ink shadow-2xl backdrop:bg-black/50",
464
+ top: "flex items-center justify-between gap-4 border-b border-line px-5 py-4",
465
+ title: "text-xl font-bold",
466
+ close: "grid size-11 place-items-center rounded-lg hover:bg-muted",
467
+ body: "space-y-5 p-5",
468
+ message: input,
469
+ copy: "grid gap-2 sm:grid-cols-2",
470
+ copyButton: "rounded-lg bg-accent px-4 py-3 font-semibold text-accent-ink hover:opacity-90",
471
+ status: "min-h-5 text-center text-sm font-semibold",
472
+ targetsLabel: "mb-2 text-sm font-semibold opacity-75",
473
+ targets: "grid grid-cols-2 gap-2 sm:grid-cols-3",
474
+ target: "inline-flex min-h-11 items-center justify-center rounded-lg px-3 text-sm font-semibold ring-1 ring-line hover:ring-current",
475
+ },
476
+
477
+ /* ─── Words ─── */
478
+ text: {
479
+ directions: "Get directions",
480
+ allEvents: "All events",
481
+ allFundraisers: "All fundraisers",
482
+ allEntries: "All {collection}",
483
+ noEvents: "No upcoming events right now. Check back soon.",
484
+ noFundraisers: "No fundraisers right now. Check back soon.",
485
+ noEntries: "Nothing here yet. Check back soon.",
486
+ eventDetails: "Event details",
487
+ register: "Register",
488
+ registrationClosed: "Registration closed",
489
+ soldOut: "Sold out",
490
+ comingSoon: "Registration coming soon",
491
+ cancelled: "Cancelled",
492
+ cancelledEvent: "This event is cancelled",
493
+ online: "Online",
494
+ onlineAndInPerson: "Online and in person",
495
+ volunteer: "Volunteer",
496
+ volunteerClosed: "Volunteer sign-up for this event has closed.",
497
+ supports: "This event supports {fundraiser}.",
498
+ ticketsSupport: "Ticket sales support {fundraiser}.",
499
+ donate: "Donate",
500
+ learnMore: "Learn more",
501
+ supporting: "Supporting: {fundraiser}",
502
+ giveTo: "Give to {fundraiser}",
503
+ completed: "Completed",
504
+ menu: "Menu",
505
+ close: "Close",
506
+ share: "Share",
507
+ visitProject: "Visit {name}",
508
+ mapFailed: "The map couldn't load.",
509
+ },
510
+ };
@@ -0,0 +1,31 @@
1
+ import { defaults } from "./defaults";
2
+
3
+ /*
4
+ * How the site looks, slot by slot. Every element Hearth's code draws takes its classes from one slot here, so a site
5
+ * restyles it in src/theme/index.ts without touching this folder. `defaults` holds every slot and what it starts as.
6
+ */
7
+
8
+ export type Theme = typeof defaults;
9
+
10
+ /** A slot's new classes, or a function of the default's classes (to add to them: (base) => `${base} uppercase`). */
11
+ export type SlotOverride = string | ((base: string) => string);
12
+
13
+ export type ThemeOverrides = { [Part in keyof Theme]?: { [Slot in keyof Theme[Part]]?: SlotOverride } };
14
+
15
+ /** The site's theme: the defaults with its own slots in their place. Missing parts and slots keep the default. */
16
+ export function defineTheme(overrides: ThemeOverrides = {}): Theme {
17
+ const theme = structuredClone(defaults) as Record<string, Record<string, string>>;
18
+ for (const [part, slots] of Object.entries(overrides)) {
19
+ if (!theme[part]) throw new Error(`Theme: there's no part "${part}". See src/hearth/theme/defaults.ts for the parts.`);
20
+ for (const [slot, value] of Object.entries(slots ?? {})) {
21
+ if (!(slot in theme[part])) throw new Error(`Theme: "${part}" has no slot "${slot}". See src/hearth/theme/defaults.ts.`);
22
+ if (value !== undefined) theme[part][slot] = typeof value === "function" ? value(theme[part][slot]) : value;
23
+ }
24
+ }
25
+ return theme as Theme;
26
+ }
27
+
28
+ /** Class names joined, skipping empty ones. */
29
+ export const cx = (...classes: (string | false | null | undefined)[]) => classes.filter(Boolean).join(" ");
30
+
31
+ export { defaults };