Skip to content

feat(branding): end-to-end branding — header, footer, colour ramp, title & emails - #222

Merged
antosubash merged 4 commits into
mainfrom
worktree-branding-header-footer
Jun 26, 2026
Merged

feat(branding): end-to-end branding — header, footer, colour ramp, title & emails#222
antosubash merged 4 commits into
mainfrom
worktree-branding-header-footer

Conversation

@antosubash

@antosubash antosubash commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add an app-shell footer to the authenticated + admin layouts (SidebarLayout) — previously only the public landing page had a footer. <main> is now a flex column so the footer sticks to the bottom.
  • Make branding-driven, not hardcoded: the public footer and the auth-card (AuthCardShell) brand lockup now read the branding shared prop (appName/logoUrl) instead of literal simple_module/python text — correct for white-labelled deploys.
  • Centralize brand metadata in packages/ui/src/lib/brand.ts (BRAND_ACCENT gradient, BRAND_DEFAULT_APP_NAME, repo URL, license, footer links), killing 4× duplication and unifying the unbranded fallback name.
  • BrandingMark gains an optional stacked caption, a size="lg" variant, and a badgeClassName shadow override — it's now the single brand-lockup primitive used by the header, footer, and auth shell.
  • New reusable BrandingFooter component (app/public variants) with unit tests.

Verification

  • Code review (high effort, 2 passes): 4 findings found + fixed (load-bearing one: a justify-between flex bug that detached the footer badge from its wordmark); final pass clean.
  • QA (browser, normal depth + responsive): 0 bugs across 4 surfaces — public landing, auth login, dashboard, users-admin — at desktop and mobile (375px). Footer badge attached to wordmark, © <year> · MIT caption, Docs/Changelog/GitHub links resolve, no horizontal overflow, nav collapses on mobile. Evidence in .qa/screenshots/.
  • Local CI: make lint ✓ · make test ✓ (1405 Python + 26 JS tests pass; e2e excluded as in pr.yml).

Round 2 — end-to-end theming (commit 482784c)

The lockup work above brands what's visible; this round makes a configured deployment branded everywhere a tenant value should reach:

  • Custom primary colour recolours the whole palette, not just buttons. New packages/ui/src/lib/color.ts derives the full --color-primary-50…900 ramp from the single brand hex (hex→OKLCH, re-hue + chroma-scale, preserving the designed lightness ladder); BrandingHead writes it inline on :root. The compiled CSS confirms .from-primary-600{--tw-gradient-from:var(--color-primary-600)}, so the logo-badge gradient and auth mesh blobs now follow the tenant colour (previously they stayed emerald while only bg-primary buttons changed). Near-grey picks → grey ramp; vivid picks stay vivid.
  • Branded document <title> + pre-hydration head. lib/app-title.ts + app.tsx seed the app name into Inertia's title callback (tabs read Dashboard — Acme), and a duck-typed branding_head() Jinja global server-renders the branded <title>, favicon <link>, and <meta name="theme-color"> before React hydrates (no default-brand flash; correct for crawlers). No framework→plugin import (SM009 clean).
  • Transactional emails carry the brand. Invite / verify / reset subjects + bodies now include the app name, threaded through a decoupled app_name provider — users never imports branding (reads app.state by name).

Round 2 verification (/ship)

  • Code review (high, 2 passes): pass 1 found 5 findings — all fixed in ead121d: (1) a CR/LF in app_name could break email Subject headers → now rejected at the source via a shared clean_app_name; (2) dropped a framework→file_storage route coupling (server favicon link) in favour of the existing client-side favicon; (3) moved a side-effect out of a React render body + made theme-color reactive; (4) derived a duplicated chroma constant; (5) accepted the framework default-name literal as required by the SM009/usersbranding decoupling. Pass 2 re-review: clean.
  • Browser QA (/qa, normal depth): 0 bugs. Verified live that setting a magenta #c026d3 re-hues the full ramp — sidebar badge gradient → oklch(… 322.9°), auth-screen mesh blobs, --primary, and <meta theme-color> all follow it; tab title → the app name; revert-to-default cleanly restores emerald. Evidence in .qa/reports/qa-report-branding-round2-iteration-1.md.
  • New tests: colour util (color.test.ts), title util (app-title.test.ts), branding_head (test_branding_head.py), rendered shell title + theme-color hot-swap + control-char rejection (test_branding.py), email branding (test_mailer.py).
  • Local CI: make lint ✓ (exit 0) · make test ✓ (1413 Python + 41 JS tests pass).

Test plan

  • Load / and /dashboard/ — confirm the footer renders the brand lockup + Docs/Changelog/GitHub links
  • Confirm the /auth/login card shows the prominent glowing brand badge
  • In Branding admin, set a non-emerald primary colour — confirm the sidebar/auth badge gradient and login mesh blobs recolour, and the browser tab title shows the app name
  • CI is green

https://claude.ai/code/session_01GYc5NpnAkEAohj9apMfFok

Add a footer to the authenticated and admin app shells (previously none) and
make the public footer + auth header branding-driven instead of hardcoded.

- lib/brand.ts: single source for framework brand metadata + footer links
- BrandingMark: optional stacked caption (backward compatible)
- BrandingFooter: reusable footer (brand lockup + © year · MIT + links),
  app/public variants
- SidebarLayout: flex-column main with sticky-bottom BrandingFooter
- PublicLayout: use shared BrandingFooter; nav links via BRAND_REPO_URL
- AuthCardShell: brand lockup now driven by the branding shared prop

Claude-Session: https://claude.ai/code/session_01GYc5NpnAkEAohj9apMfFok
- BrandingFooter: wrap BrandingMark in a flex container so the badge stays
  attached to its wordmark (BrandingMark is a fragment; the footer's
  justify-between row otherwise split the lockup into three items)
- BrandingMark: add size='lg' + badgeClassName so AuthCardShell keeps its
  prominent 36px glowing badge (was silently shrunk to 32px/flat)
- lib/brand.ts: centralize BRAND_ACCENT gradient + BRAND_DEFAULT_APP_NAME;
  use across BrandingFooter, AuthCardShell, PublicLayout (kills 3x dup +
  unifies the unbranded fallback name)

Claude-Session: https://claude.ai/code/session_01GYc5NpnAkEAohj9apMfFok
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: ead121d
Status: ✅  Deploy successful!
Preview URL: https://bd6de6f0.simple-module-python.pages.dev
Branch Preview URL: https://worktree-branding-header-foo.simple-module-python.pages.dev

View logs

Extend branding beyond the header/footer lockup so a configured deployment
is branded end to end:

- Derive the full --color-primary-50..900 ramp from the single brand hex so
  the logo-badge gradient and auth mesh blobs follow the tenant colour, not
  just bg-primary buttons (packages/ui/src/lib/color.ts + BrandingHead).
- Brand the document <title> on client navigations (lib/app-title + app.tsx)
  and server-render title/favicon/theme-color into the root template before
  hydration via a duck-typed branding_head() Jinja global (no SM009 coupling).
- Carry the app name in transactional email subjects/bodies, threaded through
  a decoupled app_name provider (users stays independent of branding).

Tests: colour/app-title utils, branding_head, rendered shell (title +
theme-color hot-swap), and email branding. make lint + make test green.

Claude-Session: https://claude.ai/code/session_01GYc5NpnAkEAohj9apMfFok
@antosubash antosubash changed the title feat(branding): unify header & footer branding across all shells feat(branding): end-to-end branding — header, footer, colour ramp, title & emails Jun 26, 2026
- Reject control chars in app_name at the source (BrandingSettings + update
  DTO via a shared clean_app_name helper) — a CR/LF previously passed a bare
  strip() and would then break every transactional email's Subject header.
- Drop the server-rendered favicon <link> and its mirrored file_storage
  route from framework code (SM009-adjacent coupling); the favicon is already
  applied client-side by BrandingHead. branding_head() now surfaces only
  plain settings strings (title + theme-color).
- BrandingHead: move setTitleAppName into an effect (no side-effect during
  render) and keep the server-rendered theme-color meta in sync on a runtime
  colour change, restoring it on unmount.
- color.ts: derive BASE_REFERENCE_CHROMA from the 600 ramp step instead of a
  duplicated magic literal.

Tests: control-char rejection (settings + API 422), theme-color sync,
updated branding_head test. Affected suites green.

Claude-Session: https://claude.ai/code/session_01GYc5NpnAkEAohj9apMfFok
@antosubash
antosubash merged commit 6d1cfe8 into main Jun 26, 2026
12 checks passed
antosubash added a commit that referenced this pull request Sep 1, 2026
…, package-update pin style (#287)

* fix(cli): package-update keeps each dependency's pin style (#284)

`smpy package-update` rewrote every constraint to `name>=<latest>`, so
bumping versions silently changed a project's pinning policy. A host that
pinned `==0.0.32` came back loosened to `>=0.0.32` while the module wheels
it depends on still pin exactly — leaving the effective version decided by
whichever wheel pins hardest rather than by the host.

The bump now changes the version and nothing else:

- `==`, `===`, `~=`, `>=` have their version replaced in place.
- `>` is left alone; anything newer already satisfies it, and `>latest`
  would exclude the release being installed.
- `<`, `<=`, `!=` are preserved verbatim, and a dependency whose upper
  bound excludes the latest release (`>=0.1,<1.0` when 2.0.0 is out) is
  reported and skipped instead of having its ceiling dropped.
- A dependency with no constraint has no pin style to preserve, so it
  still gets `>=<latest>`.

`--loosen` restores the previous blanket `>=` rewrite.

Two incidental fixes fall out of parsing requirements properly rather
than splitting on the first operator: extras (`pkg[redis]`) and
environment markers (`; python_version >= '3.12'`) now survive the
rewrite, where before they were dropped.

Requirement parsing moves to `requirements.py` and the PyPI lookup to
`pypi.py`, keeping every file under the 300-line cap.

* fix(background_tasks): namespace settings env vars under SM_BG_TASKS_ (#283)

`BackgroundTasksSettings` subclassed `BaseSettings` with no `env_prefix`,
so pydantic-settings resolved every field from its bare, case-insensitive
name. The documented `SM_BG_TASKS_*` variables did nothing, while
`broker_url`, `result_backend`, `task_default_queue`, `retention_days`
and `max_retries` were live environment reads under names generic enough
that another component setting them for its own purposes would silently
reconfigure Celery. It also made the DB the source of truth only when
nobody happened to have those names in the environment.

Setting `env_prefix=ENV_PREFIX` gives the class a single rule and aligns
it with the docstring, `settings.env_vars`, the `smpy` docker-compose
recipe, `seed_dev_settings.py`, `tasks.py` and the worker's
`_assert_broker_isolated` — all of which already assume the prefix. The
one-off `default_factory` reads on `broker_url`/`result_backend` and the
`env_bool` call on `task_always_eager` are now redundant and gone.

The localhost validator also names the mechanism it expects. It was most
people's first encounter with this and said only "set these to the Redis
service host", so the natural guess was the prefixed name that had no
effect; it now names `SM_BG_TASKS_BROKER_URL` /
`SM_BG_TASKS_RESULT_BACKEND` and says why an env var is the only thing
that can satisfy it before hydration.

Deployments relying on the accidental bare names must rename them.

* fix(settings): stop DB-backed settings classes reading bare env names

Generalises the root cause of GH #283. Every bundled settings class
subclassed `BaseSettings` and simply omitted `env_prefix`, which does not
disable environment reads — it un-namespaces them. pydantic-settings
still installs its env source and, with no prefix, resolves each field
from the bare, case-insensitive field name.

Verified before this change, with the named variables set in the
environment:

    SiteLockSettings()     -> enabled=True  password='hunter2'
    UsersSettings()        -> smtp_host='evil.example.com'
                              base_url='http://evil.example.com'
    FileStorageSettings()  -> backend='s3'  s3_bucket='attacker-bucket'

`enabled`, `password`, `backend`, `base_url`, `client_secret` and
`maintenance_mode` are common enough in a container that an unrelated
component setting one silently reconfigures the app — and site_lock's
pair is the site gate, while users' `base_url` is the origin of
password-reset links. It also made the DB the source of truth only when
nobody happened to have those names in the environment.

`DbBackedSettings` (new, in `simple_module_core.settings_base`) keeps
only the init source, so values come from the constructor — which is how
DB hydration already sets them — and from nothing else. site_lock, users,
file_storage, settings, branding, keycloak and HostSettings now subclass
it. This is what `_module_settings.py` already told the admin UI was
true, and what the 2026-04-21 DB-backed-settings plan intended by
dropping `env_prefix`.

`background_tasks` deliberately keeps `BaseSettings` + an explicit
`SM_BG_TASKS_` prefix: its broker URL must be readable before any DB row
exists.

The `Settings` shim needed an explicit override. It combines
`HostSettings` with `BootstrapSettings`, and `HostSettings` now comes
first in the MRO carrying the source override with it — which would have
stripped the environment from the bootstrap half, where
`SM_DATABASE_URL`, `SM_SECRET_KEY` and `SM_AUTH_PROVIDER` are read by
design. It restores pydantic's default ordering, so the shim behaves
exactly as before.

`i18n_supported_locales` moves to `default_factory` on the way past:
ruff's RUF012 pydantic exemption keys off the literal `BaseSettings`
base, so the mutable default became visible once the base changed.

* feat(branding): admin-configurable footer links (#282)

Since 0.0.32 there was no supported way for a deployment to change the
footer links: `BRAND_FOOTER_LINKS` was a module-level constant in
`@simple-module-py/ui`, and `BrandingFooter` mapped over it directly. The
configurable footer shipped in 0.0.21 (#222) was removed by #273/#275,
which took the only override with it — so every app advertised
`antosubash/simple_module_python` under "Docs", "Changelog" and "GitHub"
on every page, guest and authenticated. The one workaround, aliasing the
brand module in the host's `vite.config.ts`, silently diverges from the
package on every bump.

`footer_links` joins the other branding values: DB-backed, in the
`branding` shared prop as `footerLinks`, and edited at `/admin/branding`
without a redeploy. `BrandingFooter` takes an optional `links` prop and
falls back to `BRAND_FOOTER_LINKS` when it is absent, null or empty, so a
deployment that never sets any keeps exactly the footer it has today —
and clearing the list is how you go back to them.

Deliberately just `{label, href}` and a cap of 6. What #273 removed had
grown columns, social icons and a tagline; what hosts actually lost was
the ability to stop advertising the framework's repository.

`href` is checked against an allow-list — `http://`, `https://`,
`mailto:` or a site-relative path starting with a single `/`. The value
is rendered straight into an `<a href>` on every page, signed-in or not,
so `javascript:` and `data:` would make this screen a stored-XSS sink for
anyone holding `branding.manage`; scheme-relative `//host` is rejected
too, since it reads as a relative path but navigates off-site. Labels are
bounded and reject control characters, as `app_name` already does.

Also fixes the change detection in `apply_changes_and_reload`, which
compared `changes` against the settings *attribute*. A field typed as a
list of models holds model instances while `changes` carries the plain
dicts a DTO dumps to, so an unchanged list never compared equal and was
rewritten to the store on every save. It now compares against the dumped
current value, which is identical for scalars.

* fix: review findings on wildcard specifiers and footer href validation

Four defects found reviewing this branch.

**`!=1.0.*` never excluded the latest, producing an unsatisfiable pin.**
`version_key` maps `*` to 0, so `!=1.0.*` compared numerically read as
`!=1.0.0` and `_excluded_by` returned nothing. `>=1.0,!=1.0.*` with latest
1.0.5 was rewritten to `>=1.0.5,!=1.0.*`, which nothing can satisfy — and
the tool's own closing message tells you to run `uv sync` next. Wildcards
now match on a release-segment prefix and never go through the numeric
comparison.

**`~=` and `==X.Y.*` were treated as pure floors, ignoring their implicit
ceilings.** `~=1.4` means `>=1.4, ==1.*`, so bumping it to `~=2.0.0` moved
it to a different compatible band entirely instead of taking the documented
"upper bound excludes latest → skip and report" path. `==1.0.*` with latest
1.0.5 was narrowed to `==1.0.5`, though the band already allowed it.
`~=` now moves only within its band, and a wildcard band that already
covers the latest is left exactly as written.

**`clean_footer_href` accepted `/\evil.example.com`.** The guard was
`startswith("/") and not startswith("//")`, but browsers normalise `\` to
`/` in the authority position of a special-scheme URL, so that href reads
as a site-relative path and navigates to `https://evil.example.com` — the
exact bypass the `//host` rule exists to close, reachable by anyone with
`branding.manage` and landing on every page including the anonymous public
shell. Raw backslashes are now rejected outright; `%5C` still works for one
in a path, since percent-decoding happens after the authority is parsed.

**`BrandingFooter` keyed its links on `link.href`.** That is
admin-supplied data and nothing enforces href uniqueness, so two links to
the same target collided on their React key. Keyed on the index instead,
matching `FooterLinksField`.

The constraint-rewriting tests move to `test_cli_package_update_pins.py`
to stay under the 300-line cap, and the two helpers they shared with the
original file become `fake_pypi` / `write_pyproject` fixtures — this
directory has no `__init__.py`, so its conftest is where helpers are shared.

* Never rewrite a pin backwards; align the broker error with its warning

Three findings from reviewing the branch.

`_bump` moved a floor to PyPI's latest unconditionally, so when the
newest published release is *older* than the constraint already in the
file the pin was rewritten backwards. This workspace bumps its own
`simple_module_*==<next>` pins before publishing, which is exactly that
window — and now that exact pins are preserved in place rather than
loosened to `>=`, `==0.0.33` -> `==0.0.32` is a downgrade the next
`uv sync` installs. A floor now only ever rises.

`~=` was bumped to the full latest string, changing the segment count
and so the band width: `~=1.4` + 1.5.0 gave `~=1.5.0`, narrowing
`>=1.4,==1.*` to `>=1.5.0,==1.5.*`. The next run then reports
`1.6.0 excluded by ~=1.5.0` and refuses to move, freezing the
dependency after one update — the pin-style change this module exists
to avoid. It now moves within its own width, to `~=1.5`.

The production-boot localhost error told operators to set
`SM_BG_TASKS_BROKER_URL` / `SM_BG_TASKS_RESULT_BACKEND` while
`_warn_on_legacy_redis_vars`, added in the same commit, logs a
deprecation warning for those very names. It now leads with
`SM_REDIS_URL` and marks the per-field variables as deprecated
overrides, and drops the claim that an env var is the only thing that
can satisfy it — a DB override does too.

Claude-Session: https://claude.ai/code/session_012k5QBVkMXJqJLVqtWbZJht

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant