feat(branding): end-to-end branding — header, footer, colour ramp, title & emails - #222
Merged
Merged
Conversation
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
Deploying simple-module-python with
|
| 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 |
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
- 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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
SidebarLayout) — previously only the public landing page had a footer.<main>is now a flex column so the footer sticks to the bottom.AuthCardShell) brand lockup now read thebrandingshared prop (appName/logoUrl) instead of literalsimple_module/pythontext — correct for white-labelled deploys.packages/ui/src/lib/brand.ts(BRAND_ACCENTgradient,BRAND_DEFAULT_APP_NAME, repo URL, license, footer links), killing 4× duplication and unifying the unbranded fallback name.BrandingMarkgains an optional stackedcaption, asize="lg"variant, and abadgeClassNameshadow override — it's now the single brand-lockup primitive used by the header, footer, and auth shell.BrandingFootercomponent (app/publicvariants) with unit tests.Verification
justify-betweenflex bug that detached the footer badge from its wordmark); final pass clean.© <year> · MITcaption, Docs/Changelog/GitHub links resolve, no horizontal overflow, nav collapses on mobile. Evidence in.qa/screenshots/.make lint✓ ·make test✓ (1405 Python + 26 JS tests pass; e2e excluded as inpr.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:
packages/ui/src/lib/color.tsderives the full--color-primary-50…900ramp from the single brand hex (hex→OKLCH, re-hue + chroma-scale, preserving the designed lightness ladder);BrandingHeadwrites 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 onlybg-primarybuttons changed). Near-grey picks → grey ramp; vivid picks stay vivid.<title>+ pre-hydration head.lib/app-title.ts+app.tsxseed the app name into Inertia's title callback (tabs readDashboard — Acme), and a duck-typedbranding_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). Noframework→pluginimport (SM009 clean).app_nameprovider —usersnever importsbranding(readsapp.stateby name).Round 2 verification (
/ship)ead121d: (1) a CR/LF inapp_namecould break emailSubjectheaders → now rejected at the source via a sharedclean_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 + madetheme-colorreactive; (4) derived a duplicated chroma constant; (5) accepted the framework default-name literal as required by the SM009/users↛brandingdecoupling. Pass 2 re-review: clean./qa, normal depth): 0 bugs. Verified live that setting a magenta#c026d3re-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.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).make lint✓ (exit 0) ·make test✓ (1413 Python + 41 JS tests pass).Test plan
/and/dashboard/— confirm the footer renders the brand lockup + Docs/Changelog/GitHub links/auth/logincard shows the prominent glowing brand badgehttps://claude.ai/code/session_01GYc5NpnAkEAohj9apMfFok