| name | add-landing-template |
| description | Add a new org landing-page template to the ClassroomIO monorepo. Use when the user asks to "create/add a new landing template", "add a new template like X to the org landing pages", "build a new theme for the org landing page", or hands over a design reference (image, URL, prototype) for a new landing visual style. |
Add a new org landing-page template
Adds one new theme (e.g. editorial, vibrant) to the org landing-page system. The work spans three packages — prototypes/, packages/ui/ + packages/storybook/, and apps/dashboard/ — and must be done in three phases with approval gates between them. Do not skip ahead.
Workflow
Use AskUserQuestion to gate each phase transition. The user must confirm before you move from Phase 1 → 2 → 3. If the user types a custom answer instead of selecting an option, treat any negative response as "not yet" and iterate within the current phase.
Track the work with TaskCreate/TaskUpdate — one task per numbered step below so progress is visible.
Phase 1 — Prototype (approval required)
Goal: lock the visual design before any framework code.
- Pick a short, descriptive name for the template (
editorial, vibrant, terminal, etc.). Avoid generic adjectives.
- Build a single-file HTML mockup at
prototypes/org-landing-page/<theme>.html. Match the conventions of the other prototypes there (Inter font, inline CSS in <style>, no JS frameworks).
- Color discipline: define one
--primary CSS variable for the accent. Derived shades use color-mix(in oklab, var(--primary) X%, ...). Other neutrals (bg, fg, borders, cards) live as CSS variables too. Never hardcode hex inline — the Phase-2 port to var(--landing-*) becomes mechanical.
- Compose the page sections an org landing page actually needs: nav, hero, course catalog grid (this is the centerpiece — orgs sell courses), resources/links, FAQ or callout, footer. Do not invent sections without a data source in
OrgLandingPageProps (no per-org testimonials, no faculty cards, no instructor bios — they have no prop backing and will be misleading).
- Open it in the browser. Iterate with the user on typography, spacing, color, hierarchy, layout.
Phase 1 approval gate — call AskUserQuestion:
Question: "Phase 1 complete — the prototype at prototypes/org-landing-page/<theme>.html matches your direction. Move on to Phase 2 (build the template in packages/ui + Storybook)?"
Options: "Yes, proceed to Phase 2" / "Not yet — keep iterating on the prototype"
Header: "Phase 1 → 2"
If the user picks "not yet" or writes a correction, stay in Phase 1 and iterate.
Phase 2 — packages/ui + Storybook (approval required)
Goal: the template renders correctly in Storybook with the shared mockOrgLandingPageProps fixture, fully isolated from dashboard.
-
Add '<theme>' to OrgLandingPageTheme in packages/ui/src/custom/org-landing-page/types.ts.
-
Add the theme's entry to LANDING_THEME_VARS in packages/ui/src/custom/org-landing-page/theme-style.ts. The 20 variables fall into two groups:
- Surfaces —
--landing-bg, --landing-bg-section, --landing-card, --landing-card-soft, --landing-fg, --landing-fg-muted, --landing-fg-faint, --landing-border, --landing-border-soft, --landing-accent, --landing-accent-fg
- Buttons — primary/secondary/tertiary with
-bg, -fg, -bg-hover, plus -border on secondary
If the theme shares the app's chrome (light bg, dark fg, app primary): use baseTokenVars and override only what differs. If the theme has its own palette (cream like editorial, dark like terminal): declare all hex/values explicitly. Decide what each button tier means per theme — for vibrant, primary = accent; for editorial, primary = fg-inverse (dark on cream); for terminal, primary = light pill on dark.
-
Build three subcomponents in packages/ui/src/custom/org-landing-page/<theme>/:
nav.svelte
hero.svelte — defensive defaults are required: courses = [], orgName = '', labels optional. These heroes are mounted from course pages that won't pass the full prop set.
course-card.svelte — accepts course, optional disableCourseLinks, optional labels. Used both inside the template's own catalog grid AND on the /courses listing page.
Color rule: read every color from var(--landing-*). The only allowed inline hex is decorative (painterly gradients, Mac traffic-light dots, dark IDE mockup overlays) where the value is intentionally invariant of theme. No bg-background, text-foreground, bg-primary, etc. — those are app-theme tokens, not landing-theme tokens.
Text rule: hardcoded user-facing strings route through labels?.foo ?? 'English default'. See the existing OrgLandingPageLabels fields in types.ts.
Button rule: for any CTA that isn't the template's signature primary CTA, use <LandingButton variant="primary|secondary|tertiary"> from @cio/ui/custom/org-landing-page — it automatically adopts the theme's button palette.
-
Build the top-level composer packages/ui/src/custom/org-landing-page/<theme>.svelte:
- Wrap the root in
style={themeStyle('<theme>')} plus class="ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)]"
- Render in order:
<ThemeNav> (or as a snippet inside <ThemeHero>) → <ThemeHero> → catalog section (using <ThemeCourseCard>) → <OrgLandingPageLinks variant="<theme>" {labels} {links} /> → <OrgLandingPageEmbed variant="<theme>" {labels} {embed} /> → <OrgLandingPageCallout variant="<theme>" {labels} {callout} /> → <OrgLandingPageFooter variant="<theme>" {orgName} {logoUrl} {footer} />
-
Wire variant branches into shared section components:
callout.svelte — add entries to all 5 token maps (sectionClasses, headingClasses, descriptionClasses, buttonClasses, eyebrowClasses) and update the defaultEyebrow switch
links.svelte — add an {:else if variant === '<theme>'} branch
landing-page-footer.tokens.ts — add an if (variant === '<theme>') block in getFooterTokens
secondary-action-button.svelte — add an entry to themeButtonClasses
embed.svelte — only add a branch if the theme needs distinct embed styling (terminal/editorial do; most templates fall through to the default)
-
Export from packages/ui/src/custom/org-landing-page/index.ts:
<Theme>LandingPage, <Theme>LandingNav, <Theme>LandingHero, <Theme>LandingCourseCard
-
Add a Storybook story in packages/storybook/src/templates/org-landing-page/org-landing-page.stories.svelte — import the new page component and add <Story name="<Theme>"><<Theme>LandingPage {...mockProps} /></Story>.
-
Verify Phase 2: pnpm --filter @cio/ui build, open Storybook (pnpm --filter @cio/storybook dev), navigate to Templates → Org Landing Page → , click around all sections. Run npx --no-install svelte-check --workspace=packages/ui --no-tsconfig > /tmp/check.log 2>&1 once, then grep org-landing-page /tmp/check.log — zero errors expected in any file under org-landing-page/.
Phase 2 approval gate — call AskUserQuestion:
Question: "Phase 2 complete — the template renders in Storybook. Move on to Phase 3 (wire it into the dashboard so it shows up in org settings + the live org pages)?"
Options: "Yes, proceed to Phase 3" / "Not yet — fix things in Storybook first"
Header: "Phase 2 → 3"
Phase 3 — Dashboard integration (final approval)
Goal: the template selectable in /settings/landingpage, rendering correctly on /, /courses, /course/[slug], and the course editor preview.
-
Add '<theme>' to OrgLandingPageTheme in apps/dashboard/src/lib/utils/types/org.ts.
-
Three lazy switches + one eager map — all four must include the new theme or things will silently fall back to minimal:
apps/dashboard/src/lib/features/org/utils/landing-page.ts:
landingPageThemes const array
importThemeComponent(theme) switch — used by the public org root page
importThemeNavHero(theme) switch — used by /courses, /course/[slug], course-landing editor
importThemeCourseCard(theme) switch — used by /courses per-card rendering
themeCourseGridClass(theme) — only update if the template uses a non-default grid (segmented borders, special gap)
apps/dashboard/src/lib/features/org/utils/landing-page-components.ts:
- All three eager maps:
landingPageThemeComponents, landingPageNavComponents, landingPageHeroComponents
- Critical: this map is only used by the settings-page live preview. The org-site routes use the lazy switches above. Forgetting the lazy ones means /settings looks right but the live page renders minimal.
-
Theme picker in settings: apps/dashboard/src/lib/features/settings/pages/landingpage.svelte → append to themeCards with preview: '/templates/<theme>.png' and translation keys.
-
Translations: apps/dashboard/src/lib/utils/translations/en.json → add settings.landing_page.theme.cards.<theme>.title and .description.
-
Preview image: drop apps/dashboard/static/templates/<theme>.png. Must be under 500KB. If the source is larger, compress with sips -Z 900 source.png --out target.png (resizes to max 900px wide while preserving aspect ratio). JPEGs are acceptable when PNG compression isn't enough — update the themeCards preview path accordingly.
-
Verify Phase 3:
pnpm --filter @cio/ui build
npx --no-install svelte-check --workspace=apps/dashboard --no-tsconfig > /tmp/dash-check.log 2>&1 (run once)
grep -E "(landing-page-components|landingpage\.svelte|types/org\.ts|org/utils/landing-page\.ts)" /tmp/dash-check.log — zero errors expected in any touched file
- Run the dashboard and click through: select the new template in settings, view it on the live org root,
/courses, /course/[slug]. Verify the nav/hero, course cards, Filter aside, Clear Filters button, and footer all render with the right colors on each route.
Phase 3 final approval — call AskUserQuestion:
Question: "Phase 3 complete — the template is live in the dashboard and renders on all org-site routes. Anything else?"
Options: "All good, we're done" / "There's a visual issue to fix"
Header: "Final check"
Anti-checklist (mistakes to prevent)
Read this before starting. These are real bugs that have appeared in this codebase:
- Inline hex in components — the wrapper sets
--landing-* vars; components read from them. Hardcoded hex breaks theme switching and the /courses cross-page rendering. Exception: decorative painterly fills, Mac traffic-light dots, and dark IDE mockup overlays that are intentionally invariant of theme.
- Missing defensive defaults — heroes that consume
courses or orgName must default them (courses = [], orgName = ''). They crash when mounted from course pages that don't pass the full prop set.
- Per-call-site button styling — use
<LandingButton variant="..."> instead. The /courses Filter aside's Clear Filters button is the canonical example.
- Hardcoded user-facing strings — route through
labels?.X ?? 'Default'. The labels surface is consumed by the dashboard's translations layer.
- Only updating one of the dashboard registries —
landingPageThemeComponents (eager, settings preview) and the three importTheme* switches (lazy, public routes) must all include the new theme. Forgetting importThemeComponent makes the live org page silently render minimal.
- Re-running expensive checks —
svelte-check typechecks the whole package on each invocation. Run it once, capture to /tmp/check.log, grep the captured output as many times as you need.
- Adding non-prop-backed sections — testimonials, instructor cards, faculty bios, statistics aren't in
OrgLandingPageProps. Either drop them, or extend the props type for everyone (significant API surface change — discuss before doing).
- Changing the layout pattern when extracting a cell — if the original catalog uses
border-t/border-l on the grid + border-r/border-b on each child (bordered-cell pattern), preserve that pattern. Two failure modes to avoid:
- Don't swap it for
gap-px bg-border — that loses the outer top/left border and breaks visually when there are fewer cards than fill the grid.
- Don't move the right/bottom border from the card to the grid via
[&>*]:border-r [&>*]:border-b. If a <BlurFade> (or any wrapper) sits between the grid and the card, the [&>*]: selector lands on the wrapper, not the card — different render. Keep border-r/border-b ON the card component itself; the parent owns only border-t/border-l.
File-touch summary (~30 files for a typical theme)
| Path | Purpose |
|---|
prototypes/org-landing-page/<theme>.html | Phase 1 design mockup |
packages/ui/src/custom/org-landing-page/types.ts | Theme union |
packages/ui/src/custom/org-landing-page/theme-style.ts | CSS variable contract |
packages/ui/src/custom/org-landing-page/<theme>/nav.svelte | Nav component |
packages/ui/src/custom/org-landing-page/<theme>/hero.svelte | Hero component |
packages/ui/src/custom/org-landing-page/<theme>/course-card.svelte | Card component |
packages/ui/src/custom/org-landing-page/<theme>.svelte | Page composer |
packages/ui/src/custom/org-landing-page/callout.svelte | Variant branch |
packages/ui/src/custom/org-landing-page/links.svelte | Variant branch |
packages/ui/src/custom/org-landing-page/landing-page-footer.tokens.ts | Footer tokens |
packages/ui/src/custom/org-landing-page/secondary-action-button.svelte | Button variant |
packages/ui/src/custom/org-landing-page/embed.svelte | Optional variant branch |
packages/ui/src/custom/org-landing-page/index.ts | Exports |
packages/storybook/src/templates/org-landing-page/org-landing-page.stories.svelte | Storybook story |
apps/dashboard/src/lib/utils/types/org.ts | Dashboard theme union |
apps/dashboard/src/lib/features/org/utils/landing-page.ts | Lazy switches + themes array |
apps/dashboard/src/lib/features/org/utils/landing-page-components.ts | Eager maps (settings preview) |
apps/dashboard/src/lib/features/settings/pages/landingpage.svelte | Theme picker card |
apps/dashboard/src/lib/utils/translations/en.json | Title + description |
apps/dashboard/static/templates/<theme>.png | Preview image (<500KB) |