vibespot 0.4.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,693 @@
1
+ # React/TypeScript to HubSpot CMS — Conversion Guide
2
+
3
+ This guide documents the process of converting a React/Vite/Tailwind single-page application into native HubSpot CMS modules. It is designed as reusable instructional context for performing this conversion at scale — whether the source is Lovable, v0, Bolt, or any React-based page builder.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Overview](#overview)
10
+ 2. [Architecture Mapping](#architecture-mapping)
11
+ 3. [Module Structure](#module-structure)
12
+ 4. [Field Types & Gotchas](#field-types--gotchas)
13
+ 5. [CSS Conversion](#css-conversion)
14
+ 6. [JavaScript Conversion](#javascript-conversion)
15
+ 7. [Template Creation](#template-creation)
16
+ 8. [Style Tab Integration](#style-tab-integration)
17
+ 9. [Common Pitfalls](#common-pitfalls)
18
+ 10. [Checklist](#checklist)
19
+
20
+ ---
21
+
22
+ ## Overview
23
+
24
+ ### What Gets Converted
25
+
26
+ | React Concept | HubSpot Equivalent |
27
+ |---------------|-------------------|
28
+ | React component | HubSpot module (`.module/` directory) |
29
+ | Props / state | `fields.json` (editable fields) |
30
+ | JSX template | `module.html` (HubL template) |
31
+ | Component CSS | `module.css` (vanilla CSS) |
32
+ | Component JS (hooks, effects) | `module.js` or shared JS file |
33
+ | Tailwind utilities | Vanilla CSS with BEM naming |
34
+ | React Router pages | HubSpot page templates |
35
+ | Context / global state | Theme-level `fields.json` |
36
+ | npm packages (shadcn, Radix, Embla) | Vanilla JS replacements |
37
+
38
+ ### What Gets Skipped
39
+
40
+ - **Cookie consent**: HubSpot provides this natively
41
+ - **Password gates**: Use HubSpot's membership system
42
+ - **Client-side routing**: Not applicable (each page is server-rendered)
43
+ - **Build tooling**: No Vite, Webpack, or bundler needed
44
+ - **Type definitions**: HubL is untyped
45
+
46
+ ---
47
+
48
+ ## Architecture Mapping
49
+
50
+ ### 1:1 Component → Module Mapping
51
+
52
+ Each visual section of the React page becomes one HubSpot module. A typical landing page maps like this:
53
+
54
+ ```
55
+ React Component → HubSpot Module
56
+ ─────────────────────────────────────────────────
57
+ Header.tsx → Header.module/
58
+ HeroSection.tsx → Hero.module/
59
+ FeaturesSection.tsx → Features.module/
60
+ TestimonialsSection.tsx → Testimonials.module/
61
+ PricingSection.tsx → Pricing.module/
62
+ ContactSection.tsx → Contact.module/
63
+ Footer.tsx → Footer.module/
64
+ ```
65
+
66
+ ### Shared Files
67
+
68
+ | File | Purpose |
69
+ |------|---------|
70
+ | `css/<theme>.css` | Shared design system: CSS variables, utilities, animations, form overrides |
71
+ | `js/<theme>-animations.js` | Shared vanilla JS: scroll animations, carousels, accordions, etc. |
72
+ | `templates/<page>.html` | Page template that assembles modules in a DnD area |
73
+
74
+ ---
75
+
76
+ ## Module Structure
77
+
78
+ Each module lives in `modules/<Name>.module/` with these files:
79
+
80
+ ```
81
+ MyModule.module/
82
+ ├── fields.json # Editable content & style fields
83
+ ├── meta.json # Module metadata
84
+ ├── module.html # HubL template (converted from JSX)
85
+ ├── module.css # Module-specific styles (converted from Tailwind)
86
+ └── module.js # Optional: module-specific JavaScript
87
+ ```
88
+
89
+ ### meta.json
90
+
91
+ ```json
92
+ {
93
+ "label": "My Module",
94
+ "css_assets": [],
95
+ "external_js": [],
96
+ "global": false,
97
+ "host_template_types": ["PAGE"],
98
+ "content_types": ["LANDING_PAGE"],
99
+ "is_available_for_new_content": true
100
+ }
101
+ ```
102
+
103
+ - `content_types`: Use `["LANDING_PAGE"]` for landing pages, `["PAGE"]` for website pages, or both.
104
+ - `global`: Set `true` only for modules that appear on every page (rare).
105
+
106
+ ### fields.json
107
+
108
+ This is where React props become HubSpot-editable fields. Every piece of text, image, link, or repeating group that an editor should be able to change goes here.
109
+
110
+ **Converting React props/hardcoded strings to fields:**
111
+
112
+ ```tsx
113
+ // React: hardcoded content
114
+ <h2>Our Features</h2>
115
+ <p>We offer the best solutions for your business.</p>
116
+ ```
117
+
118
+ ```json
119
+ // fields.json
120
+ [
121
+ {
122
+ "name": "headline",
123
+ "label": "Headline",
124
+ "type": "text",
125
+ "default": "Our Features"
126
+ },
127
+ {
128
+ "name": "subtitle",
129
+ "label": "Subtitle",
130
+ "type": "text",
131
+ "default": "We offer the best solutions for your business."
132
+ }
133
+ ]
134
+ ```
135
+
136
+ ```html
137
+ <!-- module.html -->
138
+ <h2>{{ module.headline }}</h2>
139
+ <p>{{ module.subtitle }}</p>
140
+ ```
141
+
142
+ ### Converting Repeating Content (map → repeater group)
143
+
144
+ ```tsx
145
+ // React: array.map()
146
+ {features.map((feature) => (
147
+ <div key={feature.title}>
148
+ <h3>{feature.title}</h3>
149
+ <p>{feature.description}</p>
150
+ </div>
151
+ ))}
152
+ ```
153
+
154
+ ```json
155
+ // fields.json — repeater group
156
+ {
157
+ "name": "features",
158
+ "label": "Features",
159
+ "type": "group",
160
+ "occurrence": {
161
+ "min": 1,
162
+ "max": 10,
163
+ "default": 3
164
+ },
165
+ "default": [
166
+ { "feature_title": "Feature 1", "feature_desc": "Description 1" },
167
+ { "feature_title": "Feature 2", "feature_desc": "Description 2" },
168
+ { "feature_title": "Feature 3", "feature_desc": "Description 3" }
169
+ ],
170
+ "children": [
171
+ {
172
+ "name": "feature_title",
173
+ "label": "Title",
174
+ "type": "text",
175
+ "default": "Feature"
176
+ },
177
+ {
178
+ "name": "feature_desc",
179
+ "label": "Description",
180
+ "type": "text",
181
+ "default": "Description"
182
+ }
183
+ ]
184
+ }
185
+ ```
186
+
187
+ ```html
188
+ <!-- module.html — HubL for loop -->
189
+ {%- for item in module.features -%}
190
+ <div>
191
+ <h3>{{ item.feature_title }}</h3>
192
+ <p>{{ item.feature_desc }}</p>
193
+ </div>
194
+ {%- endfor -%}
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Field Types & Gotchas
200
+
201
+ ### Supported Field Types
202
+
203
+ | HubSpot Type | Use For | React Equivalent |
204
+ |-------------|---------|-----------------|
205
+ | `text` | Single-line or multi-line text | `string` prop |
206
+ | `richtext` | Formatted HTML content | `dangerouslySetInnerHTML` |
207
+ | `image` | Image with src + alt | `<img>` props |
208
+ | `link` | URL with target options | `href` prop |
209
+ | `color` | Color picker with opacity | CSS color values |
210
+ | `choice` | Dropdown/radio selection | Enum prop |
211
+ | `boolean` | Toggle switch | Boolean prop |
212
+ | `number` | Numeric input | Number prop |
213
+ | `group` | Container for child fields | Object prop |
214
+ | `group` + `occurrence` | Repeater (array of items) | Array prop |
215
+ | `form` | HubSpot form selector | Form embed |
216
+
217
+ ### Critical Gotchas
218
+
219
+ | Issue | Error | Fix |
220
+ |-------|-------|-----|
221
+ | `"type": "textarea"` | `'unknown' is not a valid field type` | Use `"type": "text"` instead — `textarea` is deprecated |
222
+ | `"name": "name"` | `missing field name` | `name` is reserved — use `item_name`, `link_label`, etc. |
223
+ | `{% module %}` in module.html | `'module' is disabled in this context` | Cannot nest modules — use `{% form %}` for forms |
224
+ | `{{ now() }}` | `Could not resolve function 'now'` | Use `{{ local_dt }}` for current date/time |
225
+ | Partially uploaded module | Re-upload still fails | Run `hs remove <path>` first, then re-upload |
226
+ | SVG in text field | SVG renders as escaped text | SVG markup in text fields is auto-escaped by HubL |
227
+
228
+ ### Image Fields
229
+
230
+ ```json
231
+ {
232
+ "name": "logo",
233
+ "label": "Logo",
234
+ "type": "image",
235
+ "default": {
236
+ "src": "",
237
+ "alt": "Company Logo"
238
+ }
239
+ }
240
+ ```
241
+
242
+ ```html
243
+ {%- if module.logo.src -%}
244
+ <img src="{{ module.logo.src }}" alt="{{ module.logo.alt }}" />
245
+ {%- else -%}
246
+ <span>Fallback Text</span>
247
+ {%- endif -%}
248
+ ```
249
+
250
+ ### Form Embedding
251
+
252
+ ```json
253
+ {
254
+ "name": "form_field",
255
+ "label": "Form",
256
+ "type": "form",
257
+ "default": {
258
+ "form_id": "your-form-guid-here",
259
+ "portal_id": "your-portal-id"
260
+ }
261
+ }
262
+ ```
263
+
264
+ ```html
265
+ {% form
266
+ form_to_use="{{ module.form_field.form_id }}"
267
+ response_response_type="redirect"
268
+ response_redirect_url=""
269
+ no_title=true
270
+ %}
271
+ ```
272
+
273
+ ---
274
+
275
+ ## CSS Conversion
276
+
277
+ ### From Tailwind to Vanilla CSS
278
+
279
+ 1. **Extract the design system** from `tailwind.config.ts` and `index.css`:
280
+ - Color palette → CSS custom properties (`--prefix-*`)
281
+ - Font families → `@import` Google Fonts + CSS properties
282
+ - Spacing scale → Hardcoded `rem` values
283
+ - Breakpoints → `@media` queries
284
+
285
+ 2. **Convert utility classes to BEM-named classes**:
286
+
287
+ ```tsx
288
+ // React + Tailwind
289
+ <div className="bg-gray-900 rounded-xl p-6 border border-gray-800 hover:shadow-lg transition">
290
+ ```
291
+
292
+ ```css
293
+ /* Vanilla CSS with BEM */
294
+ .my-module__card {
295
+ background: hsl(var(--dark-900));
296
+ border-radius: 1rem;
297
+ padding: 1.5rem;
298
+ border: 1px solid hsl(var(--border));
299
+ transition: box-shadow 0.3s;
300
+ }
301
+ .my-module__card:hover {
302
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
303
+ }
304
+ ```
305
+
306
+ 3. **Prefix ALL classes** with a unique namespace (e.g., `ai-`, `wco-`) to avoid conflicts with the HubSpot theme's existing CSS.
307
+
308
+ ### CSS Load Order in HubSpot
309
+
310
+ The base template loads CSS in this order (each can override the previous):
311
+
312
+ ```
313
+ 1. main.css ← Theme reset, base styles
314
+ 2. style.css ← Theme custom styles
315
+ 3. template_css ← Your shared CSS (e.g., ai-theme.css)
316
+ 4. theme-overrides ← Theme settings (colors, fonts, spacing from theme.json)
317
+ 5. module.css ← Module-specific styles
318
+ ```
319
+
320
+ **Critical**: `theme-overrides.css` loads AFTER your template CSS. It sets:
321
+ - `body { color: ...; font-size: ...; }` — overrides text color
322
+ - `h1, h2, h3... { color: ...; }` — overrides heading colors
323
+ - `.dnd-section { padding: ...; }` — adds section padding
324
+ - `.dnd-section > .row-fluid { max-width: ...; }` — constrains width
325
+ - `a { color: ...; }` — overrides link colors
326
+ - Button, form, table styles
327
+
328
+ **Solution**: Add scoped overrides in your shared CSS:
329
+
330
+ ```css
331
+ /* Override theme-overrides.css within your page scope */
332
+ .my-page h1, .my-page h2, .my-page h3,
333
+ .my-page h4, .my-page h5, .my-page h6 {
334
+ color: hsl(var(--my-fg)) !important;
335
+ font-family: 'My Font', sans-serif !important;
336
+ }
337
+ .my-page p {
338
+ color: hsl(var(--my-fg)) !important;
339
+ }
340
+ .my-page .dnd-section {
341
+ padding: 0 !important;
342
+ }
343
+ .my-page .dnd-section > .row-fluid {
344
+ max-width: 100% !important;
345
+ }
346
+ ```
347
+
348
+ ### Body Background Problem
349
+
350
+ The page wrapper structure is:
351
+ ```html
352
+ <div class="body-wrapper">
353
+ <main class="body-container-wrapper">
354
+ <div class="my-page"> ← your wrapper
355
+ <!-- modules -->
356
+ </div>
357
+ </main>
358
+ </div>
359
+ ```
360
+
361
+ `.my-page` is a CHILD of `.body-wrapper`. CSS cannot target a parent from a child selector. Solutions:
362
+
363
+ ```css
364
+ /* Modern browsers: :has() selector */
365
+ .body-wrapper:has(.my-page) {
366
+ background: #0d1117 !important;
367
+ }
368
+
369
+ /* JS fallback class (added via your animations.js) */
370
+ .body-wrapper.my-page-active {
371
+ background: #0d1117 !important;
372
+ }
373
+ ```
374
+
375
+ ```js
376
+ // In your shared JS
377
+ var page = document.querySelector('.my-page');
378
+ if (page) {
379
+ var wrapper = page.closest('.body-wrapper');
380
+ if (wrapper) wrapper.classList.add('my-page-active');
381
+ }
382
+ ```
383
+
384
+ ---
385
+
386
+ ## JavaScript Conversion
387
+
388
+ ### React Hooks → Vanilla JS
389
+
390
+ | React Pattern | Vanilla JS Replacement |
391
+ |--------------|----------------------|
392
+ | `useEffect` + IntersectionObserver | `IntersectionObserver` in IIFE |
393
+ | `useState` for toggle | `classList.toggle()` |
394
+ | `useRef` | `document.getElementById()` / `querySelector()` |
395
+ | `setTimeout` / `setInterval` in effect | Direct `setTimeout` / `setInterval` |
396
+ | Embla Carousel | Custom carousel with `translateX` |
397
+ | Radix Accordion | Custom accordion with `maxHeight` toggle |
398
+ | Framer Motion | CSS transitions + IntersectionObserver `.visible` class |
399
+
400
+ ### Scroll Animation Pattern
401
+
402
+ ```tsx
403
+ // React hook (useScrollAnimation.ts)
404
+ const ref = useRef<HTMLDivElement>(null);
405
+ useEffect(() => {
406
+ const observer = new IntersectionObserver(([entry]) => {
407
+ if (entry.isIntersecting) setIsVisible(true);
408
+ }, { threshold: 0.1 });
409
+ if (ref.current) observer.observe(ref.current);
410
+ return () => observer.disconnect();
411
+ }, []);
412
+ ```
413
+
414
+ ```js
415
+ // Vanilla JS equivalent
416
+ function initScrollAnimations() {
417
+ var els = document.querySelectorAll('.scroll-animate');
418
+ var observer = new IntersectionObserver(function(entries) {
419
+ entries.forEach(function(entry) {
420
+ if (entry.isIntersecting) {
421
+ entry.target.classList.add('visible');
422
+ observer.unobserve(entry.target);
423
+ }
424
+ });
425
+ }, { threshold: 0.1 });
426
+ els.forEach(function(el) { observer.observe(el); });
427
+ }
428
+ ```
429
+
430
+ ```css
431
+ /* CSS for the animation */
432
+ .scroll-animate {
433
+ opacity: 0;
434
+ transform: translateY(30px);
435
+ transition: opacity 0.6s ease-out, transform 0.6s ease-out;
436
+ }
437
+ .scroll-animate.visible {
438
+ opacity: 1;
439
+ transform: translateY(0);
440
+ }
441
+ ```
442
+
443
+ ### JS Loading in Templates
444
+
445
+ JavaScript must be loaded from the **base template context**, not from child template blocks. HubSpot's `get_asset_url()` resolves paths relative to the file where it's called.
446
+
447
+ ```html
448
+ <!-- WRONG: require_js in child template block resolves relative to child -->
449
+ {% block body %}
450
+ {{ require_js(get_asset_url("../../js/animations.js")) }}
451
+ {% endblock %}
452
+
453
+ <!-- CORRECT: use a variable, resolved in base template -->
454
+ <!-- Child template: -->
455
+ {% set template_js = "../../js/animations.js" %}
456
+
457
+ <!-- Base template (templates/layouts/base.html): -->
458
+ {% if template_js %}
459
+ {{ require_js(get_asset_url(template_js)) }}
460
+ {% endif %}
461
+ ```
462
+
463
+ ---
464
+
465
+ ## Template Creation
466
+
467
+ ### Page Template Structure
468
+
469
+ ```html
470
+ <!--
471
+ templateType: page
472
+ isAvailableForNewContent: true
473
+ label: My Landing Page
474
+ screenshotPath: ../images/template-previews/my-page.png
475
+ -->
476
+ {% extends "./layouts/base.html" %}
477
+
478
+ {% set template_css = "../../css/my-theme.css" %}
479
+ {% set template_js = "../../js/my-animations.js" %}
480
+
481
+ {% block header %}
482
+ {# Custom header module replaces global header #}
483
+ {% endblock header %}
484
+
485
+ {% block body %}
486
+ <div class="my-page">
487
+ {% dnd_area "main_content" label="Main Content" %}
488
+
489
+ {% dnd_section
490
+ padding={"top":"0","bottom":"0","left":"0","right":"0"},
491
+ full_width=true
492
+ %}
493
+ {% dnd_module path="../modules/My Hero.module" %}
494
+ {% end_dnd_module %}
495
+ {% end_dnd_section %}
496
+
497
+ {# Repeat for each section... #}
498
+
499
+ {% end_dnd_area %}
500
+ </div>
501
+ {% endblock body %}
502
+
503
+ {% block footer %}
504
+ {# Custom footer module replaces global footer #}
505
+ {% endblock footer %}
506
+ ```
507
+
508
+ ### DnD Section Rules
509
+
510
+ Every `dnd_section` MUST have:
511
+
512
+ ```
513
+ padding={"top":"0","bottom":"0","left":"0","right":"0"}, full_width=true
514
+ ```
515
+
516
+ Without these:
517
+ - HubSpot applies default padding from `theme-overrides.css`
518
+ - Content is constrained to `max-width` from theme settings
519
+ - Your full-width designs break
520
+
521
+ Do NOT add `dnd_column` or `dnd_row` wrappers — HubSpot creates these automatically:
522
+
523
+ ```html
524
+ <!-- WRONG -->
525
+ {% dnd_section %}
526
+ {% dnd_column %}
527
+ {% dnd_row %}
528
+ {% dnd_module path="..." %}{% end_dnd_module %}
529
+ {% end_dnd_row %}
530
+ {% end_dnd_column %}
531
+ {% end_dnd_section %}
532
+
533
+ <!-- CORRECT -->
534
+ {% dnd_section padding={"top":"0","bottom":"0","left":"0","right":"0"}, full_width=true %}
535
+ {% dnd_module path="..." %}{% end_dnd_module %}
536
+ {% end_dnd_section %}
537
+ ```
538
+
539
+ ---
540
+
541
+ ## Style Tab Integration
542
+
543
+ HubSpot modules have a **Content tab** and a **Style tab** in the page editor. To place color pickers and other styling options in the Style tab:
544
+
545
+ ```json
546
+ {
547
+ "name": "styles",
548
+ "label": "Styles",
549
+ "type": "group",
550
+ "tab": "STYLE",
551
+ "children": [
552
+ {
553
+ "name": "section_bg",
554
+ "label": "Section Background",
555
+ "type": "color",
556
+ "default": { "color": "#0d1117", "opacity": 100 }
557
+ },
558
+ {
559
+ "name": "heading_color",
560
+ "label": "Heading Color",
561
+ "type": "color",
562
+ "default": { "color": "#eef1f5", "opacity": 100 }
563
+ },
564
+ {
565
+ "name": "text_color",
566
+ "label": "Text Color",
567
+ "type": "color",
568
+ "default": { "color": "#8a95a5", "opacity": 100 }
569
+ }
570
+ ]
571
+ }
572
+ ```
573
+
574
+ Apply in module.html with inline styles:
575
+
576
+ ```html
577
+ <section style="background-color: {{ module.styles.section_bg.color }};">
578
+ <h2 style="color: {{ module.styles.heading_color.color }};">{{ module.headline }}</h2>
579
+ <p style="color: {{ module.styles.text_color.color }};">{{ module.subtitle }}</p>
580
+ </section>
581
+ ```
582
+
583
+ For backgrounds with transparency (e.g., glassmorphism cards):
584
+
585
+ ```html
586
+ <div style="background-color: rgba({{ module.styles.card_bg.color|convert_rgb }}, {{ module.styles.card_bg.opacity / 100 }});">
587
+ ```
588
+
589
+ **Key rules:**
590
+ - `"tab": "STYLE"` goes on the **group**, not on individual children
591
+ - Children inherit the tab placement from the parent group
592
+ - Defaults should match your current CSS design so the page looks correct out of the box
593
+ - Inline styles override CSS class styles, giving editors direct control
594
+
595
+ ---
596
+
597
+ ## Common Pitfalls
598
+
599
+ ### 1. Page Appears Empty After Upload
600
+
601
+ **Cause**: JavaScript not loading. Elements with `.scroll-animate` class start at `opacity: 0` and rely on JS to add `.visible`. If JS path is wrong, everything except CSS-animated elements stays invisible.
602
+
603
+ **Fix**: Use the `template_js` variable pattern (see [JS Loading](#js-loading-in-templates)).
604
+
605
+ ### 2. White Page / Light Text Invisible
606
+
607
+ **Cause**: `theme-overrides.css` sets light-theme colors on `body`, headings, and paragraphs. Your dark-theme text becomes invisible on the white body background.
608
+
609
+ **Fix**: Add scoped overrides with `!important` and fix the body-wrapper background (see [CSS Conversion](#css-conversion)).
610
+
611
+ ### 3. Sections Constrained to Narrow Width
612
+
613
+ **Cause**: Missing `padding` and `full_width` on `dnd_section` tags.
614
+
615
+ **Fix**: Add `padding={"top":"0","bottom":"0","left":"0","right":"0"}, full_width=true` to every `dnd_section`.
616
+
617
+ ### 4. Module Upload Fails After Fix
618
+
619
+ **Cause**: Partially uploaded module with invalid `fields.json` is cached on HubSpot.
620
+
621
+ **Fix**: `hs remove my-theme/modules/MyModule.module` then re-upload.
622
+
623
+ ### 5. Repeater Group Content Missing
624
+
625
+ **Cause**: The `default` array in the group doesn't match the `children` structure, or `occurrence.default` is 0.
626
+
627
+ **Fix**: Ensure `default` array items have all child field names, and `occurrence.default` is > 0.
628
+
629
+ ### 6. HubSpot Form Renders with Light Theme
630
+
631
+ **Cause**: HubSpot forms load in iframes with their own styling.
632
+
633
+ **Fix**: Add aggressive CSS overrides scoped to your page wrapper:
634
+ ```css
635
+ .my-page .hs-form-frame input,
636
+ .my-page .hs-form-frame select,
637
+ .my-page .hs-form-frame textarea {
638
+ background-color: #0d1117 !important;
639
+ color: #eef1f5 !important;
640
+ border: 1px solid #2a2f3a !important;
641
+ }
642
+ ```
643
+
644
+ ---
645
+
646
+ ## Checklist
647
+
648
+ ### Per Module
649
+
650
+ - [ ] `meta.json` created with correct `content_types`
651
+ - [ ] `fields.json` created — no `textarea` type, no `name` as field name
652
+ - [ ] All hardcoded React content extracted to field defaults
653
+ - [ ] Repeater groups have `occurrence.default` > 0 and matching `default` array
654
+ - [ ] `module.html` converts JSX to HubL (`{{ module.field }}`, `{% for %}`)
655
+ - [ ] `module.css` converts Tailwind utilities to BEM vanilla CSS
656
+ - [ ] All CSS classes use a unique prefix (e.g., `ai-`, `wco-`)
657
+ - [ ] Style fields wrapped in a `styles` group with `"tab": "STYLE"`
658
+ - [ ] Inline styles applied from `module.styles.*` fields
659
+
660
+ ### Per Template
661
+
662
+ - [ ] Extends `base.html`
663
+ - [ ] Sets `template_css` and `template_js` variables
664
+ - [ ] Empty `{% block header %}` and `{% block footer %}` (if using custom header/footer modules)
665
+ - [ ] All `dnd_section` tags have `padding` zeroed and `full_width=true`
666
+ - [ ] No `dnd_column`/`dnd_row` wrappers
667
+ - [ ] Wrapper div with page-specific class (e.g., `.my-page`)
668
+
669
+ ### Shared CSS
670
+
671
+ - [ ] CSS custom properties for the design system
672
+ - [ ] Scoped overrides to defeat `theme-overrides.css`
673
+ - [ ] Body-wrapper background fix (`:has()` + JS fallback)
674
+ - [ ] HubSpot form dark theme overrides (if applicable)
675
+ - [ ] Mobile performance rules (disable `backdrop-filter`, reduce blur)
676
+
677
+ ### Shared JS
678
+
679
+ - [ ] Scroll animations (IntersectionObserver)
680
+ - [ ] Body-wrapper class fallback
681
+ - [ ] Any interactive features (carousel, accordion, typing animation, etc.)
682
+ - [ ] `DOMContentLoaded` / readyState check for initialization
683
+
684
+ ### Upload & Test
685
+
686
+ - [ ] `hs cms upload` succeeds for all modules
687
+ - [ ] Template uploads without errors
688
+ - [ ] New page created from template shows all sections
689
+ - [ ] Scroll animations trigger on scroll
690
+ - [ ] Interactive features work (carousel, accordion, etc.)
691
+ - [ ] Style tab fields appear and change colors
692
+ - [ ] Mobile responsive layout works
693
+ - [ ] HubSpot form submits correctly (if applicable)