@decocms/start 0.32.3 → 0.33.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.
@@ -0,0 +1,110 @@
1
+ # @decocms/start — Framework TODO
2
+
3
+ Issues discovered during the portal-davinci migration script development.
4
+
5
+ ---
6
+
7
+ ## Tier 0 — Blocking migrations
8
+
9
+ ### `setup.ts` should be framework-injected
10
+ - **Current**: Every site has a `setup.ts` with identical boilerplate (import.meta.glob, registerSections, setBlocks, registerBuiltinMatchers)
11
+ - **Ideal**: Site declares only its custom loaders/actions. Framework injects the rest via Vite plugin or a single `createSiteSetup()` call
12
+ - **Impact**: Migration script has to scaffold this file. Any framework change requires re-migrating all sites
13
+
14
+ ### `SiteTheme` component missing from `@decocms/start` or `@decocms/apps`
15
+ - **Current**: `apps/website/components/Theme.tsx` was removed. Migration script scaffolds a local replacement
16
+ - **Ideal**: Export `SiteTheme` from `@decocms/start/components/Theme` or `@decocms/apps/commerce/components/Theme`
17
+ - **Impact**: Every migrated site gets a local copy that diverges over time
18
+
19
+ ---
20
+
21
+ ## Tier 1 — Developer experience
22
+
23
+ ### `useScript(fn)` hydration mismatch warning
24
+ - **Current**: `useScript` calls `fn.toString()` which produces different output in SSR vs client builds (minification, variable renaming)
25
+ - **Warning**: `[useScript] Using fn.toString() for "setup". This may produce different output in SSR vs client builds`
26
+ - **Ideal**: Provide `inlineScript()` that accepts a plain string constant, or make `useScript` stable across builds
27
+ - **Impact**: Console noise in every dev session, potential hydration bugs in production
28
+
29
+ ### Route files are 100% boilerplate
30
+ - **Files**: `__root.tsx`, `index.tsx`, `$.tsx`, `deco/meta.ts`, `deco/invoke.$.ts`, `deco/render.ts`
31
+ - **Current**: Migration script scaffolds identical route files for every site
32
+ - **Ideal**: Framework provides a `createDecoRoutes()` or auto-generates via Vite plugin. Site only customizes (e.g. GTM ID, site name)
33
+ - **Impact**: Route files are copy-pasted across sites and diverge
34
+
35
+ ### `server.ts`, `worker-entry.ts`, `router.tsx` are boilerplate
36
+ - **Current**: Every site has identical server infrastructure files
37
+ - **Ideal**: Single function call like `createDecoServer()` / `createDecoRouter()`. Or auto-generated by Vite plugin
38
+ - **Impact**: Sites copy these files and never update them when framework improves
39
+
40
+ ### `dev:clean` script should be built into framework
41
+ - **Current**: Migration script adds `"dev:clean": "rm -rf node_modules/.vite .wrangler/state .tanstack && vite dev"`
42
+ - **Ideal**: `@decocms/start` CLI command or Vite plugin hook that auto-cleans stale caches on startup
43
+ - **Impact**: Developers hit mysterious caching bugs and don't know to clean
44
+
45
+ ---
46
+
47
+ ## Tier 2 — Quality & correctness
48
+
49
+ ### GTM/Analytics SDK is duplicated across sites
50
+ - **Current**: Every site has a custom `Session.tsx` with identical analytics SDK (data-event listeners, data-gtm-event listeners, IntersectionObserver)
51
+ - **Ideal**: `@decocms/start/analytics` exports a `<DecoAnalytics gtmId="GTM-XXX" />` component used in `__root.tsx`
52
+ - **Impact**: Analytics bugs are fixed per-site instead of once in the framework
53
+
54
+ ### `useGTMEvent` hook is universal
55
+ - **Current**: Sites have a local `sdk/useGTMEvent.ts` that creates `data-gtm-event` / `data-gtm-trigger` attributes
56
+ - **Ideal**: Export from `@decocms/start/analytics/useGTMEvent`
57
+ - **Impact**: Same utility duplicated in every site
58
+
59
+ ### Negative z-index (`-z-10`) breaks in Tailwind v4 stacking contexts
60
+ - **Current**: Migration script auto-fixes `-z-N` → `z-0` on images and adds `relative z-10` to content siblings
61
+ - **Root cause**: React/TanStack wraps sections in `<section>` elements that create new stacking contexts. Negative z-index gets trapped inside
62
+ - **Ideal**: Document this pattern. Consider framework-level CSS reset that prevents section wrappers from creating stacking contexts (e.g. `section { isolation: auto; }`)
63
+ - **Impact**: Every migrated site has background images that are invisible until manually fixed
64
+
65
+ ### Tailwind v3 → v4 opacity classes
66
+ - **Current**: Migration script converts `bg-black bg-opacity-20` → `bg-black/20`
67
+ - **Edge cases**: Non-adjacent opacity classes (e.g. `bg-black flex ... hover:bg-opacity-30`) are harder to detect
68
+ - **Ideal**: Provide a lint rule or postcss plugin that warns about orphaned opacity classes
69
+ - **Impact**: Subtle visual bugs that are hard to spot
70
+
71
+ ---
72
+
73
+ ## Tier 3 — Nice to have
74
+
75
+ ### `registerLayoutSections` auto-detection
76
+ - **Current**: Migration script detects Header/Footer/Theme by name pattern and adds to `registerLayoutSections()`
77
+ - **Ideal**: Framework auto-detects layout sections from CMS page structure (sections that appear on every page = layout)
78
+ - **Impact**: New sections added as layout require manual setup.ts update
79
+
80
+ ### Icon loaders (`availableIcons.ts`, `icons.ts`) depend on deleted `static/adminIcons.ts`
81
+ - **Current**: Migration script deletes these loaders entirely
82
+ - **Ideal**: Framework provides a built-in icon discovery mechanism (scan `public/sprites.svg` at build time)
83
+ - **Impact**: Admin loses icon picker functionality after migration
84
+
85
+ ### `import.meta.glob` section discovery could be smarter
86
+ - **Current**: Glob `./sections/**/*.tsx` discovers all files, including utility exports
87
+ - **Ideal**: Use a convention (e.g. default export = section) or a marker comment to distinguish sections from helpers
88
+ - **Impact**: Non-section files in `sections/` directory get registered as CMS sections
89
+
90
+ ---
91
+
92
+ ## Discovered during migration (portal-davinci)
93
+
94
+ | Issue | Where | Status |
95
+ |-------|-------|--------|
96
+ | `scriptAsDataURI` → `useScript` leaves malformed JSX | `fresh-apis.ts` | Fixed in script |
97
+ | `.test()` then `.replace()` regex bug (lastIndex statefulness) | All transforms | Fixed in script |
98
+ | `site/` → `~/` doesn't strip `.tsx` extension | `imports.ts` | Fixed in script |
99
+ | `class` in destructuring/interface not converted to `className` | `jsx.ts` | Fixed in script |
100
+ | `for=` → `htmlFor=` missing | `jsx.ts` | Fixed in script |
101
+ | `tabindex` → `tabIndex` missing | `jsx.ts` | Fixed in script |
102
+ | Multi-line `className={clx()}` z-index insertion fails | `tailwind.ts` | Fixed in script |
103
+ | `src/` directory scanned as source → `src/src/` created | `phase-analyze.ts` | Fixed in script |
104
+ | `package.json`/`package-lock.json` treated as source files | `phase-analyze.ts` | Fixed in script |
105
+ | `constants.ts` not deleted | `phase-analyze.ts` | Fixed in script |
106
+ | `deno-lint-ignore` in JSX comments not removed | `deno-isms.ts` | Fixed in script |
107
+ | `useDevice` import not split from `useScript` module | `imports.ts` | Fixed in script |
108
+ | `@decocms/start/worker` wrong path → `@decocms/start/sdk/workerEntry` | `server-entry.ts` | Fixed in script |
109
+ | `SiteTheme` / `Font` import removed by catch-all rule | `imports.ts` | Fixed in script |
110
+ | Bootstrap failures silently ignored | `migrate.ts` | Fixed in script |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.32.3",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -346,6 +346,53 @@ function extractSiteName(sourceDir: string): string {
346
346
  return dirName;
347
347
  }
348
348
 
349
+ function extractThemeFromCms(sourceDir: string): { colors: Record<string, string>; fontFamily: string | null } {
350
+ const colors: Record<string, string> = {};
351
+ let fontFamily: string | null = null;
352
+
353
+ // Look for Theme config in .deco/blocks/
354
+ const blocksDir = path.join(sourceDir, ".deco", "blocks");
355
+ if (!fs.existsSync(blocksDir)) return { colors, fontFamily };
356
+
357
+ const files = fs.readdirSync(blocksDir);
358
+ for (const file of files) {
359
+ if (!file.endsWith(".json")) continue;
360
+ try {
361
+ const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
362
+ if (!content.includes("mainColors") && !content.includes("Theme")) continue;
363
+
364
+ const data = JSON.parse(content);
365
+ // Direct Theme block (e.g. Deco.json with __resolveType: "site/sections/Theme/Theme.tsx")
366
+ if (data.mainColors) {
367
+ Object.assign(colors, data.mainColors);
368
+ if (data.complementaryColors) {
369
+ Object.assign(colors, data.complementaryColors);
370
+ }
371
+ }
372
+ // Font
373
+ if (data.font?.fonts?.[0]?.family) {
374
+ fontFamily = data.font.fonts[0].family;
375
+ }
376
+ // Check sections array (page blocks may contain Theme)
377
+ if (data.sections) {
378
+ for (const section of data.sections) {
379
+ if (section.__resolveType?.includes("Theme") && section.mainColors) {
380
+ Object.assign(colors, section.mainColors);
381
+ if (section.complementaryColors) {
382
+ Object.assign(colors, section.complementaryColors);
383
+ }
384
+ if (section.font?.fonts?.[0]?.family) {
385
+ fontFamily = section.font.fonts[0].family;
386
+ }
387
+ }
388
+ }
389
+ }
390
+ } catch {}
391
+ }
392
+
393
+ return { colors, fontFamily };
394
+ }
395
+
349
396
  export function analyze(ctx: MigrationContext): void {
350
397
  logPhase("Analyze");
351
398
 
@@ -365,9 +412,20 @@ export function analyze(ctx: MigrationContext): void {
365
412
  ctx.platform = extractPlatform(ctx.sourceDir);
366
413
  ctx.gtmId = extractGtmId(ctx.sourceDir);
367
414
 
415
+ // Extract theme colors and font from CMS
416
+ const theme = extractThemeFromCms(ctx.sourceDir);
417
+ ctx.themeColors = theme.colors;
418
+ ctx.fontFamily = theme.fontFamily;
419
+
368
420
  console.log(` Site: ${ctx.siteName}`);
369
421
  console.log(` Platform: ${ctx.platform}`);
370
422
  console.log(` GTM ID: ${ctx.gtmId || "none"}`);
423
+ if (Object.keys(ctx.themeColors).length > 0) {
424
+ console.log(` Theme: ${Object.keys(ctx.themeColors).length} colors from CMS`);
425
+ }
426
+ if (ctx.fontFamily) {
427
+ console.log(` Font: ${ctx.fontFamily}`);
428
+ }
371
429
 
372
430
  // Scan all files
373
431
  scanDir(ctx.sourceDir, ctx.sourceDir, ctx.files);
@@ -114,6 +114,35 @@ export function report(ctx: MigrationContext): void {
114
114
  );
115
115
  lines.push("");
116
116
 
117
+ // Known Issues
118
+ lines.push("## Known Issues (Tailwind v3 → v4 + React)");
119
+ lines.push("");
120
+ lines.push("### Negative z-index on background images");
121
+ lines.push("");
122
+ lines.push("The migration script automatically converts `-z-{n}` to `z-0` on `<img>` and `<Image>` elements.");
123
+ lines.push("However, if you have **non-image elements** with negative z-index (e.g. `-z-10` on a `<div>` used as a background layer), they may become invisible.");
124
+ lines.push("");
125
+ lines.push("**Why it breaks:** In TanStack Start/React, section wrappers (`<section>`) or parent elements can create");
126
+ lines.push("CSS stacking contexts (via `animation`, `transform`, `will-change`, `filter`, `isolation`, etc.).");
127
+ lines.push("A child with negative z-index gets trapped inside that stacking context and renders behind the parent's background — making it invisible.");
128
+ lines.push("");
129
+ lines.push("**How to fix:**");
130
+ lines.push("1. Replace `-z-{n}` with `z-0` on the background element");
131
+ lines.push("2. Content siblings render on top naturally via DOM order (they come after in the HTML)");
132
+ lines.push("3. If needed, add `relative z-10` to content siblings to ensure they stay above");
133
+ lines.push("");
134
+ lines.push("**How to detect:** Search for remaining negative z-index: `grep -rn '\\-z-' src/ --include='*.tsx'`");
135
+ lines.push("");
136
+ lines.push("### Opacity utility classes");
137
+ lines.push("");
138
+ lines.push("Tailwind v4 removed `bg-opacity-{n}`, `text-opacity-{n}`, etc. The script converts them to");
139
+ lines.push("the modifier syntax (e.g. `bg-black bg-opacity-20` → `bg-black/20`). If a color and its opacity");
140
+ lines.push("are not in the same className string (e.g. set via different conditional branches), the script");
141
+ lines.push("flags them for manual review.");
142
+ lines.push("");
143
+ lines.push("**How to detect:** `grep -rn 'opacity-' src/ --include='*.tsx'`");
144
+ lines.push("");
145
+
117
146
  // Framework findings
118
147
  lines.push("## Framework Findings");
119
148
  lines.push("");
@@ -68,6 +68,21 @@ export function scaffold(ctx: MigrationContext): void {
68
68
  // Apps
69
69
  writeFile(ctx, "src/apps/site.ts", generateSiteApp(ctx));
70
70
 
71
+ // SiteTheme component (replaces apps/website/components/Theme.tsx)
72
+ // Check if any source file uses SiteTheme
73
+ const usesSiteTheme = ctx.files.some((f) => {
74
+ if (f.action === "delete") return false;
75
+ try {
76
+ const content = fs.readFileSync(f.absPath, "utf-8");
77
+ return content.includes("SiteTheme");
78
+ } catch {
79
+ return false;
80
+ }
81
+ });
82
+ if (usesSiteTheme) {
83
+ writeFile(ctx, "src/components/ui/Theme.tsx", generateSiteThemeComponent());
84
+ }
85
+
71
86
  // Create public/ directory
72
87
  if (!ctx.dryRun) {
73
88
  fs.mkdirSync(path.join(ctx.sourceDir, "public"), { recursive: true });
@@ -76,7 +91,74 @@ export function scaffold(ctx: MigrationContext): void {
76
91
  console.log(` Scaffolded ${ctx.scaffoldedFiles.length} files`);
77
92
  }
78
93
 
79
- function generateAppCss(_ctx: MigrationContext): string {
94
+ function generateSiteThemeComponent(): string {
95
+ return `export interface Font {
96
+ family: string;
97
+ styleSheet?: string;
98
+ }
99
+
100
+ export interface Props {
101
+ colorScheme?: "light" | "dark" | "any";
102
+ fonts?: Font[];
103
+ variables?: Array<{ name: string; value: string }>;
104
+ }
105
+
106
+ /**
107
+ * SiteTheme — injects CSS custom properties and font stylesheets into the page.
108
+ * This replaces the old apps/website/components/Theme.tsx from the Deno stack.
109
+ */
110
+ export default function SiteTheme({ variables, fonts, colorScheme }: Props) {
111
+ const cssVars = variables?.length
112
+ ? \`:root { \${variables.map((v) => \`\${v.name}: \${v.value};\`).join(" ")} }\`
113
+ : "";
114
+
115
+ const colorSchemeCss = colorScheme && colorScheme !== "any"
116
+ ? \`:root { color-scheme: \${colorScheme}; }\`
117
+ : "";
118
+
119
+ const css = [cssVars, colorSchemeCss].filter(Boolean).join("\\n");
120
+
121
+ return (
122
+ <>
123
+ {fonts?.map((font) =>
124
+ font.styleSheet ? (
125
+ <link key={font.family} rel="stylesheet" href={font.styleSheet} />
126
+ ) : null
127
+ )}
128
+ {css && <style dangerouslySetInnerHTML={{ __html: css }} />}
129
+ </>
130
+ );
131
+ }
132
+
133
+ export { type Font as SiteThemeFont };
134
+ `;
135
+ }
136
+
137
+ function generateAppCss(ctx: MigrationContext): string {
138
+ const c = ctx.themeColors;
139
+ // Map CMS color names to DaisyUI v5 CSS variables
140
+ const colors: Record<string, string> = {
141
+ "--color-primary": c["primary"] || "#6B21A8",
142
+ "--color-secondary": c["secondary"] || "#141414",
143
+ "--color-accent": c["tertiary"] || "#FFF100",
144
+ "--color-neutral": c["neutral"] || "#393939",
145
+ "--color-base-100": c["base-100"] || "#FFFFFF",
146
+ "--color-base-200": c["base-200"] || "#F3F3F3",
147
+ "--color-base-300": c["base-300"] || "#868686",
148
+ "--color-info": c["info"] || "#006CA1",
149
+ "--color-success": c["success"] || "#007552",
150
+ "--color-warning": c["warning"] || "#F8D13A",
151
+ "--color-error": c["error"] || "#CF040A",
152
+ };
153
+ // Add content colors if specified
154
+ if (c["primary-content"]) colors["--color-primary-content"] = c["primary-content"];
155
+ if (c["secondary-content"]) colors["--color-secondary-content"] = c["secondary-content"];
156
+ if (c["base-content"]) colors["--color-base-content"] = c["base-content"];
157
+
158
+ const colorLines = Object.entries(colors)
159
+ .map(([k, v]) => ` ${k}: ${v};`)
160
+ .join("\n");
161
+
80
162
  return `@import "tailwindcss";
81
163
  @plugin "daisyui";
82
164
  @plugin "daisyui/theme" {
@@ -84,18 +166,7 @@ function generateAppCss(_ctx: MigrationContext): string {
84
166
  default: true;
85
167
  color-scheme: light;
86
168
 
87
- /* TODO: Extract theme colors from the old Theme section's CMS config */
88
- --color-primary: #6B21A8;
89
- --color-secondary: #141414;
90
- --color-accent: #FFF100;
91
- --color-neutral: #393939;
92
- --color-base-100: #FFFFFF;
93
- --color-base-200: #F3F3F3;
94
- --color-base-300: #868686;
95
- --color-info: #006CA1;
96
- --color-success: #007552;
97
- --color-warning: #F8D13A;
98
- --color-error: #CF040A;
169
+ ${colorLines}
99
170
  }
100
171
 
101
172
  @theme {
@@ -104,7 +175,7 @@ function generateAppCss(_ctx: MigrationContext): string {
104
175
  --color-black: #000;
105
176
  --color-transparent: transparent;
106
177
  --color-current: currentColor;
107
- --color-inherit: inherit;
178
+ --color-inherit: inherit;${ctx.fontFamily ? `\n --font-sans: "${ctx.fontFamily}", ui-sans-serif, system-ui, sans-serif;` : ""}
108
179
  }
109
180
 
110
181
  /* View transitions */
@@ -230,6 +230,22 @@ const checks: Check[] = [
230
230
  return true;
231
231
  },
232
232
  },
233
+ {
234
+ name: "No negative z-index on non-image elements",
235
+ severity: "warning",
236
+ fn: (ctx) => {
237
+ const srcDir = path.join(ctx.sourceDir, "src");
238
+ if (!fs.existsSync(srcDir)) return true;
239
+ // Find -z-{n} that are NOT on img/Image elements (those are auto-fixed to z-0)
240
+ const bad = findFilesWithPattern(srcDir, /(?<!<(?:img|Image)[^>]*)-z-\d+/);
241
+ if (bad.length > 0) {
242
+ console.log(` Negative z-index on non-image elements: ${bad.join(", ")}`);
243
+ console.log(` These may be invisible due to stacking contexts. Replace with z-0 or positive z-index.`);
244
+ return false;
245
+ }
246
+ return true;
247
+ },
248
+ },
233
249
  {
234
250
  name: "No imports to deleted static files",
235
251
  severity: "error",
@@ -147,7 +147,10 @@ ${gtmScript}
147
147
  return (
148
148
  <html lang="pt-BR" data-theme="light" suppressHydrationWarning>
149
149
  <head>
150
- <HeadContent />
150
+ <HeadContent />${ctx.fontFamily ? `
151
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
152
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
153
+ <link href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(ctx.fontFamily)}:wght@400;500;600;700&display=swap" rel="stylesheet" />` : ""}
151
154
  </head>
152
155
  <body className="bg-base-200 text-base-content" suppressHydrationWarning>
153
156
  <script dangerouslySetInnerHTML={{ __html: DECO_EVENTS_BOOTSTRAP }} />
@@ -1,6 +1,36 @@
1
1
  import type { MigrationContext } from "../types.ts";
2
2
 
3
- export function generateSetup(_ctx: MigrationContext): string {
3
+ export function generateSetup(ctx: MigrationContext): string {
4
+ // Detect layout sections (Header, Footer, Theme) from source files
5
+ const layoutSections: string[] = [];
6
+ for (const f of ctx.files) {
7
+ if (f.category !== "section" || f.action === "delete") continue;
8
+ const name = f.path.replace(/^sections\//, "").replace(/\.tsx$/, "");
9
+ const lower = name.toLowerCase();
10
+ if (lower.includes("header") || lower.includes("footer") || lower.includes("theme")) {
11
+ layoutSections.push(`site/sections/${name}.tsx`);
12
+ }
13
+ }
14
+ // Also check islands that became sections
15
+ for (const f of ctx.files) {
16
+ if (f.category !== "island") continue;
17
+ const name = f.path.replace(/^islands\//, "").replace(/\.tsx$/, "");
18
+ const lower = name.toLowerCase();
19
+ if (lower.includes("header") || lower.includes("footer") || lower.includes("theme")) {
20
+ layoutSections.push(`site/sections/${name}.tsx`);
21
+ }
22
+ }
23
+
24
+ const layoutRegistration = layoutSections.length > 0
25
+ ? `\n// -- Layout Sections (cached across navigations) --
26
+ registerLayoutSections([
27
+ ${layoutSections.map((s) => ` "${s}",`).join("\n")}
28
+ ]);\n`
29
+ : "";
30
+
31
+ const layoutImport = layoutSections.length > 0
32
+ ? "\n registerLayoutSections," : "";
33
+
4
34
  return `/**
5
35
  * Site setup — registers all sections, loaders and matchers with the CMS.
6
36
  *
@@ -9,7 +39,7 @@ export function generateSetup(_ctx: MigrationContext): string {
9
39
  */
10
40
  import { blocks as generatedBlocks } from "./server/cms/blocks.gen";
11
41
  import {
12
- registerSections,
42
+ registerSections,${layoutImport}
13
43
  setBlocks,
14
44
  } from "@decocms/start/cms";
15
45
  import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
@@ -28,7 +58,7 @@ for (const [path, loader] of Object.entries(sectionGlob)) {
28
58
  sections["site/" + path.slice(2)] = loader;
29
59
  }
30
60
  registerSections(sections);
31
-
61
+ ${layoutRegistration}
32
62
  // -- Matchers --
33
63
  registerBuiltinMatchers();
34
64
  `;
@@ -29,6 +29,7 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
29
29
  [/^"apps\/website\/components\/Image\.tsx"$/, `"@decocms/apps/commerce/components/Image"`],
30
30
  [/^"apps\/website\/components\/Picture\.tsx"$/, `"@decocms/apps/commerce/components/Picture"`],
31
31
  [/^"apps\/website\/components\/Video\.tsx"$/, `"@decocms/apps/commerce/components/Video"`],
32
+ [/^"apps\/website\/components\/Theme\.tsx"$/, `"~/components/ui/Theme"`],
32
33
  [/^"apps\/commerce\/types\.ts"$/, `"@decocms/apps/commerce/types"`],
33
34
 
34
35
  // Apps — catch-all (things like apps/website/mod.ts, apps/vtex/mod.ts, etc.)
@@ -312,6 +312,52 @@ function hasOrderIssues(classes: string[]): boolean {
312
312
  return false;
313
313
  }
314
314
 
315
+ // ── Fix opacity modifier classes within a class list ────────────
316
+ // Handles non-adjacent cases like: "bg-black/50 flex ... hover:bg-opacity-30"
317
+ // Finds the base color class and merges the opacity into it.
318
+ function fixOrphanedOpacity(classList: string[]): { result: string[]; changes: string[] } {
319
+ const changes: string[] = [];
320
+ const prefixes = ["bg", "text", "border", "ring", "divide", "placeholder"];
321
+
322
+ // Find base color classes: bg-{color} or bg-{color}/{opacity}
323
+ const colorClasses: Record<string, { color: string; prefix: string }> = {};
324
+ for (const cls of classList) {
325
+ for (const pfx of prefixes) {
326
+ const match = cls.match(new RegExp(`^${pfx}-(\\w[\\w-]*?)(?:\\/(\\d+))?$`));
327
+ if (match && match[1] !== "opacity") {
328
+ colorClasses[pfx] = { color: match[1], prefix: pfx };
329
+ }
330
+ }
331
+ }
332
+
333
+ // Replace orphaned opacity classes with proper merged versions
334
+ const result: string[] = [];
335
+ for (const cls of classList) {
336
+ const opMatch = cls.match(/^((?:hover:|focus:|active:)*)(\w+)-opacity-(\d+)$/);
337
+ if (!opMatch) {
338
+ result.push(cls);
339
+ continue;
340
+ }
341
+
342
+ const modifier = opMatch[1]; // "hover:" or ""
343
+ const prefix = opMatch[2]; // "bg", "text", etc.
344
+ const opacity = opMatch[3]; // "20", "50", etc.
345
+
346
+ const base = colorClasses[prefix];
347
+ if (!base) {
348
+ result.push(cls); // No base color found, keep as-is
349
+ continue;
350
+ }
351
+
352
+ const opacityStr = opacity === "100" ? "" : `/${opacity}`;
353
+ const replacement = `${modifier}${prefix}-${base.color}${opacityStr}`;
354
+ result.push(replacement);
355
+ changes.push(`Merged ${cls} → ${replacement}`);
356
+ }
357
+
358
+ return { result, changes };
359
+ }
360
+
315
361
  // ── Fix a className string ──────────────────────────────────────
316
362
  function fixClassNameString(classes: string): { fixed: string; changes: string[] } {
317
363
  const changes: string[] = [];
@@ -340,7 +386,14 @@ function fixClassNameString(classes: string): { fixed: string; changes: string[]
340
386
  return fixed;
341
387
  });
342
388
 
343
- // 3. Fix responsive ordering
389
+ // 3. Fix orphaned opacity classes (non-adjacent to color class)
390
+ const opacityFix = fixOrphanedOpacity(classList);
391
+ if (opacityFix.changes.length > 0) {
392
+ classList = opacityFix.result;
393
+ changes.push(...opacityFix.changes);
394
+ }
395
+
396
+ // 4. Fix responsive ordering
344
397
  if (hasOrderIssues(classList)) {
345
398
  const reordered = fixResponsiveOrder(classList);
346
399
  if (reordered.join(" ") !== classList.join(" ")) {
@@ -352,6 +405,213 @@ function fixClassNameString(classes: string): { fixed: string; changes: string[]
352
405
  return { fixed: classList.join(" "), changes };
353
406
  }
354
407
 
408
+ // ── Fix negative z-index on background images ──────────────────
409
+ // In Tailwind v3, `-z-10` on an absolute image inside a relative parent worked
410
+ // to push the image behind the parent's content. In Tailwind v4 + React,
411
+ // stacking contexts from wrappers (section elements, animation, etc.) can trap
412
+ // the negative z-index, making the image invisible.
413
+ // Fix: replace `-z-{n}` with `z-0` on images. Since the image comes first in DOM,
414
+ // content siblings (which have z-index: auto) render on top naturally.
415
+ const NEG_Z_ON_IMAGE_REGEX = /\b-z-\d+\b/g;
416
+
417
+ function fixNegativeZIndex(content: string): { content: string; changed: boolean; notes: string[] } {
418
+ const notes: string[] = [];
419
+ let changed = false;
420
+ let result = content;
421
+
422
+ // Step 1: Replace -z-{n} with z-0 on img/Image elements + add inset-0
423
+ result = result.replace(
424
+ /<(?:img|Image)\b[\s\S]*?(?:\/>|>)/g,
425
+ (tag) => {
426
+ if (!/-z-\d+/.test(tag)) return tag;
427
+ let fixed = tag.replace(/(?<=\s|"|`)-z-(\d+)\b/g, (m) => {
428
+ changed = true;
429
+ notes.push(`Background image: replaced ${m} with z-0`);
430
+ return "z-0";
431
+ });
432
+ // Add inset-0 if not present (ensures absolute image covers parent)
433
+ if (fixed.includes("absolute") && !fixed.includes("inset-0")) {
434
+ fixed = fixed.replace(/\babsolute\b/, "absolute inset-0");
435
+ notes.push("Added inset-0 to absolute background image");
436
+ }
437
+ return fixed;
438
+ },
439
+ );
440
+
441
+ // Step 2: When parent div has backgroundColor inline style + child img with z-0,
442
+ // extract backgroundColor into a separate overlay div and bump content to z-20.
443
+ // Use a simple two-pass approach to avoid JSX nesting issues:
444
+ // a) Extract and remove backgroundColor from parent div
445
+ // b) Insert overlay div before the content div (after the image conditional block)
446
+ if (changed && /style=\{\{[^}]*backgroundColor/.test(result)) {
447
+ const bgMatch = result.match(/style=\{\{\s*backgroundColor:\s*"([^"]+)"\s*\}\}/);
448
+ if (bgMatch) {
449
+ const bgValue = bgMatch[1];
450
+
451
+ // Remove the style attribute from the parent div
452
+ result = result.replace(/\s*style=\{\{\s*backgroundColor:\s*"[^"]+"\s*\}\}/, "");
453
+
454
+ // Insert overlay div. Find the closing of the image conditional block:
455
+ // Pattern: )} followed by whitespace then <div
456
+ // This handles {imageBg && (<Image ... />)} <div content>
457
+ let insertedOverlay = false;
458
+
459
+ // Try pattern: )} then <div (conditional image)
460
+ if (/\)\}\s*\n\s*<div/.test(result)) {
461
+ result = result.replace(
462
+ /(\)\})([\s\n]*)(<div\b)/,
463
+ (m, closing, ws, divTag) => {
464
+ if (insertedOverlay) return m;
465
+ insertedOverlay = true;
466
+ return `${closing}${ws}{/* Overlay */}\n <div className="absolute inset-0 z-10" style={{ backgroundColor: "${bgValue}" }} />${ws}${divTag}`;
467
+ },
468
+ );
469
+ }
470
+
471
+ // Try pattern: /> then <div (direct image, no conditional)
472
+ if (!insertedOverlay && /\/>\s*\n\s*<div/.test(result)) {
473
+ result = result.replace(
474
+ /(\/>\s*\n)([\s]*)(<div\b)/,
475
+ (m, closing, ws, divTag) => {
476
+ if (insertedOverlay) return m;
477
+ insertedOverlay = true;
478
+ return `${closing}${ws}{/* Overlay */}\n${ws}<div className="absolute inset-0 z-10" style={{ backgroundColor: "${bgValue}" }} />\n${ws}${divTag}`;
479
+ },
480
+ );
481
+ }
482
+
483
+ if (insertedOverlay) {
484
+ // Bump the first content div after the overlay to z-20.
485
+ // Find the overlay marker, then the next className= string after it.
486
+ const overlayMarker = `style={{ backgroundColor: "${bgValue}" }} />`;
487
+ const overlayIdx = result.indexOf(overlayMarker);
488
+ if (overlayIdx !== -1) {
489
+ const afterOverlay = result.substring(overlayIdx + overlayMarker.length);
490
+ // Find first className= after overlay (handles both className="..." and className={clx("...")})
491
+ const classMatch = afterOverlay.match(/className=(?:\{clx\(\s*)?[""`]([^""`]*)/);
492
+ if (classMatch && classMatch[1] && !/z-\d+/.test(classMatch[1])) {
493
+ const originalClass = classMatch[1];
494
+ const fixedClass = `relative z-20 ${originalClass}`;
495
+ // Replace only the first occurrence after the overlay
496
+ const beforeOverlay = result.substring(0, overlayIdx + overlayMarker.length);
497
+ const fixed = afterOverlay.replace(originalClass, fixedClass);
498
+ result = beforeOverlay + fixed;
499
+ }
500
+ }
501
+ notes.push(`Extracted backgroundColor overlay: ${bgValue}`);
502
+ }
503
+ }
504
+ }
505
+
506
+ // Step 3: For content divs that are siblings of z-0 images,
507
+ // add `relative z-10` so content renders above the background image.
508
+ // Uses a line-by-line approach to avoid regex issues with JSX nesting.
509
+ if (changed) {
510
+ const lines = result.split("\n");
511
+ let foundZ0Image = false;
512
+ let needsContentZIndex = false;
513
+
514
+ for (let i = 0; i < lines.length; i++) {
515
+ const line = lines[i];
516
+
517
+ // Detect start of an image element (may span multiple lines)
518
+ if (/<(?:img|Image)\b/.test(line)) {
519
+ foundZ0Image = true;
520
+ if (/\bz-0\b/.test(line)) {
521
+ needsContentZIndex = true;
522
+ }
523
+ continue;
524
+ }
525
+ // Detect z-0 on a subsequent line of the image element
526
+ if (foundZ0Image && !needsContentZIndex && /\bz-0\b/.test(line)) {
527
+ needsContentZIndex = true;
528
+ continue;
529
+ }
530
+ // Detect end of multi-line image element (self-closing />)
531
+ if (foundZ0Image && !needsContentZIndex && /\/>/.test(line)) {
532
+ // Image closed without z-0, reset
533
+ foundZ0Image = false;
534
+ continue;
535
+ }
536
+
537
+ // When we hit the closing of an Image block, start looking for content div
538
+ if (foundZ0Image && /\)\}/.test(line)) {
539
+ // The conditional image block closed, content div should be next
540
+ continue;
541
+ }
542
+
543
+ // Skip overlay divs we inserted
544
+ if (/Overlay/.test(line)) continue;
545
+ if (/absolute inset-0 z-10/.test(line)) continue;
546
+
547
+ // Find the first content div after the image
548
+ if (needsContentZIndex && /^\s*<div\b/.test(line)) {
549
+ // Check if this div already has a z-index
550
+ if (/z-\d+/.test(line)) {
551
+ needsContentZIndex = false;
552
+ foundZ0Image = false;
553
+ continue;
554
+ }
555
+ // Also check the next few lines for z-index (multi-line className)
556
+ let hasZ = false;
557
+ for (let j = i; j < Math.min(i + 5, lines.length); j++) {
558
+ if (/z-\d+/.test(lines[j])) { hasZ = true; break; }
559
+ if (/>/.test(lines[j]) && j !== i) break;
560
+ }
561
+ if (hasZ) {
562
+ needsContentZIndex = false;
563
+ foundZ0Image = false;
564
+ continue;
565
+ }
566
+
567
+ // Add relative z-10 to the className — may be on this line or a subsequent one
568
+ let classLine = i;
569
+ for (let k = i; k < Math.min(i + 4, lines.length); k++) {
570
+ if (/className=/.test(lines[k])) {
571
+ classLine = k;
572
+ break;
573
+ }
574
+ }
575
+
576
+ if (/className="/.test(lines[classLine])) {
577
+ lines[classLine] = lines[classLine].replace(/className="/, 'className="relative z-10 ');
578
+ notes.push("Added relative z-10 to content div sibling of background image");
579
+ } else if (/className=\{clx\(/.test(lines[classLine])) {
580
+ // clx( may have its first string on the same line or the next
581
+ if (/className=\{clx\(\s*"/.test(lines[classLine])) {
582
+ lines[classLine] = lines[classLine].replace(/className=\{clx\(\s*"/, 'className={clx("relative z-10 ');
583
+ } else {
584
+ // First string argument is on the next line
585
+ for (let k = classLine + 1; k < Math.min(classLine + 3, lines.length); k++) {
586
+ if (/^\s*"/.test(lines[k])) {
587
+ lines[k] = lines[k].replace(/^(\s*)"/, '$1"relative z-10 ');
588
+ break;
589
+ }
590
+ }
591
+ }
592
+ notes.push("Added relative z-10 to content div sibling of background image");
593
+ } else if (/className=\{`/.test(lines[classLine])) {
594
+ lines[classLine] = lines[classLine].replace(/className=\{`/, 'className={`relative z-10 ');
595
+ notes.push("Added relative z-10 to content div sibling of background image");
596
+ }
597
+
598
+ needsContentZIndex = false;
599
+ foundZ0Image = false;
600
+ }
601
+ }
602
+
603
+ result = lines.join("\n");
604
+ }
605
+
606
+ // Step 4: Flag remaining -z-{n} on non-image elements for manual review
607
+ const remainingNegZ = result.match(/(?<=\s|"|`)-z-\d+/g);
608
+ if (remainingNegZ) {
609
+ notes.push(`MANUAL: ${remainingNegZ.length} remaining negative z-index usage(s) — may need manual fix for stacking context issues`);
610
+ }
611
+
612
+ return { content: result, changed, notes };
613
+ }
614
+
355
615
  /**
356
616
  * Transform Tailwind classes in a file.
357
617
  *
@@ -366,6 +626,76 @@ export function transformTailwind(content: string): TransformResult {
366
626
  let changed = false;
367
627
  let result = content;
368
628
 
629
+ // ── Fix negative z-index on background images ──────────────────
630
+ const zFix = fixNegativeZIndex(result);
631
+ if (zFix.changed) {
632
+ result = zFix.content;
633
+ changed = true;
634
+ notes.push(...zFix.notes);
635
+ }
636
+
637
+ // ── Fix opacity utility pattern (Tailwind v4 breaking change) ──
638
+ // bg-black bg-opacity-20 → bg-black/20
639
+ // border-white border-opacity-20 → border-white/20
640
+ // text-gray-600 text-opacity-50 → text-gray-600/50
641
+ //
642
+ // The pattern is: {prefix}-{color} {prefix}-opacity-{N} → {prefix}-{color}/{N}
643
+ // prefix can be: bg, text, border, ring, divide, placeholder
644
+ if (/(?:bg|text|border|ring|divide|placeholder)-opacity-\d+/.test(result)) {
645
+ // Match: bg-{color} bg-opacity-{N}
646
+ result = result.replace(
647
+ /\b(bg-[\w-]+?)\s+bg-opacity-(\d+)/g,
648
+ "$1/$2",
649
+ );
650
+ // Match: text-{color} text-opacity-{N}
651
+ result = result.replace(
652
+ /\b(text-[\w-]+?)\s+text-opacity-(\d+)/g,
653
+ "$1/$2",
654
+ );
655
+ // Match: border-{color} border-opacity-{N}
656
+ result = result.replace(
657
+ /\b(border-[\w-]+?)\s+border-opacity-(\d+)/g,
658
+ "$1/$2",
659
+ );
660
+ // Match: ring-{color} ring-opacity-{N}
661
+ result = result.replace(
662
+ /\b(ring-[\w-]+?)\s+ring-opacity-(\d+)/g,
663
+ "$1/$2",
664
+ );
665
+ // Match: divide-{color} divide-opacity-{N}
666
+ result = result.replace(
667
+ /\b(divide-[\w-]+?)\s+divide-opacity-(\d+)/g,
668
+ "$1/$2",
669
+ );
670
+ // Match: placeholder-{color} placeholder-opacity-{N}
671
+ result = result.replace(
672
+ /\b(placeholder-[\w-]+?)\s+placeholder-opacity-(\d+)/g,
673
+ "$1/$2",
674
+ );
675
+ // Handle hover:/focus:/active: prefixed opacity (e.g. hover:bg-opacity-100)
676
+ result = result.replace(
677
+ /\b((?:hover:|focus:|active:)(?:bg|text|border|ring)-[\w-]+?)\s+(?:hover:|focus:|active:)(?:bg|text|border|ring)-opacity-(\d+)/g,
678
+ "$1/$2",
679
+ );
680
+ // Handle hover:bg-opacity-N when bg-{color}/{N} already exists
681
+ // e.g. "bg-white/80 hover:bg-opacity-100" → "bg-white/80 hover:bg-white"
682
+ // e.g. "bg-black/60 hover:bg-opacity-50" → "bg-black/60 hover:bg-black/50"
683
+ result = result.replace(
684
+ /\b(bg|text|border|ring)-([\w-]+?)\/(\d+)\s+((?:hover:|focus:|active:)+)\1-opacity-(\d+)/g,
685
+ (_m, prefix, color, _baseOp, modifier, hoverOp) => {
686
+ const opacityStr = hoverOp === "100" ? "" : `/${hoverOp}`;
687
+ return `${prefix}-${color}/${_baseOp} ${modifier}${prefix}-${color}${opacityStr}`;
688
+ },
689
+ );
690
+
691
+ // Handle standalone orphaned opacity classes that weren't caught
692
+ if (/(?:bg|text|border|ring)-opacity-\d+/.test(result)) {
693
+ notes.push("MANUAL: Some *-opacity-N classes remain — color class may not be adjacent");
694
+ }
695
+ changed = true;
696
+ notes.push("Converted *-opacity-N to modifier syntax (e.g. bg-black/20)");
697
+ }
698
+
369
699
  // Match className="...", className={`...`}, class="..."
370
700
  const patterns = [
371
701
  /(?<=className\s*=\s*")([^"]+)(?=")/g,
@@ -75,6 +75,11 @@ export interface MigrationContext {
75
75
  /** npm dependencies discovered from inline npm: imports in source files */
76
76
  discoveredNpmDeps: Record<string, string>;
77
77
 
78
+ /** Theme colors extracted from .deco/blocks CMS config */
79
+ themeColors: Record<string, string>;
80
+ /** Font family from CMS config */
81
+ fontFamily: string | null;
82
+
78
83
  /** All categorized source files */
79
84
  files: FileRecord[];
80
85
 
@@ -118,6 +123,8 @@ export function createContext(
118
123
  gtmId: null,
119
124
  importMap: {},
120
125
  discoveredNpmDeps: {},
126
+ themeColors: {},
127
+ fontFamily: null,
121
128
  files: [],
122
129
  scaffoldedFiles: [],
123
130
  transformedFiles: [],