From d5f9350b4bf082fdb77112e548fb5ae355ba364b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 25 Jun 2026 17:07:14 +0200 Subject: [PATCH 1/4] feat(branding): unify header & footer branding across all shells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...026-06-25-branding-header-footer-design.md | 84 +++++++++++++++++++ .../ui/src/components/BrandingFooter.test.tsx | 27 ++++++ packages/ui/src/components/BrandingFooter.tsx | 53 ++++++++++++ .../ui/src/components/BrandingMark.test.tsx | 6 ++ packages/ui/src/components/BrandingMark.tsx | 35 ++++++-- packages/ui/src/layouts/AuthCardShell.tsx | 24 ++++-- packages/ui/src/layouts/PublicLayout.tsx | 41 ++------- packages/ui/src/layouts/SidebarLayout.tsx | 6 +- packages/ui/src/lib/brand.ts | 25 ++++++ 9 files changed, 248 insertions(+), 53 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-25-branding-header-footer-design.md create mode 100644 packages/ui/src/components/BrandingFooter.test.tsx create mode 100644 packages/ui/src/components/BrandingFooter.tsx create mode 100644 packages/ui/src/lib/brand.ts diff --git a/docs/superpowers/specs/2026-06-25-branding-header-footer-design.md b/docs/superpowers/specs/2026-06-25-branding-header-footer-design.md new file mode 100644 index 00000000..d98944f2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-branding-header-footer-design.md @@ -0,0 +1,84 @@ +# Branding: unified header & footer + +**Date:** 2026-06-25 +**Status:** approved-to-implement (proceeding under an active `/goal` directive) + +## Goal + +Improve the app's branding so it is consistent and present in **both the header +and the footer** of every shell — authenticated app, admin panel, public +landing, and auth-card screens. + +## Current state + +The codebase already has a server-driven branding system: + +- A `branding` module emits a `branding` Inertia shared prop: + `{ appName, primaryColor, logoUrl, faviconUrl }` (default `appName` = + `"SimpleModule"`). +- `BrandingHead` applies the favicon + primary colour on every page. +- `BrandingMark` renders the logo/initial badge + wordmark and is used in the + `SidebarLayout` header (desktop sidebar + mobile top bar). + +Gaps: + +1. **No footer in the authenticated/admin app shell** (`SidebarLayout`). Only + the public landing page has a footer. +2. The public footer and the `AuthCardShell` brand lockup **hardcode** brand + text (`simple_module_python · MIT`, `simple_module` / `python`) instead of + reading the `branding` prop — wrong for white-labelled deploys. +3. Brand link URLs (repo / docs / changelog) are duplicated inline. + +## Design + +Refined-minimal, matching the existing aesthetic (emerald `oklch` primary, Sora +display font, JetBrains Mono technical caption, subtle `border-border`, gradient +brand badge). No new server settings, no migrations — purely a frontend +consolidation in `packages/ui`. + +### 1. `lib/brand.ts` — single source for static brand metadata + +Framework-level constants that are not part of the white-labellable +`branding` prop: `BRAND_REPO_URL`, `BRAND_LICENSE` (`MIT`), `BRAND_TECH` +(`python`), and `BRAND_FOOTER_LINKS` (Docs / Changelog / GitHub). + +### 2. `BrandingMark` gains an optional stacked caption + +Add optional `caption` / `captionClassName` props. When `caption` is set the +wordmark + caption stack in a column (badge stays to the left). Backward +compatible — existing callers (sidebar header, mobile bar) pass no caption and +render exactly as before. This makes `BrandingMark` the single brand-lockup +primitive used by the header, footer, and auth shell. + +### 3. `BrandingFooter` — one reusable footer + +New presentational component: brand lockup (`BrandingMark`, small, light label, +caption `© · MIT`) on the left; `BRAND_FOOTER_LINKS` on the right. +`variant` prop: `public` (centred `max-w-6xl`) vs `app` (full content width). +Pure/props-driven so it unit-tests without Inertia. Year computed at runtime +(client-only render, no SSR — safe). + +### 4. Wire it in + +- `SidebarLayout`: make `
` a flex column (`flex-1` content wrapper + + sticky-bottom footer) and render `` driven by + the already-derived `appName` / `logoUrl`. Both `AuthenticatedLayout` and + `AdminLayout` inherit it. +- `PublicLayout`: replace the bespoke footer with ``; point the nav's external links at `BRAND_REPO_URL`. +- `AuthCardShell`: replace the hardcoded `simple_module` / `python` lockup with + `BrandingMark` driven by the `branding` prop (`caption` = `BRAND_TECH`). + +### 5. Tests + +Follow the repo's co-located `*.test.tsx` pattern: new `BrandingFooter.test.tsx` +(app name, links, year/licence caption) and an added caption case in +`BrandingMark.test.tsx`. + +## Out of scope / assumptions + +- No new server-side branding settings (tagline, version, custom footer links). + Footer links remain framework constants. +- Footer link labels stay un-localised, matching the existing public footer + (they are largely proper nouns: Docs / Changelog / GitHub). +- 300-line cap respected; all new files are small and presentational. diff --git a/packages/ui/src/components/BrandingFooter.test.tsx b/packages/ui/src/components/BrandingFooter.test.tsx new file mode 100644 index 00000000..ceb01d12 --- /dev/null +++ b/packages/ui/src/components/BrandingFooter.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { BrandingFooter } from './BrandingFooter'; + +describe('BrandingFooter', () => { + test('renders the app name and framework links', () => { + render(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Docs' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'GitHub' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Changelog' })).toBeInTheDocument(); + }); + + test('shows the current year and licence in the caption', () => { + render(); + const year = new Date().getFullYear(); + expect(screen.getByText(new RegExp(`${year}.*MIT`))).toBeInTheDocument(); + }); + + test('renders the uploaded logo when a logoUrl is provided', () => { + render(); + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute( + 'src', + '/api/file-storage/files/abc/download', + ); + }); +}); diff --git a/packages/ui/src/components/BrandingFooter.tsx b/packages/ui/src/components/BrandingFooter.tsx new file mode 100644 index 00000000..e4f907cf --- /dev/null +++ b/packages/ui/src/components/BrandingFooter.tsx @@ -0,0 +1,53 @@ +import { BRAND_FOOTER_LINKS, BRAND_LICENSE } from '../lib/brand'; +import { BrandingMark } from './BrandingMark'; + +/** Stable for the lifetime of the bundle — the year only matters at page load. */ +const FOOTER_YEAR = new Date().getFullYear(); + +interface BrandingFooterProps { + /** Application name from the `branding` shared prop. */ + appName: string; + /** Custom logo URL; falls back to the generated initial badge. */ + logoUrl?: string | null; + /** + * `public` centres the row within `max-w-6xl` (marketing pages); `app` spans + * the full content width of the sidebar shell. + */ + variant?: 'app' | 'public'; +} + +/** + * App-wide footer: brand lockup on the left, framework links on the right. + * Presentational (props-driven) so it renders without Inertia context and is + * shared by both the authenticated shell and the public layout. + */ +export function BrandingFooter({ + appName, + logoUrl, + variant = 'app', +}: BrandingFooterProps): React.ReactElement { + const container = + variant === 'public' ? 'mx-auto max-w-6xl px-4 py-6 sm:px-8' : 'px-4 py-6 sm:px-6 lg:px-8'; + + return ( + + ); +} diff --git a/packages/ui/src/components/BrandingMark.test.tsx b/packages/ui/src/components/BrandingMark.test.tsx index c340ca44..4787215d 100644 --- a/packages/ui/src/components/BrandingMark.test.tsx +++ b/packages/ui/src/components/BrandingMark.test.tsx @@ -25,4 +25,10 @@ describe('BrandingMark', () => { expect(screen.queryByRole('img')).toBeNull(); expect(screen.getByText('Z')).toBeInTheDocument(); }); + + test('renders an optional caption stacked under the wordmark', () => { + render(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.getByText('python')).toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/components/BrandingMark.tsx b/packages/ui/src/components/BrandingMark.tsx index 7f144eeb..bf3959b2 100644 --- a/packages/ui/src/components/BrandingMark.tsx +++ b/packages/ui/src/components/BrandingMark.tsx @@ -11,6 +11,10 @@ interface BrandingMarkProps { size?: 'sm' | 'md'; /** Classes for the wordmark text (colour/spacing supplied by the layout). */ labelClassName?: string; + /** Optional muted sub-caption stacked under the wordmark (e.g. `python`, `© 2026 · MIT`). */ + caption?: string; + /** Classes for the caption text. Defaults to a muted mono line. */ + captionClassName?: string; } /** @@ -24,11 +28,24 @@ export function BrandingMark({ accentColor, size = 'md', labelClassName, + caption, + captionClassName, }: BrandingMarkProps): React.ReactElement { const box = size === 'sm' ? 'w-7 h-7 rounded-md' : 'w-8 h-8 rounded-lg'; const labelSize = size === 'sm' ? 'text-base' : 'text-lg'; const initial = appName.trim().charAt(0).toUpperCase() || 'S'; + const wordmark = ( + + {appName} + + ); + return ( <> {logoUrl ? ( @@ -38,14 +55,16 @@ export function BrandingMark({ {initial} )} - - {appName} - + {caption ? ( + + {wordmark} + + {caption} + + + ) : ( + wordmark + )} ); } diff --git a/packages/ui/src/layouts/AuthCardShell.tsx b/packages/ui/src/layouts/AuthCardShell.tsx index 96bdf181..ab9e3700 100644 --- a/packages/ui/src/layouts/AuthCardShell.tsx +++ b/packages/ui/src/layouts/AuthCardShell.tsx @@ -1,5 +1,9 @@ +import { usePage } from '@inertiajs/react'; import type React from 'react'; import { BrandingHead } from '../components/BrandingHead'; +import { BrandingMark } from '../components/BrandingMark'; +import { BRAND_TECH } from '../lib/brand'; +import type { SharedProps } from '../types'; /** * Full-viewport centered shell for unauthenticated flows @@ -9,6 +13,10 @@ import { BrandingHead } from '../components/BrandingHead'; * SimpleModulePython HiFi auth screens. */ export function AuthCardShell({ children }: { children: React.ReactNode }) { + const { branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const appName = branding?.appName ?? 'SimpleModule'; + const logoUrl = branding?.logoUrl ?? null; + return (
@@ -19,15 +27,13 @@ export function AuthCardShell({ children }: { children: React.ReactNode }) {
-
- S -
-
- - simple_module - - python -
+
{children}
diff --git a/packages/ui/src/layouts/PublicLayout.tsx b/packages/ui/src/layouts/PublicLayout.tsx index b7075521..1a714004 100644 --- a/packages/ui/src/layouts/PublicLayout.tsx +++ b/packages/ui/src/layouts/PublicLayout.tsx @@ -3,8 +3,10 @@ import { Button } from '@simple-module-py/ui/components/ui/button'; import { Menu, X } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; +import { BrandingFooter } from '../components/BrandingFooter'; import { BrandingHead } from '../components/BrandingHead'; import { LocaleSwitcher } from '../components/LocaleSwitcher'; +import { BRAND_REPO_URL } from '../lib/brand'; import type { SharedProps } from '../types'; export function PublicLayout({ children }: { children: React.ReactNode }) { @@ -41,19 +43,19 @@ export function PublicLayout({ children }: { children: React.ReactNode }) { ); } diff --git a/packages/ui/src/layouts/SidebarLayout.tsx b/packages/ui/src/layouts/SidebarLayout.tsx index e2afe57a..47e7de76 100644 --- a/packages/ui/src/layouts/SidebarLayout.tsx +++ b/packages/ui/src/layouts/SidebarLayout.tsx @@ -18,6 +18,7 @@ import { import { ChevronsUpDown } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; +import { BrandingFooter } from '../components/BrandingFooter'; import { BrandingHead } from '../components/BrandingHead'; import { BrandingMark } from '../components/BrandingMark'; import { NavIcon } from '../components/NavIcon'; @@ -281,7 +282,10 @@ export function SidebarLayout({ {/* Main content */} -
{children}
+
+
{children}
+ +
); diff --git a/packages/ui/src/lib/brand.ts b/packages/ui/src/lib/brand.ts new file mode 100644 index 00000000..3d3d2579 --- /dev/null +++ b/packages/ui/src/lib/brand.ts @@ -0,0 +1,25 @@ +/** + * Framework-level brand metadata shared across the header, footer, and auth + * shells. These are constants of the *framework/template* itself — distinct + * from the white-labellable `branding` shared prop (`appName`, `logoUrl`, …), + * which a deployment can customise at runtime. + */ +export const BRAND_REPO_URL = 'https://github.com/antosubash/simple_module_python'; + +/** Licence shown in the footer caption. */ +export const BRAND_LICENSE = 'MIT'; + +/** Short technology tag shown beneath the wordmark on auth screens. */ +export const BRAND_TECH = 'python'; + +export interface BrandLink { + label: string; + href: string; +} + +/** Links rendered on the right of the application + marketing footers. */ +export const BRAND_FOOTER_LINKS: BrandLink[] = [ + { label: 'Docs', href: `${BRAND_REPO_URL}#readme` }, + { label: 'Changelog', href: `${BRAND_REPO_URL}/releases` }, + { label: 'GitHub', href: BRAND_REPO_URL }, +]; From 577e22059e885f7fe1544e81ccb88b8fe6d0cd12 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 25 Jun 2026 17:14:28 +0200 Subject: [PATCH 2/4] fix(branding): address code review findings (round 1, pass 1) - 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 --- packages/ui/src/components/BrandingFooter.tsx | 20 +++++++------ packages/ui/src/components/BrandingMark.tsx | 28 +++++++++++++++---- packages/ui/src/layouts/AuthCardShell.tsx | 8 ++++-- packages/ui/src/layouts/PublicLayout.tsx | 8 ++++-- packages/ui/src/lib/brand.ts | 10 +++++++ 5 files changed, 53 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/components/BrandingFooter.tsx b/packages/ui/src/components/BrandingFooter.tsx index e4f907cf..257cd400 100644 --- a/packages/ui/src/components/BrandingFooter.tsx +++ b/packages/ui/src/components/BrandingFooter.tsx @@ -1,4 +1,4 @@ -import { BRAND_FOOTER_LINKS, BRAND_LICENSE } from '../lib/brand'; +import { BRAND_ACCENT, BRAND_FOOTER_LINKS, BRAND_LICENSE } from '../lib/brand'; import { BrandingMark } from './BrandingMark'; /** Stable for the lifetime of the bundle — the year only matters at page load. */ @@ -32,14 +32,16 @@ export function BrandingFooter({ return (