---
title: "Learner View Branding — Lessons Learned"
description: "A field retrospective on the branding system built and shipped on 2026-09-20: the 7 knobs, the Home tooling, the guest/R&D extension, and everything that bit us along the way. Written the same night, while the scars…"
created: 2026-09-21
updated: 2026-09-21
authors: ThinkingCap R&D
topics: [CapGPT]
status: published
canonical: https://console.thinkingcap.com/rd/CapGPT/Learner-View-Branding-Lessons-Learned
---

# Learner View Branding — Lessons Learned

*A field retrospective on the branding system built and shipped on
2026-09-20: the 7 knobs, the Home tooling, the guest/R&D extension, and
everything that bit us along the way. Written the same night, while the
scars were fresh.*

---

## Product lessons

### 1. Constraint is the feature

The first and best decision was "this isn't a website." Seven knobs — brand
colour, accent, logo, two fonts, corner style, night-mode policy — and the
shell derives *everything else*: hover, contrast-safe on-colours, tints, the
dark palette, the type scale. No custom CSS, ever, for anyone.

**The rule:** every option you add is a way a client can make their app ugly
and a thing you must support forever. The option set is right when every
knob changes how the app *feels* and none can break it. Past that number,
you're building website-thinking into an app.

### 2. Brand as data, not stylesheets

The knobs live as validated **values** in a settings store (`brand.*` keys,
branch-scoped with ancestor inheritance, audited, propagated in minutes) —
never as CSS. Anything that can read seven values can render the brand: our
shell today, an agent host tomorrow.

**The rule:** if the brand can't be serialized to JSON without loss, it isn't
branding — it's a stylesheet trapped in one browser.

### 3. Put the settings store where the tool can write

The earlier design had theme config in the client DB's `Flags.Json` — which
capcom *can't write* (no write-capable deployment DB user). The unblock was
an estate Postgres table (`lv_settings`) that capcom owns. Branch-scoping
(per-branch overrides with client-wide fallback) came free with the table's
key shape.

**The rule:** the authoring UI's write path decides the storage, not the
other way around. A config you can't edit from your own console is a config
edited by SQL at 11pm.

### 4. One plane, many dialogs

Nav, branding, and Home content all ride the *same* table, the same GET/PUT,
the same validator dispatch, the same diff-save dialog pattern, the same
5-minute cache. Each new settings family since has cost a validator + a
dialog + an audit action — nothing else.

**The rule:** build the plane once; every later "can we make X configurable"
is a keys-and-knobs exercise, not a feature.

### 5. Convergence beats configuration

The best branding work is the work you don't repeat. Home renders through
the shell's MUI theme, so `brand.primary` and the fonts themed it with zero
Home-specific code; `brand.logoUrl`/`accent` only fill the slots `home.json`
leaves unset. The login gate and the shell share **one** `buildLvTheme`, so
the very first page a learner sees already wears the brand.

**The rule:** every surface gets its identity from the same resolved
document. The day you hand-tune a second surface to "match," you've forked
the brand.

### 6. Preview the real thing, never a mock

Two previews, both honest: the Branding dialog iframes the *actual sign-in
gate* with a browser-only `?lv_brand=` override (unsaved edits, zero risk);
the Home dialog runs the *actual parser* server-side on unsaved markdown and
renders the resulting document. The parser preview was fidelity-tested
byte-identical against upstream on the real corpus.

**The rule:** a preview that isn't the production render path is a promise
you can't keep.

### 7. Fallback chains beat upload forms

The logo isn't a required upload — it's a chain: brand override → the client
logo already in capcom → the per-branch `programlogo.gif` blob convention →
the client name as text. Every knob degrades the same way: blank means the
built-in default look, never a broken page.

**The rule:** design every setting so that doing nothing is a complete,
respectable answer.

### 8. Curate fonts; respect system stacks

Arbitrary font families can never load — the shell only loads from a curated
list, sanitized again client-side even though the writer validates (defense
in depth; rows can be written by SQL). Tahoma joined as a *system* entry
(`google: ''`, explicit stack) because the classic LMS skin's typeface is
Tahoma and fidelity to it matters — no webfont, no license question.

**The rule:** the allowlist is enforced at load time, not just at write
time. And "system font" is a legitimate branding choice, not a fallback to
apologize for.

### 9. Placement is identity

Two of Douglas's sharpest calls were spatial, not chromatic: the logo leads
**top-left** (the assistant bar), migrating to the strip only when the
assistant hides — the same rule the controls already followed; and the
"Assistant" title text came out — *the mark carries the bar*. Later: the
Home tab itself became re-nameable (`nav.home.label`/`nav.home.icon`),
because Cox will call its Home something of its own.

**The rule:** where the mark sits *is* the brand. Get placement right before
pixels; and a client's own name for their home is branding, not navigation.

### 10. Content *is* part of the home

Home is a mini-site made of markdown: `index.md` is the entry (H1 = hero,
H2 = section, H3 = tile, link-only line = action), inner pages open in
place, folders become menus, frontmatter dates drive publish-date display
and newest-first ordering. The "three md files" a client hands us became
settings rows with the same inheritance as everything else.

**The rule:** a small, honest document grammar beats a page builder. If a
paragraph of markdown can express it, it isn't a feature — it's content.

---

## Process lessons (the scars)

### 11. The shared clone never pulls

`~/lv-site-template` sat clean on an old `main` while the bakery moved
`srv/main` — I edited a stale tree and had to discard and redo. The capcom
shared clone is worse: dirty on `floor-zero-health`, behind dozens.

**The rule:** fresh isolated worktree off `srv/main` (or `srv/master`) for
every change; `git fetch && merge --ff-only` first if you must work in a
shared clone. Commit with explicit pathspecs, always.

### 12. The bakery rewrites commits — rebase before re-dropping

A drop carries the net diff of local commits vs `srv/<branch>`. After a
merge, your local pre-rewrite duplicates linger, and the next drop can
conflict against its own already-merged content. `git rebase --onto
srv/main HEAD~1` isolates the new commit and the drop goes clean.

### 13. Pipes eat exit codes

`npm run typecheck | tail -3` returns *tail's* exit code. I "passed" a
typecheck that was failing. Use `${PIPESTATUS[0]}` or count errors.

### 14. Vendored files drift — test fidelity, not similarity

Three copies of the Home parser now exist (phoenix canonical, LV vendored,
capcom preview) and they were already drifting in both directions. The
capcom copy was validated **byte-identical on the real corpus**; the LV copy
is deliberately behind and inherits on the next sync.

**The rule:** vendor with a "keep in sync" header, test against the real
data, and know which direction each copy is allowed to drift.

### 15. New enforcement rules break old write paths — find them all

Read-only git (gitagent upload-pack only) silently broke *two* write paths
in one week: the floor-zero-health roll un-installed the `/api/bakery` door
from production (diagnostic signature: 403 JSON without auth, SPA *with* a
valid key = the route isn't in the deployed build), and the CMS's own
`commitAndSync` push started failing while committing locally.

**The rule:** when you change who may write, grep the estate for every
writer, and make the durable route (bakery-drop) the one they all converge
on.

### 16. `node_modules` is part of the worktree question

A fast-forward without `npm install` produces phantom typecheck failures
(TipTap, FormCast deps). For worktrees: symlink `node_modules` for
typecheck-only work; hardlink-copy (`cp -al`) before any `npm install`, so
the shared clone's store is never disturbed.

### 17. Registry before rows

The Cox branding spec is finished — and unapplicable, because Cox has no
client row in capcom's registry and no LV deployment; there is no
`client_guid` to anchor `brand.*` rows to.

**The rule:** before writing settings for a scope, prove the scope exists.
Settings without a resolver are orphans.

### 18. Small, validated, audited — every time

Every write this day went through the same shape: a validated API change
list (or a bakery drop), an audit entry with the actor, and a propagation
note. The one time a side door looked shorter (direct SQL for a
not-yet-existing Cox scope), not taking it was the right call.

---

## Deliberately not done

- The logged-in **dashboard** Home variant (Douglas's scope call — it is
  generated, not parsed; leave it).
- A `programlogo.gif` server-side fallback fetch (step 3 of the logo chain —
  worthwhile when a client needs it).
- Per-branch brand overrides (the inheritance is built in; untested in
  anger).
- The `sync-home.sh` parser refresh that pulls folder-nav/dates into the LV.
- Custom CSS. Ever.

*End of lessons. The system they describe: capcom `srv/main` `cb1b0e2` and
follow-ups, lv-site-template `050566a` and follow-ups, drops #48–#125, one
evening.*
