Skip to content

Module conventions#

Module work on a live theme follows one rule: variant, never restyle. A module upload reaches every page that uses the module within a minute, and modules have no draft state. Additive changes and the regression gate are the only safety net.

In Claude Code

The hubspot-module-change skill of the bb-hubspot plugin loads before Claude edits any module's fields.json, module.html or module.css, and carries the rules on this page in short form.

Variant, never restyle#

  • Additive fields. A change adds a field whose default reproduces the current look exactly.
  • Modifier classes. The template adds a class only when the new field is set.
  • Append-only CSS. New rules are scoped to the modifier class. Existing rules don't change.
  • Never rename, remove or retype a field. Saved instance values are keyed by field name, so a rename silently resets every instance.
  • Bug fixes may change base behaviour, but only after you check live usage (crawl report, catalog counts) and say so in the commit.

Moving a control up a level: the inherit pattern#

When a legacy module's field needs a new home (for example, Theme moving from a nested group to the top of the Style tab):

  1. Add the new field with its default set to inherit.
  2. In module.html, read the new field first and fall back to the old one.
  3. Hide the old field behind a visibility rule that never matches (for example style.preset == __legacy__).

Saved instances render unchanged. Once an editor sets the new field, it wins. On the first site this moved a card module's Theme, Card Columns and Container in place, and the saved values on 87 live instances kept working.

A Style tab convention#

After a few rebuilds it becomes clear that editors need one shape for every Style tab. Write it down as STYLE-TAB.md once two modules exist, make the third rebuild the reference implementation, and add a linter.

# Field / group Holds
1 Preset House arrangements taken from live usage, as overridable defaults. Default custom.
2 Theme One shared list for every module, e.g. solid_white, light, transparent, dark, darker.
3 Text Alignment, title and description sizes, title balance, copy width.
4 Layout The module's own structure. This is the only group that legitimately differs between modules.
5 Media Fit and position. Only in modules with a media slot.
6 Background Source (auto / image / none), image, fit, position, tint, fade.
7 Spacing Inner padding, outer spacing, gap, all on one shared scale.
8 Advanced Only the hidden legacy fields of an upgraded module.

Same concept, same name in every module: theme (not mode), title_size (not heading_level), align (not text_align), container = contained/full (not a remove_container boolean). Labels use the editor's language ("Copy / Media Width"), not the schema's ("aside"). Spacing pickers show Small/Medium/Large but store the theme tokens. Anything that could ever have a third state is a choice, not a boolean.

python3 $BBH/scripts/lint-style-tab.py                  # every adopter (style_tab.adopted); exits 1 on any drift
python3 $BBH/scripts/lint-style-tab.py <client>-hero    # one module

HubSpot field restrictions that shape the convention

text fields are rejected on the Style tab (the upload fails with a bare internal error), so Anchor ID and Custom Class go in an advanced group at the end of the Content tab. Fields named name, label or style are rejected, and items is reserved in HubL. See HubSpot facts.

Presets are defaults, not locks#

Every control a preset owns offers Default (inherit) as its first choice, and can be overridden on its own. The template resolves inherit → preset value → module default. A preset never hides controls: hiding whole groups behind a preset made editors think options were missing, and it failed the editor check within a day.

Presets never write into fields either. A preset's default background is a fallback in the template that applies only while the image field is empty, so switching presets can't leave stale values behind.

Removing a preset breaks saved pages

A page saved with a preset value that later leaves the choice list fails editor validation ("Style, Preset is invalid"). Keep retired values renderable through a legacy table in the template, and re-save the affected pages through the API onto current presets.

Rebuild in parallel, not in place#

Rebuild the high-usage modules as new, prefixed modules (<client>-hero, <client>-cards, <client>-stats, …) instead of restyling the legacy ones. Legacy modules stay untouched, live pages keep working, and new pages use the unified set. Unifying is often the point: on the first site one unified hero replaced three legacy heroes.

The order of work that held up:

  1. Rebuild the simplest module on the reference module's shape. Preview it locally with --only.
  2. Write the convention down once two modules exist. Make the third the reference implementation and add the linter.
  3. Rebuild the high-usage modules as prefixed parallel modules.
  4. Upload each one to dev when the client says go, and publish a showcase page so reviewers can see it in the real editor.
  5. Finish the library with a guide page: every module and its lineage (stays the same / updated / replaced / consolidated), before and after.

Theme assets a module depends on#

Put house assets that a module's defaults need (a closer background render, say) in the theme's images/ folder and reach them with get_asset_url('../images/…'), so the module carries its defaults onto any portal. hs cms upload silently drops .webp, so use jpg, png or svg for Design Manager assets.

Generated modules#

When a module's CSS and JS are produced by a build script (an animation ported from another page by a tools/build-<module>.py script, say), mark the generated files do not hand-edit in CLAUDE.md and give the script a --check mode that fails when they drift.