create-nextblock 0.14.2 → 0.14.4

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 (29) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +375 -117
  3. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  4. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  5. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  6. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  7. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  8. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  9. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  10. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  11. package/templates/nextblock-template/app/layout.tsx +49 -0
  12. package/templates/nextblock-template/app/providers.tsx +16 -4
  13. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  14. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  15. package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
  16. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  17. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
  18. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  19. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  20. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  21. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +92 -87
  22. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  23. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  24. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  25. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  26. package/templates/nextblock-template/next-env.d.ts +2 -2
  27. package/templates/nextblock-template/package.json +1 -1
  28. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  29. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -13,6 +13,12 @@ import { ConsentGatedAnalytics } from '../components/privacy/ConsentGatedAnalyti
13
13
  import { ConsentBanner } from '../components/privacy/ConsentBanner';
14
14
  import { getPrivacySettings } from '../lib/privacy/settings';
15
15
  import { DEFAULT_PRIVACY_SETTINGS } from '../lib/privacy/types';
16
+ import {
17
+ activeThemeSlugs,
18
+ buildThemeCss,
19
+ defaultThemeSlug,
20
+ type SiteTheme,
21
+ } from '../lib/themes/buildThemeCss';
16
22
  import { DeferredSpeedInsights } from '../components/DeferredSpeedInsights';
17
23
  import { DeferredVisualEditing } from '../components/visual-editing/DeferredVisualEditing';
18
24
  import {
@@ -169,6 +175,25 @@ const getCachedGlobalCss = unstable_cache(
169
175
  { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS }
170
176
  );
171
177
 
178
+ const getCachedSiteThemes = unstable_cache(
179
+ async (): Promise<SiteTheme[]> => {
180
+ const supabase = createStaticSupabaseClient();
181
+ const { data, error } = await supabase
182
+ .from('site_themes')
183
+ .select('id, slug, name, description, icon, color_scheme, tokens, extra_css, is_system, is_default, is_active, sort_order')
184
+ .order('sort_order');
185
+
186
+ if (error || !data) {
187
+ // A missing table (pre-migration install) must not take the site down —
188
+ // libs/ui/src/styles/theme.css still ships a working fallback palette.
189
+ return [];
190
+ }
191
+ return data as unknown as SiteTheme[];
192
+ },
193
+ ['public-layout-site-themes'],
194
+ { revalidate: PUBLIC_LAYOUT_REVALIDATE_SECONDS, tags: ['public-layout-site-themes'] }
195
+ );
196
+
172
197
  const getCachedTranslations = unstable_cache(
173
198
  async () => {
174
199
  const supabase = createStaticSupabaseClient();
@@ -298,6 +323,7 @@ async function loadLayoutData() {
298
323
  siteTitle: 'NextBlock',
299
324
  isEcommerceActive: false,
300
325
  globalCss: '',
326
+ siteThemes: [] as SiteTheme[],
301
327
  privacySettings: DEFAULT_PRIVACY_SETTINGS,
302
328
  footerAttributionEnabled: true,
303
329
  rememberVisitorChoice: DEFAULT_LANGUAGE_DETECTION_SETTINGS.rememberVisitorChoice,
@@ -324,6 +350,7 @@ async function loadLayoutData() {
324
350
  currenciesResult,
325
351
  copyrightSettingsResult,
326
352
  globalCssResult,
353
+ siteThemesResult,
327
354
  translationsResult,
328
355
  isEcommerceActive,
329
356
  privacySettings,
@@ -336,6 +363,7 @@ async function loadLayoutData() {
336
363
  en: '(c) {year} Nextblock CMS. All rights reserved.',
337
364
  })),
338
365
  getCachedGlobalCss().catch(() => ''),
366
+ getCachedSiteThemes().catch(() => [] as SiteTheme[]),
339
367
  getCachedTranslations().catch(() => []),
340
368
  verifyPackageOnline('ecommerce').catch(() => false),
341
369
  getPrivacySettings().catch(() => DEFAULT_PRIVACY_SETTINGS),
@@ -369,6 +397,7 @@ async function loadLayoutData() {
369
397
  const copyrightText = templateForLocale.replace('{year}', new Date().getFullYear().toString());
370
398
 
371
399
  const globalCss = typeof globalCssResult === 'string' ? globalCssResult : '';
400
+ const siteThemes = Array.isArray(siteThemesResult) ? siteThemesResult : [];
372
401
  const translations = Array.isArray(translationsResult) ? translationsResult : [];
373
402
 
374
403
  const hasSupabaseEnv = isSupabaseConfigured();
@@ -404,6 +433,7 @@ async function loadLayoutData() {
404
433
  siteTitle,
405
434
  isEcommerceActive,
406
435
  globalCss,
436
+ siteThemes,
407
437
  privacySettings,
408
438
  footerAttributionEnabled,
409
439
  rememberVisitorChoice: languageDetectionSettings.rememberVisitorChoice,
@@ -486,10 +516,25 @@ export default async function RootLayout({
486
516
  siteTitle,
487
517
  isEcommerceActive,
488
518
  globalCss,
519
+ siteThemes,
489
520
  privacySettings,
490
521
  footerAttributionEnabled,
491
522
  rememberVisitorChoice,
492
523
  } = await loadLayoutData();
524
+ // Themes are rendered server-side into <head> so the palette is correct on the
525
+ // very first paint, before next-themes' blocking script adds the html class.
526
+ const themeCss = buildThemeCss(siteThemes);
527
+ const themeSlugs = activeThemeSlugs(siteThemes);
528
+ const initialTheme = defaultThemeSlug(siteThemes);
529
+ const themeCatalog = siteThemes
530
+ .filter((theme) => theme.is_active)
531
+ .sort((a, b) => a.sort_order - b.sort_order)
532
+ .map((theme) => ({
533
+ slug: theme.slug,
534
+ name: theme.name,
535
+ icon: theme.icon,
536
+ colorScheme: theme.color_scheme,
537
+ }));
493
538
  const draft = await draftMode();
494
539
  // GTM container id comes solely from the privacy settings row (site_settings).
495
540
  // There is intentionally no NEXT_PUBLIC_GTM_ID env fallback — analytics is
@@ -517,6 +562,7 @@ export default async function RootLayout({
517
562
  <html lang={serverDeterminedLocale} suppressHydrationWarning>
518
563
  <head>
519
564
  <meta name="viewport" content="width=device-width, initial-scale=1" />
565
+ {themeCss && <style id="nb-theme-tokens" dangerouslySetInnerHTML={{ __html: themeCss }} />}
520
566
  {globalCss && <style dangerouslySetInnerHTML={{ __html: globalCss }} />}
521
567
  </head>
522
568
  <body className="min-h-screen">
@@ -545,6 +591,9 @@ export default async function RootLayout({
545
591
  rememberVisitorChoice={rememberVisitorChoice}
546
592
  translations={translations}
547
593
  nonce={nonce}
594
+ themeSlugs={themeSlugs}
595
+ initialTheme={initialTheme}
596
+ themeCatalog={themeCatalog}
548
597
  >
549
598
  <ToasterProvider />
550
599
  <AppShell
@@ -15,6 +15,7 @@ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
15
15
  import { AuthProvider } from '../context/AuthContext';
16
16
  import { LanguageProvider, useLanguage } from '../context/LanguageContext';
17
17
  import { CurrentContentProvider } from '../context/CurrentContentContext';
18
+ import { ThemeCatalogProvider } from '../context/ThemeCatalogContext';
18
19
  import { DeferredCartTranslator } from '../components/DeferredCartTranslator';
19
20
  import { CurrencyProvider } from '@nextblock-cms/ecommerce/CurrencyProvider';
20
21
  import { TranslationsProvider } from '@nextblock-cms/utils';
@@ -46,9 +47,20 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
46
47
  initialDefaultLanguage,
47
48
  rememberVisitorChoice,
48
49
  translations,
49
- nonce
50
+ nonce,
51
+ themeSlugs,
52
+ initialTheme,
53
+ themeCatalog,
50
54
  } = props;
51
55
 
56
+ // Themes come from the site_themes table. Fall back to the three that ship in
57
+ // libs/ui/src/styles/theme.css if the table is empty or unreachable, so the
58
+ // switcher never renders an empty list.
59
+ const resolvedThemes: string[] =
60
+ Array.isArray(themeSlugs) && themeSlugs.length > 0 ? themeSlugs : ['light', 'dark', 'vibrant'];
61
+ const resolvedDefault: string =
62
+ typeof initialTheme === 'string' && resolvedThemes.includes(initialTheme) ? initialTheme : 'light';
63
+
52
64
  return (
53
65
  <AuthProvider serverUser={serverUser} serverProfile={serverProfile}>
54
66
  <LanguageProvider
@@ -67,13 +79,13 @@ export function Providers({ children, ...props }: { children: React.ReactNode;[k
67
79
  <TranslationBridge translations={translations}>
68
80
  <ThemeProvider
69
81
  attribute="class"
70
- defaultTheme="light"
82
+ defaultTheme={resolvedDefault}
71
83
  enableSystem
72
84
  disableTransitionOnChange
73
85
  nonce={nonce}
74
- themes={['light', 'dark', 'vibrant']}
86
+ themes={resolvedThemes}
75
87
  >
76
- {children}
88
+ <ThemeCatalogProvider themes={themeCatalog}>{children}</ThemeCatalogProvider>
77
89
  </ThemeProvider>
78
90
  </TranslationBridge>
79
91
  </CurrentContentProvider>
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import type { HeadingBlockContent } from '../../../lib/blocks/blockRegistry';
3
+ import { resolveTextAlign, resolveTextColor } from '../../../lib/blocks/blockColors';
3
4
  import type { VisualEditAttributes } from "../../../lib/visual-editing/types";
4
5
 
5
6
  interface HeadingBlockRendererProps {
@@ -23,19 +24,15 @@ const HeadingBlockRenderer: React.FC<HeadingBlockRendererProps> = ({
23
24
  : 2;
24
25
  const Tag: React.ElementType = `h${level}`;
25
26
 
26
- let alignmentClass = "";
27
- if (content.textAlign) {
28
- alignmentClass = `text-${content.textAlign}`;
29
- }
27
+ const alignmentClass = resolveTextAlign(content.textAlign);
28
+ const { className: colorClass, style: colorStyle } = resolveTextColor(content.textColor);
30
29
 
31
- let colorClass = "";
32
- if (content.textColor) {
33
- colorClass = `text-${content.textColor}`;
34
- }
30
+ const combinedClasses = ["my-6 font-bold container mx-auto", alignmentClass, colorClass]
31
+ .filter(Boolean)
32
+ .join(" ");
35
33
 
36
- const combinedClasses = `my-6 font-bold container mx-auto ${alignmentClass} ${colorClass}`.trim();
37
34
  return (
38
- <Tag className={combinedClasses} {...visualEditAttributes}>
35
+ <Tag className={combinedClasses} style={colorStyle} {...visualEditAttributes}>
39
36
  {content.text_content}
40
37
  </Tag>
41
38
  );
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import {
4
+ Aperture,
5
+ Atom,
6
+ Blend,
7
+ Brush,
8
+ Cloud,
9
+ Contrast,
10
+ Droplet,
11
+ Feather,
12
+ Flame,
13
+ Gem,
14
+ Ghost,
15
+ Leaf,
16
+ Moon,
17
+ Mountain,
18
+ Palette,
19
+ Rocket,
20
+ Snowflake,
21
+ Sparkles,
22
+ Star,
23
+ Sun,
24
+ Sunrise,
25
+ Sunset,
26
+ Waves,
27
+ Zap,
28
+ type LucideIcon,
29
+ } from "lucide-react";
30
+
31
+ /**
32
+ * Curated icon set offered to themes.
33
+ *
34
+ * A fixed map rather than a dynamic lucide lookup: the icon name comes from the
35
+ * database, and importing lucide dynamically by string would either pull the
36
+ * whole icon set into the bundle or break tree-shaking.
37
+ */
38
+ export const THEME_ICONS: Record<string, LucideIcon> = {
39
+ Sun,
40
+ Moon,
41
+ Zap,
42
+ Sparkles,
43
+ Palette,
44
+ Flame,
45
+ Droplet,
46
+ Leaf,
47
+ Star,
48
+ Cloud,
49
+ Snowflake,
50
+ Waves,
51
+ Mountain,
52
+ Sunrise,
53
+ Sunset,
54
+ Contrast,
55
+ Blend,
56
+ Brush,
57
+ Aperture,
58
+ Atom,
59
+ Feather,
60
+ Gem,
61
+ Ghost,
62
+ Rocket,
63
+ };
64
+
65
+ export const THEME_ICON_NAMES = Object.keys(THEME_ICONS);
66
+
67
+ export function ThemeIcon({
68
+ name,
69
+ size = 16,
70
+ className,
71
+ }: {
72
+ name: string;
73
+ size?: number;
74
+ className?: string;
75
+ }) {
76
+ const Icon = THEME_ICONS[name] ?? Palette;
77
+ return <Icon size={size} className={className} />;
78
+ }
@@ -1,90 +1,85 @@
1
- "use client";
2
-
3
- import { Button } from "@nextblock-cms/ui";
4
- import {
5
- DropdownMenu,
6
- DropdownMenuContent,
7
- DropdownMenuRadioGroup,
8
- DropdownMenuRadioItem,
9
- DropdownMenuTrigger,
10
- } from "@nextblock-cms/ui";
11
- import { Laptop, Moon, Sun, Zap } from "lucide-react";
12
- import { useTheme } from "next-themes";
13
- import { useEffect, useState } from "react";
14
- import { useTranslations } from "@nextblock-cms/utils";
15
-
16
- const ThemeSwitcher = () => {
17
- const [mounted, setMounted] = useState(false);
18
- const { theme, setTheme } = useTheme();
19
- const { t } = useTranslations();
20
-
21
- // useEffect only runs on the client, so now we can safely show the UI
22
- useEffect(() => {
23
- setMounted(true);
24
- }, []);
25
-
26
- if (!mounted) {
27
- return null;
28
- }
29
-
30
- const ICON_SIZE = 16;
31
-
32
- return (
33
- <DropdownMenu>
34
- <DropdownMenuTrigger asChild>
35
- <Button variant="ghost" size={"sm"} aria-label={t('theme_switcher')}>
36
- {theme === "light" ? (
37
- <Sun
38
- key="light"
39
- size={ICON_SIZE}
40
- className={"text-muted-foreground"}
41
- />
42
- ) : theme === "dark" ? (
43
- <Moon
44
- key="dark"
45
- size={ICON_SIZE}
46
- className={"text-muted-foreground"}
47
- />
48
- ) : theme === "vibrant" ? (
49
- <Zap
50
- key="vibrant"
51
- size={ICON_SIZE}
52
- className={"text-muted-foreground"}
53
- />
54
- ) : (
55
- <Laptop
56
- key="system"
57
- size={ICON_SIZE}
58
- className={"text-muted-foreground"}
59
- />
60
- )}
61
- </Button>
62
- </DropdownMenuTrigger>
63
- <DropdownMenuContent className="w-content" align="start">
64
- <DropdownMenuRadioGroup
65
- value={theme}
66
- onValueChange={(e) => setTheme(e)}
67
- >
68
- <DropdownMenuRadioItem className="flex gap-2" value="light">
69
- <Sun size={ICON_SIZE} className="text-muted-foreground" />{" "}
70
- <span>{t('theme_light')}</span>
71
- </DropdownMenuRadioItem>
72
- <DropdownMenuRadioItem className="flex gap-2" value="dark">
73
- <Moon size={ICON_SIZE} className="text-muted-foreground" />{" "}
74
- <span>{t('theme_dark')}</span>
75
- </DropdownMenuRadioItem>
76
- <DropdownMenuRadioItem className="flex gap-2" value="vibrant">
77
- <Zap size={ICON_SIZE} className="text-muted-foreground" />{" "}
78
- <span>{t('theme_vibrant')}</span>
79
- </DropdownMenuRadioItem>
80
- <DropdownMenuRadioItem className="flex gap-2" value="system">
81
- <Laptop size={ICON_SIZE} className="text-muted-foreground" />{" "}
82
- <span>{t('theme_system')}</span>
83
- </DropdownMenuRadioItem>
84
- </DropdownMenuRadioGroup>
85
- </DropdownMenuContent>
86
- </DropdownMenu>
87
- );
88
- };
89
-
90
- export { ThemeSwitcher };
1
+ "use client";
2
+
3
+ import { Button } from "@nextblock-cms/ui";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuRadioGroup,
8
+ DropdownMenuRadioItem,
9
+ DropdownMenuTrigger,
10
+ } from "@nextblock-cms/ui";
11
+ import { Laptop } from "lucide-react";
12
+ import { useTheme } from "next-themes";
13
+ import { useEffect, useState } from "react";
14
+ import { useTranslations } from "@nextblock-cms/utils";
15
+ import { useThemeCatalog } from "../context/ThemeCatalogContext";
16
+ import { ThemeIcon } from "./theme-icon";
17
+
18
+ const ICON_SIZE = 16;
19
+
20
+ const ThemeSwitcher = () => {
21
+ const [mounted, setMounted] = useState(false);
22
+ const { theme, setTheme } = useTheme();
23
+ const { t } = useTranslations();
24
+ const themes = useThemeCatalog();
25
+
26
+ // useEffect only runs on the client, so now we can safely show the UI
27
+ useEffect(() => {
28
+ setMounted(true);
29
+ }, []);
30
+
31
+ if (!mounted) {
32
+ return null;
33
+ }
34
+
35
+ const active = themes.find((entry) => entry.slug === theme);
36
+
37
+ /**
38
+ * Built-in slugs keep their existing translation keys so no copy is lost;
39
+ * admin-created themes fall back to the name stored on the row.
40
+ */
41
+ const labelFor = (slug: string, name: string) => {
42
+ const builtin: Record<string, string> = {
43
+ light: "theme_light",
44
+ dark: "theme_dark",
45
+ vibrant: "theme_vibrant",
46
+ };
47
+ const key = builtin[slug];
48
+ if (!key) return name;
49
+ const translated = t(key);
50
+ return translated === key ? name : translated;
51
+ };
52
+
53
+ return (
54
+ <DropdownMenu>
55
+ <DropdownMenuTrigger asChild>
56
+ <Button variant="ghost" size={"sm"} aria-label={t('theme_switcher')}>
57
+ {active ? (
58
+ <ThemeIcon name={active.icon} size={ICON_SIZE} className="text-muted-foreground" />
59
+ ) : (
60
+ <Laptop size={ICON_SIZE} className="text-muted-foreground" />
61
+ )}
62
+ </Button>
63
+ </DropdownMenuTrigger>
64
+ <DropdownMenuContent className="w-content" align="start">
65
+ <DropdownMenuRadioGroup
66
+ value={theme}
67
+ onValueChange={(e) => setTheme(e)}
68
+ >
69
+ {themes.map((entry) => (
70
+ <DropdownMenuRadioItem key={entry.slug} className="flex gap-2" value={entry.slug}>
71
+ <ThemeIcon name={entry.icon} size={ICON_SIZE} className="text-muted-foreground" />{" "}
72
+ <span>{labelFor(entry.slug, entry.name)}</span>
73
+ </DropdownMenuRadioItem>
74
+ ))}
75
+ <DropdownMenuRadioItem className="flex gap-2" value="system">
76
+ <Laptop size={ICON_SIZE} className="text-muted-foreground" />{" "}
77
+ <span>{t('theme_system')}</span>
78
+ </DropdownMenuRadioItem>
79
+ </DropdownMenuRadioGroup>
80
+ </DropdownMenuContent>
81
+ </DropdownMenu>
82
+ );
83
+ };
84
+
85
+ export { ThemeSwitcher };
@@ -0,0 +1,44 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
4
+
5
+ /** The switcher-facing shape of a row in `site_themes`. */
6
+ export interface ThemeCatalogEntry {
7
+ slug: string;
8
+ name: string;
9
+ /** lucide-react icon name. */
10
+ icon: string;
11
+ colorScheme: "light" | "dark";
12
+ }
13
+
14
+ /**
15
+ * Themes are read once on the server in app/layout.tsx and handed to the client
16
+ * through this context, so the switcher never fetches and never hardcodes a
17
+ * list. Falls back to the palette shipped in libs/ui/src/styles/theme.css when
18
+ * the table is empty or unreachable.
19
+ */
20
+ export const FALLBACK_THEME_CATALOG: ThemeCatalogEntry[] = [
21
+ { slug: "light", name: "Light", icon: "Sun", colorScheme: "light" },
22
+ { slug: "dark", name: "Dark", icon: "Moon", colorScheme: "dark" },
23
+ { slug: "vibrant", name: "Vibrant", icon: "Zap", colorScheme: "dark" },
24
+ ];
25
+
26
+ const ThemeCatalogContext = createContext<ThemeCatalogEntry[]>(FALLBACK_THEME_CATALOG);
27
+
28
+ export function ThemeCatalogProvider({
29
+ themes,
30
+ children,
31
+ }: {
32
+ themes?: ThemeCatalogEntry[];
33
+ children: ReactNode;
34
+ }) {
35
+ const value = useMemo(
36
+ () => (Array.isArray(themes) && themes.length > 0 ? themes : FALLBACK_THEME_CATALOG),
37
+ [themes],
38
+ );
39
+ return <ThemeCatalogContext.Provider value={value}>{children}</ThemeCatalogContext.Provider>;
40
+ }
41
+
42
+ export function useThemeCatalog(): ThemeCatalogEntry[] {
43
+ return useContext(ThemeCatalogContext);
44
+ }
@@ -200,3 +200,80 @@ contract lives in `libs/sdk` and is documented in
200
200
 
201
201
  If you are changing how blocks work inside the CMS, start here. If you are
202
202
  designing a reusable third-party block contract, start with the SDK doc.
203
+
204
+ ## Themes and Colour
205
+
206
+ ### Editable site themes
207
+
208
+ Themes are rows in `site_themes`, edited at **/cms/settings/global-css**. An
209
+ ADMIN can recolour any theme, create new ones, duplicate, reorder, hide, set the
210
+ site default, and delete non-system themes.
211
+
212
+ The pipeline is:
213
+
214
+ 1. `app/layout.tsx` reads the table through a cached `getCachedSiteThemes()`
215
+ (tag `public-layout-site-themes`).
216
+ 2. `lib/themes/buildThemeCss.ts` renders each row to a `:root.<slug> { ... }`
217
+ rule, injected as a `<style id="nb-theme-tokens">` in `<head>` — before the
218
+ Global CSS box, so custom CSS can still override a theme.
219
+ 3. `app/providers.tsx` feeds the slug list and default to `next-themes`, and
220
+ `context/ThemeCatalogContext.tsx` carries names + icons to the switcher.
221
+
222
+ `libs/ui/src/styles/theme.css` remains as the fallback palette for standalone
223
+ consumers of the published `@nextblock-cms/ui` package. Generated rules use
224
+ `:root.<slug>` (specificity 0,2,0) so they always beat that file's `.dark` /
225
+ `.vibrant` rules (0,1,0) regardless of stylesheet order.
226
+
227
+ ### Token storage
228
+
229
+ Colour tokens are stored as **bare HSL triplets** (`"222 47% 11%"`), not hex,
230
+ because `libs/ui/tailwind.config.js` composes them as `hsl(var(--primary))` and
231
+ appends alpha as `hsl(var(--primary) / 0.5)` — only a bare triplet supports
232
+ that. `lib/themes/tokenColor.ts` converts to and from the hex the colour picker
233
+ speaks. The allowed token list lives in `lib/themes/tokens.ts`; anything not on
234
+ it is dropped on save, and values are shape-checked, because the result is
235
+ interpolated into a `<style>` tag.
236
+
237
+ Per-theme `extra_css` is emitted **nested inside** the theme rule, so authors
238
+ write `& h1 { ... }` and scoping is automatic. `sanitizeExtraCss()` strips `<`
239
+ and unbalanced `}` so it cannot escape the rule or close the style element.
240
+
241
+ ### Known limitation: `dark:` utilities under custom themes
242
+
243
+ Tailwind's dark variant compiles to `.dark`, and `darkMode: ['class']` is fixed
244
+ at build time. A custom theme with `color_scheme: 'dark'` recolours every token
245
+ and sets CSS `color-scheme`, but does **not** activate `dark:` utilities — the
246
+ same behaviour the shipped `.vibrant` theme has always had.
247
+
248
+ It cannot be fixed by mapping the theme to two classes: `next-themes` applies
249
+ its value with a single `classList.add(value)`, and `DOMTokenList.add` throws
250
+ `InvalidCharacterError` on a string containing a space. Wiring `dark:` to a data
251
+ attribute would require a pre-hydration script mirroring next-themes' own.
252
+
253
+ Verify the database → CSS path without booting the app:
254
+
255
+ ```bash
256
+ npm run verify:site-themes
257
+ ```
258
+
259
+ ### Block text colour
260
+
261
+ `apps/nextblock/lib/blocks/blockColors.ts` is the single source of truth for
262
+ block text colour, shared by `HeadingBlockRenderer`, `EditableBlock` and
263
+ `ColumnEditor` so the CMS preview cannot drift from the live page.
264
+
265
+ A stored value is either a **theme token** keyword (`"primary"` — follows the
266
+ theme) or a **literal CSS colour** (`"#FF8800"` — fixed). Tokens render as a
267
+ static Tailwind class; literals render as an inline `style`. `resolveTextColor()`
268
+ returns `{ className?, style? }` and falls back to `{}` for anything it does not
269
+ recognise, so a hand-edited value degrades to the inherited colour instead of
270
+ emitting a bogus class.
271
+
272
+ Class names here are written as **complete literals**. Tailwind v4 ignores the
273
+ `safelist` key in the legacy JS config — `libs/ui/tailwind.config.js` still
274
+ carries one, but v4 never reads it, so a class exists only if the scanner finds
275
+ the whole string in a source file.
276
+
277
+ The editor control is `ColorField` from `libs/ui`, which offers the theme tokens
278
+ and a custom picker (hex, alpha, screen eyedropper, recent colours) with a live
279
+ WCAG contrast badge measured against the section background.