CoursePrimaryAction used the base Button with per-theme color classes that overrode disabled styling. When enrollment was closed, the pricing CTA appeared correctly muted while the hero Buy Now button still looked active. Switch the hero CTA to LandingButton (same primitive as pricing) and reset theme color overrides when disabled so both CTAs look and behave consistently.
Org + Course landing pages
This folder holds the entire landing-page system: the public org site ({theme}.org) and the per-course landing ({theme}.course), in 10 themes.
Both surfaces share the same nav, hero, footer, tokens, and edit context. A course landing inherits its theme from the org's landingpage.theme — courses don't pick their own.
Folder map
packages/ui/src/custom/org-landing-page/
│
├── types.ts Public types: OrgLandingPageProps, CourseLandingPageProps,
│ CourseLandingPageLabels, OrgLandingPageTheme, …
├── theme-vars-base.ts Type + baseTokenVars for --landing-* CSS variables
├── theme-style.ts Thin registry: aggregates per-theme vars + exports themeStyle()
├── course-tokens-base.ts Type + baseCourseTokens + tokenize() helper
├── course-landing-page.tokens.ts Thin registry: aggregates per-theme courseTokens + exports
│ courseLandingTokens(theme)
├── course-landing-page.helpers.ts alignHeroCtaWithPricing, buildCourseSectionNavItems
├── fixtures.ts mockOrgLandingPageProps, mockCourseLandingPageProps
├── index.ts Public exports
│
├── edit-context.ts LandingSectionKey + setLandingPageEditContext/getLandingPageEditContext
├── editable-section.svelte Wrapper that makes a section click-to-edit when a context is set
├── landing-button.svelte Theme-aware button used by every CTA
├── landing-page-footer.svelte Shared footer, per-theme class tokens
├── landing-page-footer.tokens.ts
│
├── {theme}/ Everything for one theme (minimal, bold, classic, saas, tech,
│ │ studio, corporate, terminal, editorial, vibrant)
│ ├── index.ts re-exports { nav, hero, courseCard, org, course }
│ ├── nav.svelte reused by both org and course
│ ├── hero.svelte reused; course populates with course-shaped hero data
│ ├── course-card.svelte used by the org catalog
│ ├── org.svelte org-landing composer for this theme
│ ├── course.svelte course-landing composer for this theme
│ ├── vars.ts --landing-* CSS variables for this theme
│ └── course-tokens.ts CourseLandingTokens record for this theme
│
└── course-*.svelte Shared body components driven by `variant` + tokens:
course-section-nav, course-social-proof, course-info-blocks, course-curriculum,
course-chips, course-instructor, course-reviews, course-pricing
A theme is one folder
Every artifact for a theme — composer, sub-components, CSS variables, class tokens — lives in {theme}/. To touch one theme you edit one directory. To delete one you rm -rf {theme}/. To duplicate one you copy the folder.
The central registries (theme-style.ts, course-landing-page.tokens.ts, index.ts) only import-and-aggregate from each {theme}/.
Three-layer separation
┌────────────────────────────────────────┐
│ {theme}/course.svelte (orchestration)│ ← composes the theme
│ ── reuses {theme}/nav, {theme}/hero, │
│ landing-page-footer │
└──┬─────────────────────────────────────┘
│ renders, passes variant="…"
▼
┌────────────────────────────────────────┐
│ course-*.svelte (shared body) │ ← one component per section
│ • course-section-nav │ used by all 10 themes
│ • course-social-proof │
│ • course-info-blocks │
│ • course-curriculum │
│ • course-chips │
│ • course-instructor │
│ • course-reviews │
│ • course-pricing │
└──┬─────────────────────────────────────┘
│ reads courseLandingTokens(variant)
▼
┌────────────────────────────────────────┐
│ {theme}/course-tokens.ts (tokens) │ ← per-theme class strings
│ {theme}/vars.ts (vars) │ and CSS variables
└────────────────────────────────────────┘
The composer only orchestrates. Body components only render. Theme variation lives in tokens + CSS vars.
Token contract
Each theme owns its course-tokens.ts exporting courseTokens: CourseLandingTokens via tokenize({...overrides}). The base lives in course-tokens-base.ts. Every body component reads t = courseLandingTokens(variant) and applies {t.sectionShell}, {t.heading}, {t.curriculumModule}, … Anything not overridden falls through to the base.
Tokens-first rule. Body components must not hardcode theme-varying utilities (font-bold, tracking-tight, rounded-2xl, bg-card-soft, …). Anything that differs between themes lives in tokens. Layout-only utilities that are identical everywhere (flex, grid, gap-6, py-12) can sit inline.
Off-palette rule. A course body section must only use surfaces that exist in the theme's org template. Don't introduce bg-section greys to minimal, don't paint card-soft on bold, etc.
Data flow
The dashboard's <CourseLandingPage> (apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte) is the entry point. It:
- Reads the org's chosen theme from
landingpage.theme. - Maps
course + org→CourseLandingPagePropsviabuildCourseLandingPageProps(). Section visibility (metadata.sectionDisplay) is honoured here — hidden sections are stripped frominfo/reviews/instructorbefore rendering. - Lazy-loads the theme bundle via
importThemeBundle(theme)— one bundle per theme; the other nine never download. The bundle exposes{ nav, hero, courseCard, org, course }.
The composer wires nav/hero/footer (theme-specific files) around the shared body components, threading variant={theme} and the same labels prop into each.
Edit context (click-to-edit)
EditableLandingSection sectionKey="…" wraps every editable region. When a parent route calls setLandingPageEditContext({ selectedKey, selectKey, labelFor, iconFor }), clicking a section in the preview calls selectKey(key). The Editor sidebar reads the same key from a bound prop and opens the matching form. Without a context, the wrapper is a transparent pass-through.
Course LandingSectionKey values: header, requirement, description, goals, certificate, curriculum, chips, instructor, reviews, pricing.
Adding a new theme
A theme is one folder. The central registries are the only files outside that folder you touch.
1. Declare the theme key
types.ts:
export type OrgLandingPageTheme =
| 'minimal'
| …
| 'newtheme';
2. Create the folder
newtheme/
├── index.ts
├── nav.svelte
├── hero.svelte
├── course-card.svelte
├── org.svelte
├── course.svelte
├── vars.ts
└── course-tokens.ts
Quickest path: cp -R minimal/ newtheme/ and edit.
3. Fill in each file
-
nav.svelte,hero.svelte,course-card.svelte— the theme's UI personality. Hero composes{@render navigation()}if the theme renders nav inside the hero. -
org.svelte— org landing composer for this theme. -
course.svelte— course landing composer (use any existing theme'scourse.svelteas a template; the body sequence is the same). -
vars.ts— exportsvars: LandingThemeVars. Spread...baseTokenVarsand override the differences. Use raw hex only for genuinely off-palette themes (seeterminal/vars.ts,editorial/vars.ts). -
course-tokens.ts— exportscourseTokens: CourseLandingTokens = tokenize({ ... }). Only override regions that differ from the base. -
index.ts:export { default as nav } from './nav.svelte'; export { default as hero } from './hero.svelte'; export { default as courseCard } from './course-card.svelte'; export { default as org } from './org.svelte'; export { default as course } from './course.svelte';
4. Wire into the central registries
theme-style.ts:
import { vars as newthemeVars } from './newtheme/vars';
…
export const LANDING_THEME_VARS = {
…,
newtheme: newthemeVars
};
course-landing-page.tokens.ts:
import { courseTokens as newthemeTokens } from './newtheme/course-tokens';
…
const TOKENS = {
…,
newtheme: newthemeTokens
};
index.ts:
import * as newtheme from './newtheme';
export const NewthemeLandingPage = newtheme.org;
export const NewthemeCourseLanding = newtheme.course;
export const NewthemeLandingNav = newtheme.nav;
export const NewthemeLandingHero = newtheme.hero;
export const NewthemeLandingCourseCard = newtheme.courseCard;
5. Register in the dashboard utils
apps/dashboard/src/lib/features/org/utils/landing-page.ts:
- Add the key to
landingPageThemes. - Add a
case 'newtheme'toimportThemeBundle(this is the only lazy-load switch —importThemeComponent,importThemeNavHero,importThemeCourseCard, andimportCourseLandingPageThemeare thin adapters over it). - If your hero composes
{@render navigation()}, add the theme key toNAV_SNIPPET_THEMES. - Add an entry to
themeCourseGridClass.
6. Pick body layouts
course-curriculum.sveltehas alayoutByThememap (accordionfor collapsible,chaptersfor permanently expanded). Addnewtheme.- If the info-blocks should use a different layout, do the same in
course-info-blocks.svelte.
7. Storybook
In packages/storybook/src/templates/org-landing-page/:
org-landing-page.stories.svelte— add<NewthemeLandingPage {...mockProps} />.course-landing-page.stories.svelte— add<NewthemeCourseLanding {...mockProps} />.
8. Audit
- Walk the new story top-to-bottom and compare against the org story for the same theme. Surfaces should match.
grep -nE 'font-(black|extrabold|bold|semibold|medium)|tracking-(tight|tighter|wider|widest)|rounded-(none|sm|md|lg|xl|2xl|3xl|full)' course-*.svelte— body components should return no matches.grep -nE 'bg-\[var\(--landing-(bg-section|card-soft|card)\)' newtheme/course-tokens.ts— confirm you only used surfaces present in the org theme.pnpm exec svelte-check --threshold errorfrom the repo root.
That's it — one folder, two registry hooks, two Storybook entries.