From e7ed5dba2a5044ad655343169a6e852cd6e30d41 Mon Sep 17 00:00:00 2001 From: babblebey Date: Thu, 9 Jul 2026 16:11:26 +0100 Subject: [PATCH 01/19] chore(prd): add prd to implement Family brandingConfig for logotype font personalization --- .../030-family-branded-instance-phase2/prd.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 .project/prds/030-family-branded-instance-phase2/prd.md diff --git a/.project/prds/030-family-branded-instance-phase2/prd.md b/.project/prds/030-family-branded-instance-phase2/prd.md new file mode 100644 index 0000000..9a346ad --- /dev/null +++ b/.project/prds/030-family-branded-instance-phase2/prd.md @@ -0,0 +1,227 @@ +--- +title: "Family Branded Instance Phase 2 - Logotype Fonts via Family brandingConfig" +status: draft +references: + - type: doc + url: .project/brief.md + description: "Project brief" + - type: prd + url: .project/prds/029-family-branded-instance-phase1/prd.md + description: "Phase 1 host-aware branding baseline across app, PWA, metadata, and email" + - type: prd + url: .project/prds/004-family-identity-static-ui/prd.md + description: "Family identity settings ownership and user-facing family profile foundations" +--- + +> **Instructions for AI Agents:** +> - Mark each task checkbox (`- [x]`) immediately upon completion. +> - Update the `status` field in the frontmatter to reflect the current state: +> - `in-progress` - when work begins on any phase. +> - `completed` - when all tasks and acceptance criteria are done. +> - `on-hold` - if work is blocked or paused. +> - Do not skip tasks or mark them complete without implementing the work. + +# Family Logotype Branding Phase 2 - Family brandingConfig Font Personalization + +## Description + +Phase 1 established host-aware family branding across shell, metadata, PWA, and email. Phase 2 keeps that baseline and introduces family-level text logotype personalization in selected app surfaces. + +This revision intentionally moves logotype persistence to the `Family` model using a single expandable `brandingConfig` field. Logotype personalization is managed through owner/admin Family Settings and rendered directly from Family branding data. + +In scope: +- Family logotype personalization driven by a provided allowlist of font names loaded from `api.fonts.coollabs.io`. +- Family-scoped persistence of branding state via `Family.brandingConfig`. +- Owner/admin management in Family Settings using existing role guards and tRPC patterns. +- App-provided fallback rendering when brandingConfig is unset/invalid with explicit behavior for nav and membership guard surfaces. +- Rollout to selected shell/loading surfaces with deterministic fallback to current lockup. + +Out of scope: +- Font uploads, SVG uploads, or generated image logos. +- Runtime free-text font names that are not in the provided allowlist. +- Landing repository changes. + +### Design Decisions + +- **Single expandable branding field**: Use one Family-level `brandingConfig` payload for current and future branding options. +- **Allowlisted font names as source of truth**: The app consumes a provided list of font names and only those names can be selected. +- **api.fonts.coollabs.io delivery**: Approved font names are loaded from `api.fonts.coollabs.io`, not bundled local font files. +- **Schema is additive and safe**: Introduce `brandingConfig` as nullable first; no destructive migration steps in this phase. +- **Versioned payload contract**: Include a version key in `brandingConfig` to support future additions (for example colors) without schema churn. +- **No Ffeature path for branding**: Branding read/write must come from `Family.brandingConfig` only. +- **Lockup update prework first**: Before Phase 2 implementation tasks, standardize lockup wording from `FamilyName on Fircle` to `The FamilyName Fircle`. +- **Fallback first with surface rules**: When logotype is not enabled, nav surfaces fall back to `Fircle` text with favicon, and membership guard loading area renders no logo mark while keeping `Powered by Fircle` unchanged. +- **Surface-scoped rollout**: Apply only to approved shell/loading placements to reduce regressions. + +### User Stories + +- **As a** family owner/admin, **I want** to select a logotype font from a provided list, **so that** our family space has a stronger visual identity without asset uploads. +- **As a** family member, **I want** key app shell surfaces to reflect our family logotype styling, **so that** the instance feels personalized. +- **As a** maintainer, **I want** branding state centralized in one expandable Family field, **so that** future brand options (for example color) do not require repeated schema additions. +- **As a** maintainer, **I want** app-provided fallback visuals when brandingConfig is missing or invalid, **so that** user experience remains stable. + +## Provided Font List and brandingConfig Model + +Use a provided allowlist of font names (operator-defined) and persist branding preferences per family in `Family.brandingConfig`. + +Font list contract: +- Source: `api.fonts.coollabs.io` +- Input: array of font names provided at configuration time +- Seed file for this phase: `.project/prds/030-family-branded-instance-phase2/font-names.json` +- Validation: only allowlist members are selectable + +Font loading contract: +- Preconnect must be added for `https://api.fonts.coollabs.io`. +- Stylesheet URL format must follow: + - `https://api.fonts.coollabs.io/css2?family=&display=swap` +- Example: + - `` + - `` + +brandingConfig contract (initial version): +```json +{ + "version": 1, + "logotype": { + "enabled": true, + "fontName": "Manufacturing Consent", + "fontProvider": "api.fonts.coollabs.io" + } +} +``` + +Resolution rules: +- `brandingConfig` null/missing/invalid: render app-provided fallback lockup behavior from brand context. +- `brandingConfig.logotype.enabled=false`: render app-provided fallback lockup behavior. +- `brandingConfig.logotype.enabled=true` + allowlisted `fontName`: render text logotype with that font loaded from `api.fonts.coollabs.io`. +- Non-allowlisted `fontName`: ignore font selection and render app-provided fallback; show remediation in Family Settings. + +Fallback behavior by surface: +- Navigation surfaces (`desktop-sidebar`, `mobile-header`, mobile menu header): render `Fircle` text with favicon when logotype is not enabled. +- Membership guard loading footer: render no logo mark when logotype is not enabled. +- `Powered by Fircle` copy in membership guard remains unchanged in all states. + +## Prework: Lockup Baseline Alignment + +This prework must be completed before implementation phases below. + +Prework objective: +- Update family lockup baseline wording from `FamilyName on Fircle` to `The FamilyName Fircle`. +- Apply the same lockup wording update to metadata and email branding surfaces that currently use family lockup text. + +Lockup composition specification (must be implemented exactly): +- Content structure: `The FamilyName Fircle`. +- `FamilyName`: rendered with selected custom logotype font. +- `The`: rendered in app sans, smaller scale, visually positioned at the top-left of the `FamilyName` lockup. +- `Fircle`: rendered in app sans, smaller scale, visually positioned at the bottom-right of the `FamilyName` lockup. +- `The` and `Fircle` must not inherit the selected custom logotype font. + +Prework tasks: +- [ ] Update brand lockup resolver output in [src/lib/brand-context.ts](src/lib/brand-context.ts) to emit `The FamilyName Fircle` for family lockup contexts. +- [ ] Update canonical lockup formatter behavior in [src/lib/family-name.ts](src/lib/family-name.ts) to emit `The FamilyName Fircle` where family lockup text is generated. +- [ ] Update existing lockup assertions in [test/lib/brand-context.test.ts](test/lib/brand-context.test.ts) to match the new wording. +- [ ] Add lockup composition helper/component API that separates `the`, `familyName`, and `fircle` segments for structured rendering in [src/lib/brand-context.ts](src/lib/brand-context.ts) and/or [src/components/nav](src/components/nav). +- [ ] Update metadata lockup outputs to use `The FamilyName Fircle` where family lockup text is used in [src/app/layout.tsx](src/app/layout.tsx). +- [ ] Update manifest generator lockup outputs to use `The FamilyName Fircle` where family lockup text is used in [src/app/manifest.json/route.ts](src/app/manifest.json/route.ts). +- [ ] Update transactional email lockup wording/templates to use `The FamilyName Fircle` where family lockup text is used in [src/server/email/templates.ts](src/server/email/templates.ts). +- [ ] Update auth route lockup usage to reflect the prework wording in [src/app/auth/page.tsx](src/app/auth/page.tsx), [src/app/auth/signin/page.tsx](src/app/auth/signin/page.tsx), and [src/app/auth/setup/page.tsx](src/app/auth/setup/page.tsx). +- [ ] Update auth UI components receiving `primaryLockup` to reflect the prework wording in [src/components/auth/signin-form.tsx](src/components/auth/signin-form.tsx) and [src/components/auth/first-family-setup-form.tsx](src/components/auth/first-family-setup-form.tsx). +- [ ] Update PWA install prompt lockup usage to reflect the prework wording in [src/components/pwa/pwa-install-prompt.tsx](src/components/pwa/pwa-install-prompt.tsx). +- [ ] Update router-level lockup/fallback copy usage in [src/server/api/routers/invite.ts](src/server/api/routers/invite.ts) and [src/server/api/routers/family-member.ts](src/server/api/routers/family-member.ts). +- [ ] Add/adjust metadata assertions for lockup wording in [test/app/layout-metadata.test.ts](test/app/layout-metadata.test.ts). +- [ ] Add/adjust manifest assertions for lockup wording in [test/app/brand-route-wiring.test.ts](test/app/brand-route-wiring.test.ts). +- [ ] Add/adjust email template assertions for lockup wording in [test/server/email/templates.test.ts](test/server/email/templates.test.ts). +- [ ] Add/adjust auth and install prompt assertions for lockup wording in [test/app/brand-route-wiring.test.ts](test/app/brand-route-wiring.test.ts). + +Prework lockup touch matrix (must be verified complete before Phase 1 implementation): +- Core lockup logic: [src/lib/brand-context.ts](src/lib/brand-context.ts), [src/lib/family-name.ts](src/lib/family-name.ts) +- App metadata and manifest: [src/app/layout.tsx](src/app/layout.tsx), [src/app/manifest.json/route.ts](src/app/manifest.json/route.ts) +- Auth routes and auth components: [src/app/auth/page.tsx](src/app/auth/page.tsx), [src/app/auth/signin/page.tsx](src/app/auth/signin/page.tsx), [src/app/auth/setup/page.tsx](src/app/auth/setup/page.tsx), [src/components/auth/signin-form.tsx](src/components/auth/signin-form.tsx), [src/components/auth/first-family-setup-form.tsx](src/components/auth/first-family-setup-form.tsx) +- PWA install copy: [src/components/pwa/pwa-install-prompt.tsx](src/components/pwa/pwa-install-prompt.tsx) +- Transactional/email and related router copy: [src/server/email/templates.ts](src/server/email/templates.ts), [src/server/api/routers/invite.ts](src/server/api/routers/invite.ts), [src/server/api/routers/family-member.ts](src/server/api/routers/family-member.ts) +- Test coverage: [test/lib/brand-context.test.ts](test/lib/brand-context.test.ts), [test/app/layout-metadata.test.ts](test/app/layout-metadata.test.ts), [test/app/brand-route-wiring.test.ts](test/app/brand-route-wiring.test.ts), [test/server/email/templates.test.ts](test/server/email/templates.test.ts) + +## Implementation Plan + +### Phase 1: Schema Expansion and Payload Contract + +**Goal:** Introduce a safe, expandable Family branding payload and validation contract. + +#### Tasks + +- [ ] Add nullable `brandingConfig` field to `Family` model in [prisma/schema.prisma](prisma/schema.prisma) using JSON type. +- [ ] Create and apply additive migration for new `brandingConfig` field under [prisma/migrations](prisma/migrations). +- [ ] Add typed branding schema validator (zod) for `brandingConfig` in [src/lib/branding/branding-config.ts](src/lib/branding/branding-config.ts). +- [ ] Add a provided font-list config module (font names + normalization helpers) in [src/lib/branding/logotype-fonts.ts](src/lib/branding/logotype-fonts.ts). +- [ ] Add helper to generate safe `api.fonts.coollabs.io` stylesheet URLs for selected allowlisted fonts in [src/lib/branding/logotype-fonts.ts](src/lib/branding/logotype-fonts.ts). + +### Phase 2: Owner/Admin Family Branding API + +**Goal:** Manage logotype enablement and font selection through Family identity APIs with strict validation. + +#### Tasks + +- [ ] Extend Family identity input/output schemas in [src/server/api/routers/invite.ts](src/server/api/routers/invite.ts) to include `brandingConfig`. +- [ ] Validate `brandingConfig.logotype.fontName` against provided allowlist before save. +- [ ] Reuse existing family membership role checks (OWNER or ADMIN) for branding writes. +- [ ] Update management context response to include normalized brandingConfig output for consumers. +- [ ] Ensure invalid payloads are rejected with clear validation errors. + +### Phase 3: Family Settings Management UI + +**Goal:** Allow owner/admin users to choose and preview logotype fonts from the provided `api.fonts.coollabs.io` list in existing Family Settings flow. + +#### Tasks + +- [ ] Extend Family Settings data loading to include `brandingConfig` and provided allowlist metadata in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). +- [ ] Add font selector controls from the provided allowlist, with explicit default option (disable logotype) in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). +- [ ] Load preview font stylesheets from `api.fonts.coollabs.io` for selected allowlisted font names in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). +- [ ] Add live preview based on resolved selected font using existing family name context in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). +- [ ] Show remediation messaging when brandingConfig is invalid or fontName is not allowlisted. +- [ ] Preserve existing settings permission and navigation behavior in [src/app/(app)/settings/layout.tsx](src/app/(app)/settings/layout.tsx). + +### Phase 4: Surface Rollout Using Resolved Font + +**Goal:** Apply resolved logotype font styling to approved shell/loading placements while preserving fallback lockup. + +#### Tasks + +- [ ] Wire resolved selected font from `brandingConfig` into desktop sidebar brand rendering in [src/components/nav/desktop-sidebar.tsx](src/components/nav/desktop-sidebar.tsx). +- [ ] Wire resolved selected font from `brandingConfig` into mobile center header and menu sheet header brand rendering in [src/components/nav/mobile-header.tsx](src/components/nav/mobile-header.tsx). +- [ ] Wire resolved selected font from `brandingConfig` into membership loading footer brand rendering in [src/components/auth/membership-guard.tsx](src/components/auth/membership-guard.tsx). +- [ ] Implement lockup layout rules in nav/membership logotype views: `The` top-left small sans, `FamilyName` custom font, `Fircle` bottom-right small sans. +- [ ] Enforce nav fallback when logotype is not enabled: show `Fircle` text with favicon in [src/components/nav/desktop-sidebar.tsx](src/components/nav/desktop-sidebar.tsx) and [src/components/nav/mobile-header.tsx](src/components/nav/mobile-header.tsx). +- [ ] Enforce membership guard fallback when logotype is not enabled: no logo mark is rendered while `Powered by Fircle` remains unchanged in [src/components/auth/membership-guard.tsx](src/components/auth/membership-guard.tsx). +- [ ] Keep host-aware family lockup fallback behavior sourced from existing brand context where applicable in [src/lib/brand-context.ts](src/lib/brand-context.ts). +- [ ] Ensure no branding reads depend on ffeatures paths for logotype selection. + +### Phase 5: Verification and Regression Safety + +**Goal:** Validate allowlisted font behavior, role guards, and branding fallback stability. + +#### Tasks + +- [ ] Add tests for allowlist validation and `api.fonts.coollabs.io` URL generation in [test/lib/branding/logotype-fonts.test.ts](test/lib/branding/logotype-fonts.test.ts). +- [ ] Add or extend brand-context/rendering tests for fallback lockup continuity when no logotype font is active in [test/lib/brand-context.test.ts](test/lib/brand-context.test.ts). +- [ ] Add router-level tests for owner/admin mutation authorization and family scoping in [test/server/api/routers/invite.test.ts](test/server/api/routers/invite.test.ts). +- [ ] Add tests for brandingConfig validation, normalization, and fallback behavior in [test/server/api/routers/invite.test.ts](test/server/api/routers/invite.test.ts). +- [ ] Run `pnpm lint`, `pnpm typecheck`, and targeted tests for touched modules. +- [ ] Perform manual checks for desktop/mobile shell placements and loading state rendering. + +## Acceptance Criteria + +- [ ] Prework lockup alignment is completed: family lockup baseline reads `The FamilyName Fircle`. +- [ ] Metadata lockup outputs that use family lockup text are updated to `The FamilyName Fircle`. +- [ ] Manifest generator lockup outputs that use family lockup text are updated to `The FamilyName Fircle`. +- [ ] Email lockup outputs that use family lockup text are updated to `The FamilyName Fircle`. +- [ ] In logotype-enabled state, lockup composition is exact: `The` (small sans, top-left), `FamilyName` (selected custom font), `Fircle` (small sans, bottom-right). +- [ ] Logotype personalization is represented through `Family.brandingConfig` only. +- [ ] The font choices available to families are limited to a provided allowlist of font names loaded via `api.fonts.coollabs.io`. +- [ ] Owner/admin users can manage logotype enablement and font selection through Family Settings and Family identity APIs. +- [ ] Desktop sidebar, mobile header center, mobile menu sheet header, and membership loading footer apply resolved selected-font styling when active. +- [ ] When logotype is not enabled, nav surfaces fall back to `Fircle` text with favicon. +- [ ] When logotype is not enabled, membership guard loading area renders no logo mark while leaving `Powered by Fircle` untouched. +- [ ] Unset, invalid, or conflicting font-selection states preserve fallback behavior and show remediation where applicable. +- [ ] Existing Phase 1 host-aware metadata, PWA, and email branding behavior remains unaffected. +- [ ] A single additive migration adds nullable `Family.brandingConfig` with no destructive schema changes. +- [ ] Lint, typecheck, and targeted tests pass for touched areas. From fd893085629609d1dbf3c2a5e26068c77a549c79 Mon Sep 17 00:00:00 2001 From: babblebey Date: Thu, 9 Jul 2026 16:28:58 +0100 Subject: [PATCH 02/19] feat(branding): update logotype formatting and related tests for Family branding --- .../030-family-branded-instance-phase2/prd.md | 32 +++++++++---------- src/lib/brand-context.ts | 15 +++++++-- src/lib/family-name.ts | 2 +- test/app/brand-route-wiring.test.ts | 2 ++ test/app/layout-metadata.test.ts | 14 +++++--- test/lib/brand-context.test.ts | 17 ++++++++-- test/lib/family-name.test.ts | 13 +++++++- test/server/email/templates.test.ts | 8 ++--- 8 files changed, 72 insertions(+), 31 deletions(-) diff --git a/.project/prds/030-family-branded-instance-phase2/prd.md b/.project/prds/030-family-branded-instance-phase2/prd.md index 9a346ad..04bc421 100644 --- a/.project/prds/030-family-branded-instance-phase2/prd.md +++ b/.project/prds/030-family-branded-instance-phase2/prd.md @@ -1,6 +1,6 @@ --- title: "Family Branded Instance Phase 2 - Logotype Fonts via Family brandingConfig" -status: draft +status: in-progress references: - type: doc url: .project/brief.md @@ -117,21 +117,21 @@ Lockup composition specification (must be implemented exactly): - `The` and `Fircle` must not inherit the selected custom logotype font. Prework tasks: -- [ ] Update brand lockup resolver output in [src/lib/brand-context.ts](src/lib/brand-context.ts) to emit `The FamilyName Fircle` for family lockup contexts. -- [ ] Update canonical lockup formatter behavior in [src/lib/family-name.ts](src/lib/family-name.ts) to emit `The FamilyName Fircle` where family lockup text is generated. -- [ ] Update existing lockup assertions in [test/lib/brand-context.test.ts](test/lib/brand-context.test.ts) to match the new wording. -- [ ] Add lockup composition helper/component API that separates `the`, `familyName`, and `fircle` segments for structured rendering in [src/lib/brand-context.ts](src/lib/brand-context.ts) and/or [src/components/nav](src/components/nav). -- [ ] Update metadata lockup outputs to use `The FamilyName Fircle` where family lockup text is used in [src/app/layout.tsx](src/app/layout.tsx). -- [ ] Update manifest generator lockup outputs to use `The FamilyName Fircle` where family lockup text is used in [src/app/manifest.json/route.ts](src/app/manifest.json/route.ts). -- [ ] Update transactional email lockup wording/templates to use `The FamilyName Fircle` where family lockup text is used in [src/server/email/templates.ts](src/server/email/templates.ts). -- [ ] Update auth route lockup usage to reflect the prework wording in [src/app/auth/page.tsx](src/app/auth/page.tsx), [src/app/auth/signin/page.tsx](src/app/auth/signin/page.tsx), and [src/app/auth/setup/page.tsx](src/app/auth/setup/page.tsx). -- [ ] Update auth UI components receiving `primaryLockup` to reflect the prework wording in [src/components/auth/signin-form.tsx](src/components/auth/signin-form.tsx) and [src/components/auth/first-family-setup-form.tsx](src/components/auth/first-family-setup-form.tsx). -- [ ] Update PWA install prompt lockup usage to reflect the prework wording in [src/components/pwa/pwa-install-prompt.tsx](src/components/pwa/pwa-install-prompt.tsx). -- [ ] Update router-level lockup/fallback copy usage in [src/server/api/routers/invite.ts](src/server/api/routers/invite.ts) and [src/server/api/routers/family-member.ts](src/server/api/routers/family-member.ts). -- [ ] Add/adjust metadata assertions for lockup wording in [test/app/layout-metadata.test.ts](test/app/layout-metadata.test.ts). -- [ ] Add/adjust manifest assertions for lockup wording in [test/app/brand-route-wiring.test.ts](test/app/brand-route-wiring.test.ts). -- [ ] Add/adjust email template assertions for lockup wording in [test/server/email/templates.test.ts](test/server/email/templates.test.ts). -- [ ] Add/adjust auth and install prompt assertions for lockup wording in [test/app/brand-route-wiring.test.ts](test/app/brand-route-wiring.test.ts). +- [x] Update brand lockup resolver output in [src/lib/brand-context.ts](src/lib/brand-context.ts) to emit `The FamilyName Fircle` for family lockup contexts. +- [x] Update canonical lockup formatter behavior in [src/lib/family-name.ts](src/lib/family-name.ts) to emit `The FamilyName Fircle` where family lockup text is generated. +- [x] Update existing lockup assertions in [test/lib/brand-context.test.ts](test/lib/brand-context.test.ts) to match the new wording. +- [x] Add lockup composition helper/component API that separates `the`, `familyName`, and `fircle` segments for structured rendering in [src/lib/brand-context.ts](src/lib/brand-context.ts) and/or [src/components/nav](src/components/nav). +- [x] Update metadata lockup outputs to use `The FamilyName Fircle` where family lockup text is used in [src/app/layout.tsx](src/app/layout.tsx). +- [x] Update manifest generator lockup outputs to use `The FamilyName Fircle` where family lockup text is used in [src/app/manifest.json/route.ts](src/app/manifest.json/route.ts). +- [x] Update transactional email lockup wording/templates to use `The FamilyName Fircle` where family lockup text is used in [src/server/email/templates.ts](src/server/email/templates.ts). +- [x] Update auth route lockup usage to reflect the prework wording in [src/app/auth/page.tsx](src/app/auth/page.tsx), [src/app/auth/signin/page.tsx](src/app/auth/signin/page.tsx), and [src/app/auth/setup/page.tsx](src/app/auth/setup/page.tsx). +- [x] Update auth UI components receiving `primaryLockup` to reflect the prework wording in [src/components/auth/signin-form.tsx](src/components/auth/signin-form.tsx) and [src/components/auth/first-family-setup-form.tsx](src/components/auth/first-family-setup-form.tsx). +- [x] Update PWA install prompt lockup usage to reflect the prework wording in [src/components/pwa/pwa-install-prompt.tsx](src/components/pwa/pwa-install-prompt.tsx). +- [x] Update router-level lockup/fallback copy usage in [src/server/api/routers/invite.ts](src/server/api/routers/invite.ts) and [src/server/api/routers/family-member.ts](src/server/api/routers/family-member.ts). +- [x] Add/adjust metadata assertions for lockup wording in [test/app/layout-metadata.test.ts](test/app/layout-metadata.test.ts). +- [x] Add/adjust manifest assertions for lockup wording in [test/app/brand-route-wiring.test.ts](test/app/brand-route-wiring.test.ts). +- [x] Add/adjust email template assertions for lockup wording in [test/server/email/templates.test.ts](test/server/email/templates.test.ts). +- [x] Add/adjust auth and install prompt assertions for lockup wording in [test/app/brand-route-wiring.test.ts](test/app/brand-route-wiring.test.ts). Prework lockup touch matrix (must be verified complete before Phase 1 implementation): - Core lockup logic: [src/lib/brand-context.ts](src/lib/brand-context.ts), [src/lib/family-name.ts](src/lib/family-name.ts) diff --git a/src/lib/brand-context.ts b/src/lib/brand-context.ts index d322659..9762a07 100644 --- a/src/lib/brand-context.ts +++ b/src/lib/brand-context.ts @@ -13,6 +13,11 @@ export type BrandContext = { familyBaseName: string | null; familyDisplayName: string; primaryLockup: string; + primaryLockupParts: { + leading: string; + familyName: string; + trailing: string; + } | null; appDescription: string; }; @@ -38,7 +43,7 @@ function buildResolvedBrandContext(input: { includeArticle: false, includeSuffix: false, }); - const primaryLockup = `${familyDisplayName} on Fircle`; + const primaryLockup = `The ${familyDisplayName} Fircle`; return { state: input.state, @@ -48,7 +53,12 @@ function buildResolvedBrandContext(input: { familyBaseName, familyDisplayName, primaryLockup, - appDescription: `${familyDisplayName} on Fircle: a private family space for sharing updates, photos, and memories.`, + primaryLockupParts: { + leading: "The", + familyName: familyDisplayName, + trailing: "Fircle", + }, + appDescription: `${primaryLockup}: a private family space for sharing updates, photos, and memories.`, }; } @@ -64,6 +74,7 @@ function buildFallbackBrandContext(input: { familyBaseName: null, familyDisplayName: "Family", primaryLockup: FALLBACK_LOCKUP, + primaryLockupParts: null, appDescription: FALLBACK_APP_DESCRIPTION, }; } diff --git a/src/lib/family-name.ts b/src/lib/family-name.ts index 59d3e8e..d086a5c 100644 --- a/src/lib/family-name.ts +++ b/src/lib/family-name.ts @@ -34,5 +34,5 @@ export function formatFamilyLockup(value: string): string { return "Fircle"; } - return `${familyName} on Fircle`; + return `The ${familyName} Fircle`; } diff --git a/test/app/brand-route-wiring.test.ts b/test/app/brand-route-wiring.test.ts index f84455f..f9c023b 100644 --- a/test/app/brand-route-wiring.test.ts +++ b/test/app/brand-route-wiring.test.ts @@ -29,10 +29,12 @@ describe("Phase 5 brand lockup wiring", () => { const signinPage = readWorkspaceFile("src/app/auth/signin/page.tsx"); const signinForm = readWorkspaceFile("src/components/auth/signin-form.tsx"); const setupPage = readWorkspaceFile("src/app/auth/setup/page.tsx"); + const pwaPrompt = readWorkspaceFile("src/components/pwa/pwa-install-prompt.tsx"); expect(authLanding).toContain("{brandContext.primaryLockup}"); expect(signinPage).toContain(""); expect(signinForm).toContain("Enter your credentials for {primaryLockup}"); expect(setupPage).toContain(""); + expect(pwaPrompt).toContain("Install {primaryLockup}"); }); }); diff --git a/test/app/layout-metadata.test.ts b/test/app/layout-metadata.test.ts index 6f79e24..f8b1abd 100644 --- a/test/app/layout-metadata.test.ts +++ b/test/app/layout-metadata.test.ts @@ -53,16 +53,21 @@ describe("Phase 5 metadata and manifest branding", () => { shouldRedirectToCanonical: false, familyBaseName: "Ng", familyDisplayName: "Ng", - primaryLockup: "Ng on Fircle", - appDescription: "Ng on Fircle: a private family space for sharing updates, photos, and memories.", + primaryLockup: "The Ng Fircle", + primaryLockupParts: { + leading: "The", + familyName: "Ng", + trailing: "Fircle", + }, + appDescription: "The Ng Fircle: a private family space for sharing updates, photos, and memories.", }); const manifestResponse = await manifestGet(); const manifest = (await manifestResponse.json()) as ManifestPayload; - expect(manifest.name).toBe("Ng on Fircle"); + expect(manifest.name).toBe("The Ng Fircle"); expect(manifest.short_name).toBe("Ng"); - expect(manifest.description).toContain("Ng on Fircle"); + expect(manifest.description).toContain("The Ng Fircle"); }); it("returns fallback manifest values for an unknown host", async () => { @@ -74,6 +79,7 @@ describe("Phase 5 metadata and manifest branding", () => { familyBaseName: null, familyDisplayName: "Family", primaryLockup: "Fircle", + primaryLockupParts: null, appDescription: "A private family space for sharing updates, photos, and memories.", }); diff --git a/test/lib/brand-context.test.ts b/test/lib/brand-context.test.ts index 44329fc..69af914 100644 --- a/test/lib/brand-context.test.ts +++ b/test/lib/brand-context.test.ts @@ -45,9 +45,14 @@ describe("resolveBrandContextFromHeaders", () => { shouldRedirectToCanonical: false, familyBaseName: "Ng", familyDisplayName: "Ng", - primaryLockup: "Ng on Fircle", + primaryLockup: "The Ng Fircle", + primaryLockupParts: { + leading: "The", + familyName: "Ng", + trailing: "Fircle", + }, }); - expect(context.appDescription).toContain("Ng on Fircle"); + expect(context.appDescription).toContain("The Ng Fircle"); }); it("returns fallback context when tenant is not found", async () => { @@ -68,6 +73,7 @@ describe("resolveBrandContextFromHeaders", () => { familyBaseName: null, familyDisplayName: "Family", primaryLockup: "Fircle", + primaryLockupParts: null, appDescription: "A private family space for sharing updates, photos, and memories.", }); }); @@ -98,6 +104,11 @@ describe("resolveBrandContextFromHeaders", () => { expect(context.shouldRedirectToCanonical).toBe(true); expect(context.host).toBe("family.example.com"); expect(context.canonicalHost).toBe("ng.fircle.app"); - expect(context.primaryLockup).toBe("Ng on Fircle"); + expect(context.primaryLockup).toBe("The Ng Fircle"); + expect(context.primaryLockupParts).toEqual({ + leading: "The", + familyName: "Ng", + trailing: "Fircle", + }); }); }); diff --git a/test/lib/family-name.test.ts b/test/lib/family-name.test.ts index 7aa28b5..547cad2 100644 --- a/test/lib/family-name.test.ts +++ b/test/lib/family-name.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatFamilyDisplayName, normalizeFamilyNameInput } from "~/lib/family-name"; +import { formatFamilyDisplayName, formatFamilyLockup, normalizeFamilyNameInput } from "~/lib/family-name"; describe("normalizeFamilyNameInput", () => { it("removes leading article and trailing family suffix", () => { @@ -24,3 +24,14 @@ describe("formatFamilyDisplayName", () => { expect(formatFamilyDisplayName("Walker", { includeSuffix: false })).toBe("The Walker"); }); }); + +describe("formatFamilyLockup", () => { + it("formats the family lockup baseline", () => { + expect(formatFamilyLockup("Walker")).toBe("The Walker Fircle"); + expect(formatFamilyLockup("The Walker Family")).toBe("The Walker Fircle"); + }); + + it("falls back for empty family names", () => { + expect(formatFamilyLockup(" ")).toBe("Fircle"); + }); +}); diff --git a/test/server/email/templates.test.ts b/test/server/email/templates.test.ts index 279dc9f..e4e1081 100644 --- a/test/server/email/templates.test.ts +++ b/test/server/email/templates.test.ts @@ -14,8 +14,8 @@ describe("buildInviteCreatedTemplate", () => { expiresAt: new Date("2030-01-01T00:00:00.000Z"), }); - expect(result.subject).toContain("Ng on Fircle"); - expect(result.text).toContain("join Ng on Fircle."); + expect(result.subject).toContain("The Ng Fircle"); + expect(result.text).toContain("join The Ng Fircle."); expect(result.actionUrl).toBe("https://fircle.example.com/auth/invite/INVITE_CODE_123"); expect(result.text).toContain("This link expires on Tue, 01 Jan 2030 00:00:00 GMT."); expect(result.html).toContain("Accept invite"); @@ -45,8 +45,8 @@ describe("buildClaimLinkCreatedTemplate", () => { expiresAt: new Date("2031-02-03T04:05:06.000Z"), }); - expect(result.subject).toContain("Grandma Mary profile in Ng on Fircle"); - expect(result.text).toContain("profile in Ng on Fircle."); + expect(result.subject).toContain("Grandma Mary profile in The Ng Fircle"); + expect(result.text).toContain("profile in The Ng Fircle."); expect(result.actionUrl).toBe("https://fircle.example.com/auth/claim/token%2Fwith%20space"); expect(result.text).toContain("This link expires on Mon, 03 Feb 2031 04:05:06 GMT."); expect(result.html).toContain("Claim profile"); From a6e86311f73cd4a847786afad4a239cb2c85b5e2 Mon Sep 17 00:00:00 2001 From: babblebey Date: Thu, 9 Jul 2026 16:41:23 +0100 Subject: [PATCH 03/19] feat(branding): add brandingConfig to Family model and implement logotype font handling --- .../030-family-branded-instance-phase2/prd.md | 13 +- .../migration.sql | 2 + prisma/schema.prisma | 29 +++-- src/lib/branding/branding-config.ts | 70 ++++++++++ src/lib/branding/logotype-fonts.ts | 120 ++++++++++++++++++ test/lib/branding/logotype-fonts.test.ts | 73 +++++++++++ 6 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20260709120000_add_family_branding_config/migration.sql create mode 100644 src/lib/branding/branding-config.ts create mode 100644 src/lib/branding/logotype-fonts.ts create mode 100644 test/lib/branding/logotype-fonts.test.ts diff --git a/.project/prds/030-family-branded-instance-phase2/prd.md b/.project/prds/030-family-branded-instance-phase2/prd.md index 04bc421..fe88b39 100644 --- a/.project/prds/030-family-branded-instance-phase2/prd.md +++ b/.project/prds/030-family-branded-instance-phase2/prd.md @@ -149,11 +149,11 @@ Prework lockup touch matrix (must be verified complete before Phase 1 implementa #### Tasks -- [ ] Add nullable `brandingConfig` field to `Family` model in [prisma/schema.prisma](prisma/schema.prisma) using JSON type. -- [ ] Create and apply additive migration for new `brandingConfig` field under [prisma/migrations](prisma/migrations). -- [ ] Add typed branding schema validator (zod) for `brandingConfig` in [src/lib/branding/branding-config.ts](src/lib/branding/branding-config.ts). -- [ ] Add a provided font-list config module (font names + normalization helpers) in [src/lib/branding/logotype-fonts.ts](src/lib/branding/logotype-fonts.ts). -- [ ] Add helper to generate safe `api.fonts.coollabs.io` stylesheet URLs for selected allowlisted fonts in [src/lib/branding/logotype-fonts.ts](src/lib/branding/logotype-fonts.ts). +- [x] Add nullable `brandingConfig` field to `Family` model in [prisma/schema.prisma](prisma/schema.prisma) using JSON type. +- [x] Create and apply additive migration for new `brandingConfig` field under [prisma/migrations](prisma/migrations). +- [x] Add typed branding schema validator (zod) for `brandingConfig` in [src/lib/branding/branding-config.ts](src/lib/branding/branding-config.ts). +- [x] Add a provided font-list config module (font names + normalization helpers) in [src/lib/branding/logotype-fonts.ts](src/lib/branding/logotype-fonts.ts). +- [x] Add helper to generate safe `api.fonts.coollabs.io` stylesheet URLs for selected allowlisted fonts in [src/lib/branding/logotype-fonts.ts](src/lib/branding/logotype-fonts.ts). ### Phase 2: Owner/Admin Family Branding API @@ -205,7 +205,8 @@ Prework lockup touch matrix (must be verified complete before Phase 1 implementa - [ ] Add or extend brand-context/rendering tests for fallback lockup continuity when no logotype font is active in [test/lib/brand-context.test.ts](test/lib/brand-context.test.ts). - [ ] Add router-level tests for owner/admin mutation authorization and family scoping in [test/server/api/routers/invite.test.ts](test/server/api/routers/invite.test.ts). - [ ] Add tests for brandingConfig validation, normalization, and fallback behavior in [test/server/api/routers/invite.test.ts](test/server/api/routers/invite.test.ts). -- [ ] Run `pnpm lint`, `pnpm typecheck`, and targeted tests for touched modules. +- [x] Run `pnpm typecheck` and targeted tests for touched modules. +- [ ] Run `pnpm lint`. - [ ] Perform manual checks for desktop/mobile shell placements and loading state rendering. ## Acceptance Criteria diff --git a/prisma/migrations/20260709120000_add_family_branding_config/migration.sql b/prisma/migrations/20260709120000_add_family_branding_config/migration.sql new file mode 100644 index 0000000..705ddf1 --- /dev/null +++ b/prisma/migrations/20260709120000_add_family_branding_config/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Family" ADD COLUMN "brandingConfig" JSONB; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f841d2a..4289e1a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -83,22 +83,23 @@ enum NotificationDeliveryStatus { } model Family { - id String @id @default(cuid()) - name String - slug String @unique - image String? - description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - users User[] - members FamilyMember[] - invites Invite[] - domains Domain[] - features Ffeature[] + id String @id @default(cuid()) + name String + slug String @unique + image String? + description String? + brandingConfig Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users User[] + members FamilyMember[] + invites Invite[] + domains Domain[] + features Ffeature[] integrationCredentials IntegrationCredential[] - notifications Notification[] + notifications Notification[] notificationPreferences NotificationPreference[] - pushSubscriptions PushSubscription[] + pushSubscriptions PushSubscription[] } model Domain { diff --git a/src/lib/branding/branding-config.ts b/src/lib/branding/branding-config.ts new file mode 100644 index 0000000..e695b0d --- /dev/null +++ b/src/lib/branding/branding-config.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +import { + LOGOTYPE_FONT_PROVIDER, + buildLogotypeFontStylesheetUrl, + isLogotypeFontName, + normalizeLogotypeFontName, + resolveLogotypeFontName, +} from "./logotype-fonts"; + +const logotypeFontNameSchema = z.string().min(1).transform((value, ctx) => { + const resolvedFontName = resolveLogotypeFontName(value); + + if (!resolvedFontName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "fontName must be one of the provided allowlist values.", + }); + return z.NEVER; + } + + return resolvedFontName; +}); + +const logotypeConfigSchema = z + .object({ + enabled: z.boolean(), + fontName: logotypeFontNameSchema.optional(), + fontProvider: z.literal(LOGOTYPE_FONT_PROVIDER), + }) + .superRefine((value, ctx) => { + if (value.enabled && !value.fontName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["fontName"], + message: "fontName is required when logotype is enabled.", + }); + } + }); + +export const brandingConfigSchema = z.object({ + version: z.literal(1), + logotype: logotypeConfigSchema, +}); + +export type BrandingConfig = z.infer; + +export function parseBrandingConfig(value: unknown): BrandingConfig { + return brandingConfigSchema.parse(value); +} + +export function tryParseBrandingConfig(value: unknown): BrandingConfig | null { + const result = brandingConfigSchema.safeParse(value); + + return result.success ? result.data : null; +} + +export function getBrandingConfigFontStylesheetUrl(value: BrandingConfig): string | null { + if (!value.logotype.enabled || !value.logotype.fontName) { + return null; + } + + return buildLogotypeFontStylesheetUrl(value.logotype.fontName); +} + +export { + isLogotypeFontName, + normalizeLogotypeFontName, + resolveLogotypeFontName, +}; \ No newline at end of file diff --git a/src/lib/branding/logotype-fonts.ts b/src/lib/branding/logotype-fonts.ts new file mode 100644 index 0000000..d620e4e --- /dev/null +++ b/src/lib/branding/logotype-fonts.ts @@ -0,0 +1,120 @@ +export const LOGOTYPE_FONT_PROVIDER = "api.fonts.coollabs.io" as const; + +export const LOGOTYPE_FONT_NAMES = [ + "Aguafina Script", + "Alex Brush", + "Allura", + "Arizonia", + "Ballet", + "Beau Rivage", + "Bilbo", + "Bilbo Swash Caps", + "Birthstone", + "Bonheur Royale", + "Caramel", + "Carattere", + "Charm", + "Charmonman", + "Cherish", + "Clicker Script", + "Cookie", + "Corinthia", + "Dancing Script", + "Dynalight", + "Eagle Lake", + "Engagement", + "Ephesis", + "Estonia", + "Euphoria Script", + "Felipa", + "Fleur De Leah", + "Galada", + "Grand Hotel", + "Great Vibes", + "Grey Qo", + "Gwendolyn", + "Herr Von Muellerhoff", + "Hurricane", + "Imperial Script", + "Inspiration", + "Island Moments", + "Italianno", + "Kings", + "Lavishly Yours", + "Licorice", + "Lobster", + "Love Light", + "Lovers Quarrel", + "Lugrasimo", + "Luxurious Script", + "Manufacturing Consent", + "Marck Script", + "Mea Culpa", + "Meddon", + "Meow Script", + "Miss Fajardose", + "Molle", + "MonteCarlo", + "Montez", + "Mr Bedfort", + "Mr Dafoe", + "Mr De Haviland", + "Mrs Saint Delafield", + "Mrs Sheppards", + "Ms Madi", + "My Soul", + "Niconne", + "Norican", + "Oleo Script Swash Caps", + "Parisienne", + "Passions Conflict", + "Pattaya", + "Petemoss", + "Petit Formal Script", + "Pinyon Script", + "Puppies Play", + "Qwigley", + "Qwitcher Grypen", + "Rochester", + "Romanesco", + "Rouge Script", + "Ruthie", + "Sacramento", + "Sassy Frass", + "Send Flowers", + "Sofia", + "Stalemate", + "Style Script", + "Tangerine", + "The Nautigal", + "Updock", + "Waterfall", + "Whisper", + "WindSong", + "Yesteryear", +] as const; + +export type LogotypeFontName = (typeof LOGOTYPE_FONT_NAMES)[number]; + +export function normalizeLogotypeFontName(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function resolveLogotypeFontName(value: string): LogotypeFontName | null { + const normalized = normalizeLogotypeFontName(value); + + return (LOGOTYPE_FONT_NAMES as readonly string[]).includes(normalized) + ? (normalized as LogotypeFontName) + : null; +} + +export function isLogotypeFontName(value: string): value is LogotypeFontName { + return resolveLogotypeFontName(value) !== null; +} + +export function buildLogotypeFontStylesheetUrl(fontName: string): string { + const resolvedFontName = resolveLogotypeFontName(fontName) ?? normalizeLogotypeFontName(fontName); + const encodedFontName = encodeURIComponent(resolvedFontName).replace(/%20/g, "+"); + + return `https://${LOGOTYPE_FONT_PROVIDER}/css2?family=${encodedFontName}&display=swap`; +} \ No newline at end of file diff --git a/test/lib/branding/logotype-fonts.test.ts b/test/lib/branding/logotype-fonts.test.ts new file mode 100644 index 0000000..5a7dad9 --- /dev/null +++ b/test/lib/branding/logotype-fonts.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { + brandingConfigSchema, + getBrandingConfigFontStylesheetUrl, + parseBrandingConfig, +} from "~/lib/branding/branding-config"; +import { + LOGOTYPE_FONT_NAMES, + buildLogotypeFontStylesheetUrl, + resolveLogotypeFontName, +} from "~/lib/branding/logotype-fonts"; + +describe("logotype font allowlist", () => { + it("exposes the configured allowlist", () => { + expect(LOGOTYPE_FONT_NAMES).toContain("Manufacturing Consent"); + expect(LOGOTYPE_FONT_NAMES).toContain("Great Vibes"); + }); + + it("resolves known font names and rejects unknown ones", () => { + expect(resolveLogotypeFontName(" Manufacturing Consent ")).toBe("Manufacturing Consent"); + expect(resolveLogotypeFontName("Not A Real Font")).toBeNull(); + }); + + it("builds api.fonts.coollabs.io stylesheet URLs", () => { + expect(buildLogotypeFontStylesheetUrl("Manufacturing Consent")).toBe( + "https://api.fonts.coollabs.io/css2?family=Manufacturing+Consent&display=swap", + ); + }); +}); + +describe("brandingConfig schema", () => { + it("accepts normalized enabled logotype configs", () => { + const parsed = brandingConfigSchema.parse({ + version: 1, + logotype: { + enabled: true, + fontName: " Manufacturing Consent ", + fontProvider: "api.fonts.coollabs.io", + }, + }); + + expect(parsed.logotype.fontName).toBe("Manufacturing Consent"); + expect(getBrandingConfigFontStylesheetUrl(parsed)).toBe( + "https://api.fonts.coollabs.io/css2?family=Manufacturing+Consent&display=swap", + ); + }); + + it("rejects enabled configs without an allowlisted font", () => { + expect(() => + parseBrandingConfig({ + version: 1, + logotype: { + enabled: true, + fontProvider: "api.fonts.coollabs.io", + }, + }), + ).toThrow(/fontName is required when logotype is enabled/); + }); + + it("rejects non-allowlisted font names", () => { + const result = brandingConfigSchema.safeParse({ + version: 1, + logotype: { + enabled: true, + fontName: "Not A Font", + fontProvider: "api.fonts.coollabs.io", + }, + }); + + expect(result.success).toBe(false); + }); +}); \ No newline at end of file From d48ad708a967e07d760289ee0fde4867effdddaf Mon Sep 17 00:00:00 2001 From: babblebey Date: Thu, 9 Jul 2026 17:02:16 +0100 Subject: [PATCH 04/19] feat(branding): implement brandingConfig handling in invite router and tests --- .../030-family-branded-instance-phase2/prd.md | 13 +- src/server/api/routers/invite.ts | 28 ++- test/server/api/routers/invite.test.ts | 171 ++++++++++++++++++ 3 files changed, 204 insertions(+), 8 deletions(-) diff --git a/.project/prds/030-family-branded-instance-phase2/prd.md b/.project/prds/030-family-branded-instance-phase2/prd.md index fe88b39..b28709d 100644 --- a/.project/prds/030-family-branded-instance-phase2/prd.md +++ b/.project/prds/030-family-branded-instance-phase2/prd.md @@ -161,11 +161,11 @@ Prework lockup touch matrix (must be verified complete before Phase 1 implementa #### Tasks -- [ ] Extend Family identity input/output schemas in [src/server/api/routers/invite.ts](src/server/api/routers/invite.ts) to include `brandingConfig`. -- [ ] Validate `brandingConfig.logotype.fontName` against provided allowlist before save. -- [ ] Reuse existing family membership role checks (OWNER or ADMIN) for branding writes. -- [ ] Update management context response to include normalized brandingConfig output for consumers. -- [ ] Ensure invalid payloads are rejected with clear validation errors. +- [x] Extend Family identity input/output schemas in [src/server/api/routers/invite.ts](src/server/api/routers/invite.ts) to include `brandingConfig`. +- [x] Validate `brandingConfig.logotype.fontName` against provided allowlist before save. +- [x] Reuse existing family membership role checks (OWNER or ADMIN) for branding writes. +- [x] Update management context response to include normalized brandingConfig output for consumers. +- [x] Ensure invalid payloads are rejected with clear validation errors. ### Phase 3: Family Settings Management UI @@ -204,7 +204,8 @@ Prework lockup touch matrix (must be verified complete before Phase 1 implementa - [ ] Add tests for allowlist validation and `api.fonts.coollabs.io` URL generation in [test/lib/branding/logotype-fonts.test.ts](test/lib/branding/logotype-fonts.test.ts). - [ ] Add or extend brand-context/rendering tests for fallback lockup continuity when no logotype font is active in [test/lib/brand-context.test.ts](test/lib/brand-context.test.ts). - [ ] Add router-level tests for owner/admin mutation authorization and family scoping in [test/server/api/routers/invite.test.ts](test/server/api/routers/invite.test.ts). -- [ ] Add tests for brandingConfig validation, normalization, and fallback behavior in [test/server/api/routers/invite.test.ts](test/server/api/routers/invite.test.ts). +- [x] Add router-level tests for owner/admin mutation authorization and family scoping in [test/server/api/routers/invite.test.ts](test/server/api/routers/invite.test.ts). +- [x] Add tests for brandingConfig validation, normalization, and fallback behavior in [test/server/api/routers/invite.test.ts](test/server/api/routers/invite.test.ts). - [x] Run `pnpm typecheck` and targeted tests for touched modules. - [ ] Run `pnpm lint`. - [ ] Perform manual checks for desktop/mobile shell placements and loading state rendering. diff --git a/src/server/api/routers/invite.ts b/src/server/api/routers/invite.ts index fe62b66..8ef3c68 100644 --- a/src/server/api/routers/invite.ts +++ b/src/server/api/routers/invite.ts @@ -1,6 +1,6 @@ import bcrypt from "bcryptjs" import { TRPCError } from "@trpc/server" -import { type Prisma } from "../../../../generated/prisma" +import { Prisma } from "../../../../generated/prisma" import { z } from "zod" import { env } from "~/env" @@ -25,6 +25,7 @@ import { } from "~/lib/invite-schemas" import { normalizeEmail } from "~/lib/email" import { formatFamilyLockup, normalizeFamilyNameInput } from "~/lib/family-name" +import { brandingConfigSchema } from "~/lib/branding/branding-config" import { checkRateLimit, getClientIp } from "~/lib/rate-limit" import { getMemberSlugBase, resolveUniqueMemberSlug } from "~/lib/member-slug" import { findTenantUserByEmail } from "~/lib/tenant-users" @@ -62,8 +63,15 @@ const updateFamilyIdentityInputSchema = z.object({ }), description: z.string().trim().max(500).nullable(), image: familyImageInputSchema.nullable(), + brandingConfig: brandingConfigSchema.nullable().optional(), }) + function resolveBrandingConfigOutput(value: unknown) { + const parsed = brandingConfigSchema.safeParse(value) + + return parsed.success ? parsed.data : null + } + export const inviteRouter = createTRPCRouter({ /** * Public query: Get invite details by code for pre-acceptance viewing. @@ -401,6 +409,7 @@ export const inviteRouter = createTRPCRouter({ name: true, description: true, image: true, + brandingConfig: true, }, }) : null @@ -412,6 +421,7 @@ export const inviteRouter = createTRPCRouter({ name: selectedFamily.name, description: selectedFamily.description, image: selectedFamily.image, + brandingConfig: resolveBrandingConfigOutput(selectedFamily.brandingConfig), } : null, role: selectedMembership?.role ?? null, @@ -443,22 +453,36 @@ export const inviteRouter = createTRPCRouter({ }) } + const brandingConfigUpdateValue = + input.brandingConfig === undefined + ? undefined + : input.brandingConfig === null + ? Prisma.JsonNull + : input.brandingConfig + const updatedFamily = await ctx.db.family.update({ where: { id: input.familyId }, data: { name: input.name, description: input.description, image: input.image, + ...(brandingConfigUpdateValue === undefined + ? {} + : { brandingConfig: brandingConfigUpdateValue }), }, select: { id: true, name: true, description: true, image: true, + brandingConfig: true, }, }) - return updatedFamily + return { + ...updatedFamily, + brandingConfig: resolveBrandingConfigOutput(updatedFamily.brandingConfig), + } }), /** diff --git a/test/server/api/routers/invite.test.ts b/test/server/api/routers/invite.test.ts index 18746c8..51d84c4 100644 --- a/test/server/api/routers/invite.test.ts +++ b/test/server/api/routers/invite.test.ts @@ -1,5 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const mockedEnv = vi.hoisted(() => ({ + DATABASE_URL: "postgresql://user:pass@localhost:5432/fircle_test", + SELF_HOSTED: true, + STORAGE_DRIVER: "r2", + NODE_ENV: "test", + EMAIL_FROM_ADDRESS: "no-reply@example.com", + EMAIL_FROM_NAME: "Fircle", +})); + +vi.mock("~/env", () => ({ + env: mockedEnv, +})); + vi.mock("~/server/auth", () => ({ auth: vi.fn(), })); @@ -571,3 +584,161 @@ describe("inviteRouter.retryEmailSend", () => { await expect(retryCall).rejects.toThrow(/claimed/i); }); }); + +describe("inviteRouter.updateFamilyIdentity brandingConfig", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + const familyId = "clh0000000000000000000601"; + const memberId = "clh0000000000000000000602"; + + it("stores normalized brandingConfig for owners and admins", async () => { + const update = vi.fn().mockResolvedValue({ + id: familyId, + name: "Ng", + description: "Private family space", + image: null, + brandingConfig: { + version: 1, + logotype: { + enabled: true, + fontName: "Manufacturing Consent", + fontProvider: "api.fonts.coollabs.io", + }, + }, + }); + + const db = { + familyMember: { + findUnique: vi.fn().mockResolvedValue({ + id: memberId, + familyId, + role: "OWNER", + }), + }, + family: { + update, + }, + } as never; + + const caller = createCaller(db); + + const result = await caller.updateFamilyIdentity({ + familyId, + name: "Ng", + description: "Private family space", + image: null, + brandingConfig: { + version: 1, + logotype: { + enabled: true, + fontName: " Manufacturing Consent ", + fontProvider: "api.fonts.coollabs.io", + }, + }, + }); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + brandingConfig: { + version: 1, + logotype: { + enabled: true, + fontName: "Manufacturing Consent", + fontProvider: "api.fonts.coollabs.io", + }, + }, + }), + }), + ); + expect(result.brandingConfig).toEqual({ + version: 1, + logotype: { + enabled: true, + fontName: "Manufacturing Consent", + fontProvider: "api.fonts.coollabs.io", + }, + }); + }); + + it("rejects non-allowlisted branding font names before save", async () => { + const db = { + familyMember: { + findUnique: vi.fn(), + }, + family: { + update: vi.fn(), + }, + } as never; + + const caller = createCaller(db); + + await expect( + caller.updateFamilyIdentity({ + familyId, + name: "Ng", + description: null, + image: null, + brandingConfig: { + version: 1, + logotype: { + enabled: true, + fontName: "Not A Real Font", + fontProvider: "api.fonts.coollabs.io", + }, + }, + }), + ).rejects.toThrow(/fontName/i); + + expect((db as { family: { update: ReturnType } }).family.update).not.toHaveBeenCalled(); + }); + + it("returns normalized brandingConfig in management context", async () => { + const db = { + familyMember: { + findMany: vi.fn().mockResolvedValue([ + { + familyId, + role: "ADMIN", + createdAt: new Date("2030-01-01T00:00:00.000Z"), + }, + ]), + }, + family: { + findUnique: vi.fn().mockResolvedValue({ + id: familyId, + name: "Ng", + description: "Private family space", + image: null, + brandingConfig: { + version: 1, + logotype: { + enabled: true, + fontName: " Manufacturing Consent ", + fontProvider: "api.fonts.coollabs.io", + }, + }, + }), + }, + } as never; + + const caller = createCaller(db); + const result = await caller.getManagementContext(); + + expect(result.family).toMatchObject({ + id: familyId, + name: "Ng", + brandingConfig: { + version: 1, + logotype: { + enabled: true, + fontName: "Manufacturing Consent", + fontProvider: "api.fonts.coollabs.io", + }, + }, + }); + }); +}); From 0172f98ee53d03aa6da8760dd6f80f6b4064e9f7 Mon Sep 17 00:00:00 2001 From: babblebey Date: Thu, 9 Jul 2026 17:05:45 +0100 Subject: [PATCH 05/19] feat(branding): implement logotype font selection and preview in Family settings --- .../030-family-branded-instance-phase2/prd.md | 12 +- src/app/(app)/settings/family/page.tsx | 135 +++++++++++++++++- 2 files changed, 140 insertions(+), 7 deletions(-) diff --git a/.project/prds/030-family-branded-instance-phase2/prd.md b/.project/prds/030-family-branded-instance-phase2/prd.md index b28709d..fe468b5 100644 --- a/.project/prds/030-family-branded-instance-phase2/prd.md +++ b/.project/prds/030-family-branded-instance-phase2/prd.md @@ -173,12 +173,12 @@ Prework lockup touch matrix (must be verified complete before Phase 1 implementa #### Tasks -- [ ] Extend Family Settings data loading to include `brandingConfig` and provided allowlist metadata in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). -- [ ] Add font selector controls from the provided allowlist, with explicit default option (disable logotype) in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). -- [ ] Load preview font stylesheets from `api.fonts.coollabs.io` for selected allowlisted font names in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). -- [ ] Add live preview based on resolved selected font using existing family name context in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). -- [ ] Show remediation messaging when brandingConfig is invalid or fontName is not allowlisted. -- [ ] Preserve existing settings permission and navigation behavior in [src/app/(app)/settings/layout.tsx](src/app/(app)/settings/layout.tsx). +- [x] Extend Family Settings data loading to include `brandingConfig` and provided allowlist metadata in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). +- [x] Add font selector controls from the provided allowlist, with explicit default option (disable logotype) in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). +- [x] Load preview font stylesheets from `api.fonts.coollabs.io` for selected allowlisted font names in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). +- [x] Add live preview based on resolved selected font using existing family name context in [src/app/(app)/settings/family/page.tsx](src/app/(app)/settings/family/page.tsx). +- [x] Show remediation messaging when brandingConfig is invalid or fontName is not allowlisted. +- [x] Preserve existing settings permission and navigation behavior in [src/app/(app)/settings/layout.tsx](src/app/(app)/settings/layout.tsx). ### Phase 4: Surface Rollout Using Resolved Font diff --git a/src/app/(app)/settings/family/page.tsx b/src/app/(app)/settings/family/page.tsx index 586bc17..1289b56 100644 --- a/src/app/(app)/settings/family/page.tsx +++ b/src/app/(app)/settings/family/page.tsx @@ -10,6 +10,13 @@ import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Skeleton } from "~/components/ui/skeleton"; +import { tryParseBrandingConfig } from "~/lib/branding/branding-config"; +import { + LOGOTYPE_FONT_NAMES, + LOGOTYPE_FONT_PROVIDER, + buildLogotypeFontStylesheetUrl, + resolveLogotypeFontName, +} from "~/lib/branding/logotype-fonts"; import { compressImage, createInstantPreviewUrl, resolveMediaMimeType } from "~/lib/media-compression"; import { api } from "~/trpc/react"; @@ -36,6 +43,7 @@ const managementContextSchema = z.object({ name: z.string(), description: z.string().nullable(), image: z.string().nullable(), + brandingConfig: z.unknown().nullable().optional(), }) .nullable(), role: z.enum(["OWNER", "ADMIN", "MEMBER"]).nullable(), @@ -99,6 +107,7 @@ export default function FamilySettingsPage() { const [selectedImagePreviewUrl, setSelectedImagePreviewUrl] = useState(null); const [isPreviewConverting, setIsPreviewConverting] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); + const [selectedLogotypeFontName, setSelectedLogotypeFontName] = useState(""); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [saveSuccess, setSaveSuccess] = useState(null); @@ -126,6 +135,21 @@ export default function FamilySettingsPage() { const familyId = managementContextData?.family?.id; const canManageFamilyIdentity = managementContextData?.role === "ADMIN" || managementContextData?.role === "OWNER"; + const brandingConfig = managementContextData?.family?.brandingConfig ?? null; + const resolvedSelectedLogotypeFont = + resolveLogotypeFontName(selectedLogotypeFontName); + const resolvedExistingBrandingConfig = tryParseBrandingConfig(brandingConfig); + const hasInvalidBrandingConfig = brandingConfig !== null && resolvedExistingBrandingConfig === null; + const previewStylesheetUrl = resolvedSelectedLogotypeFont + ? buildLogotypeFontStylesheetUrl(resolvedSelectedLogotypeFont) + : null; + const logotypeFontMetadata = useMemo( + () => ({ + provider: LOGOTYPE_FONT_PROVIDER, + names: [...LOGOTYPE_FONT_NAMES], + }), + [], + ); const previewImage = selectedImagePreviewUrl ?? familyImageUrl; const previewName = familyName.trim().length > 0 ? familyName : "Family"; @@ -138,9 +162,42 @@ export default function FamilySettingsPage() { setFamilyName(family.name); setFamilyDescription(family.description ?? ""); setFamilyImageUrl(family.image ?? ""); + setSelectedLogotypeFontName( + resolvedExistingBrandingConfig?.logotype.enabled + ? (resolvedExistingBrandingConfig.logotype.fontName ?? "") + : "", + ); setSaveError(null); setIsPreviewConverting(false); - }, [managementContextData?.family]); + }, [managementContextData?.family, resolvedExistingBrandingConfig]); + + useEffect(() => { + if (!previewStylesheetUrl) { + return; + } + + const preconnectSelector = "link[data-logotype-font-preconnect='1']"; + const existingPreconnect = document.head.querySelector(preconnectSelector); + + if (!existingPreconnect) { + const preconnectLink = document.createElement("link"); + preconnectLink.rel = "preconnect"; + preconnectLink.href = "https://api.fonts.coollabs.io"; + preconnectLink.crossOrigin = "anonymous"; + preconnectLink.dataset.logotypeFontPreconnect = "1"; + document.head.appendChild(preconnectLink); + } + + const stylesheetLink = document.createElement("link"); + stylesheetLink.rel = "stylesheet"; + stylesheetLink.href = previewStylesheetUrl; + stylesheetLink.dataset.logotypeFontPreview = "1"; + document.head.appendChild(stylesheetLink); + + return () => { + stylesheetLink.remove(); + }; + }, [previewStylesheetUrl]); useEffect(() => { return () => { @@ -296,6 +353,16 @@ export default function FamilySettingsPage() { description: normalizedFamilyDescription.length > 0 ? normalizedFamilyDescription : null, image: nextFamilyImageUrl.length > 0 ? nextFamilyImageUrl : null, + brandingConfig: resolvedSelectedLogotypeFont + ? { + version: 1, + logotype: { + enabled: true, + fontName: resolvedSelectedLogotypeFont, + fontProvider: LOGOTYPE_FONT_PROVIDER, + }, + } + : null, }); await trpcUtils.invite.getManagementContext.invalidate(); @@ -454,6 +521,72 @@ export default function FamilySettingsPage() {

{familyDescription.length}/500

+
+
+

Family logotype font

+

+ Choose from {logotypeFontMetadata.names.length} allowlisted fonts served by {logotypeFontMetadata.provider}. +

+
+ +
+ + +
+ +
+

Live preview

+ {resolvedSelectedLogotypeFont ? ( +
+ The + + {previewName} + + Fircle +
+ ) : ( +
+ Fircle + Default fallback preview +
+ )} +
+ + {hasInvalidBrandingConfig ? ( +

+ Your saved logotype configuration is invalid or uses a font outside the allowlist. Choose a new allowlisted font and save to fix it. +

+ ) : null} + + {!resolvedSelectedLogotypeFont ? ( +

+ Custom logotype is disabled. Navigation surfaces will use the fallback Fircle lockup. +

+ ) : null} +
+ {saveError ? (

{saveError} From 79775d27b7568061a8ae08ce64359c72d1b9070b Mon Sep 17 00:00:00 2001 From: babblebey Date: Thu, 9 Jul 2026 18:56:12 +0100 Subject: [PATCH 06/19] feat(branding): enhance logotype preview styling and add text stroke effect --- src/app/(app)/settings/family/page.tsx | 24 ++++++++++++++++-------- src/styles/globals.css | 5 +++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/app/(app)/settings/family/page.tsx b/src/app/(app)/settings/family/page.tsx index 1289b56..5c83d2a 100644 --- a/src/app/(app)/settings/family/page.tsx +++ b/src/app/(app)/settings/family/page.tsx @@ -553,23 +553,31 @@ export default function FamilySettingsPage() { -

+

Live preview

{resolvedSelectedLogotypeFont ? ( -
- The +
+ The + + {previewName} - Fircle + + Fircle +
) : ( -
- Fircle - Default fallback preview +
+ Fircle + Default fallback preview
)}
diff --git a/src/styles/globals.css b/src/styles/globals.css index 374acde..b7a0738 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -205,3 +205,8 @@ transform: rotate(3deg) translate(0, -4px); /* box-shadow: 0 0 10px var(--primary), 0 0 5px var(--primary); */ } + +.logo-parts-stroke { + -webkit-text-stroke: 0.05px var(--color-background); + text-stroke: 0.05px var(--color-background); +} From 1f82b69326b9324f90d806813c9f4640e4bf352f Mon Sep 17 00:00:00 2001 From: babblebey Date: Thu, 9 Jul 2026 19:14:47 +0100 Subject: [PATCH 07/19] feat(branding): integrate Logo component for enhanced logotype preview in Family settings --- src/app/(app)/settings/family/page.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/(app)/settings/family/page.tsx b/src/app/(app)/settings/family/page.tsx index 5c83d2a..6abe0e0 100644 --- a/src/app/(app)/settings/family/page.tsx +++ b/src/app/(app)/settings/family/page.tsx @@ -9,6 +9,7 @@ import { AlertCircle, Camera, Loader } from "~/components/ui/icons"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; +import { Logo } from "~/components/ui/logo"; import { Skeleton } from "~/components/ui/skeleton"; import { tryParseBrandingConfig } from "~/lib/branding/branding-config"; import { @@ -555,8 +556,9 @@ export default function FamilySettingsPage() {

Live preview

+
{resolvedSelectedLogotypeFont ? ( -
+
@@ -575,11 +577,15 @@ export default function FamilySettingsPage() {
) : ( -
- Fircle - Default fallback preview -
- )} + <> + + + Default fallback preview + + )} +
{hasInvalidBrandingConfig ? ( From 94066011c21e8de787f3dfdc37436a711a744441 Mon Sep 17 00:00:00 2001 From: babblebey Date: Thu, 9 Jul 2026 20:06:52 +0100 Subject: [PATCH 08/19] feat(branding): implement FamilyLogotypeLockup component and integrate logotype font rendering in navigation --- .../030-family-branded-instance-phase2/prd.md | 16 ++-- src/app/(app)/settings/family/page.tsx | 55 ++---------- src/components/auth/membership-guard.tsx | 21 ++++- .../branding/family-logotype-lockup.tsx | 83 +++++++++++++++++++ src/components/nav/desktop-sidebar.tsx | 26 +++++- src/components/nav/mobile-header.tsx | 45 ++++++++-- 6 files changed, 175 insertions(+), 71 deletions(-) create mode 100644 src/components/branding/family-logotype-lockup.tsx diff --git a/.project/prds/030-family-branded-instance-phase2/prd.md b/.project/prds/030-family-branded-instance-phase2/prd.md index fe468b5..5c71976 100644 --- a/.project/prds/030-family-branded-instance-phase2/prd.md +++ b/.project/prds/030-family-branded-instance-phase2/prd.md @@ -186,14 +186,14 @@ Prework lockup touch matrix (must be verified complete before Phase 1 implementa #### Tasks -- [ ] Wire resolved selected font from `brandingConfig` into desktop sidebar brand rendering in [src/components/nav/desktop-sidebar.tsx](src/components/nav/desktop-sidebar.tsx). -- [ ] Wire resolved selected font from `brandingConfig` into mobile center header and menu sheet header brand rendering in [src/components/nav/mobile-header.tsx](src/components/nav/mobile-header.tsx). -- [ ] Wire resolved selected font from `brandingConfig` into membership loading footer brand rendering in [src/components/auth/membership-guard.tsx](src/components/auth/membership-guard.tsx). -- [ ] Implement lockup layout rules in nav/membership logotype views: `The` top-left small sans, `FamilyName` custom font, `Fircle` bottom-right small sans. -- [ ] Enforce nav fallback when logotype is not enabled: show `Fircle` text with favicon in [src/components/nav/desktop-sidebar.tsx](src/components/nav/desktop-sidebar.tsx) and [src/components/nav/mobile-header.tsx](src/components/nav/mobile-header.tsx). -- [ ] Enforce membership guard fallback when logotype is not enabled: no logo mark is rendered while `Powered by Fircle` remains unchanged in [src/components/auth/membership-guard.tsx](src/components/auth/membership-guard.tsx). -- [ ] Keep host-aware family lockup fallback behavior sourced from existing brand context where applicable in [src/lib/brand-context.ts](src/lib/brand-context.ts). -- [ ] Ensure no branding reads depend on ffeatures paths for logotype selection. +- [x] Wire resolved selected font from `brandingConfig` into desktop sidebar brand rendering in [src/components/nav/desktop-sidebar.tsx](src/components/nav/desktop-sidebar.tsx). +- [x] Wire resolved selected font from `brandingConfig` into mobile center header and menu sheet header brand rendering in [src/components/nav/mobile-header.tsx](src/components/nav/mobile-header.tsx). +- [x] Wire resolved selected font from `brandingConfig` into membership loading footer brand rendering in [src/components/auth/membership-guard.tsx](src/components/auth/membership-guard.tsx). +- [x] Implement lockup layout rules in nav/membership logotype views: `The` top-left small sans, `FamilyName` custom font, `Fircle` bottom-right small sans. +- [x] Enforce nav fallback when logotype is not enabled: show `Fircle` text with favicon in [src/components/nav/desktop-sidebar.tsx](src/components/nav/desktop-sidebar.tsx) and [src/components/nav/mobile-header.tsx](src/components/nav/mobile-header.tsx). +- [x] Enforce membership guard fallback when logotype is not enabled: no logo mark is rendered while `Powered by Fircle` remains unchanged in [src/components/auth/membership-guard.tsx](src/components/auth/membership-guard.tsx). +- [x] Keep host-aware family lockup fallback behavior sourced from existing brand context where applicable in [src/lib/brand-context.ts](src/lib/brand-context.ts). +- [x] Ensure no branding reads depend on ffeatures paths for logotype selection. ### Phase 5: Verification and Regression Safety diff --git a/src/app/(app)/settings/family/page.tsx b/src/app/(app)/settings/family/page.tsx index 6abe0e0..93f9451 100644 --- a/src/app/(app)/settings/family/page.tsx +++ b/src/app/(app)/settings/family/page.tsx @@ -7,6 +7,7 @@ import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { AlertCircle, Camera, Loader } from "~/components/ui/icons"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { FamilyLogotypeLockup } from "~/components/branding/family-logotype-lockup"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Logo } from "~/components/ui/logo"; @@ -15,7 +16,6 @@ import { tryParseBrandingConfig } from "~/lib/branding/branding-config"; import { LOGOTYPE_FONT_NAMES, LOGOTYPE_FONT_PROVIDER, - buildLogotypeFontStylesheetUrl, resolveLogotypeFontName, } from "~/lib/branding/logotype-fonts"; import { compressImage, createInstantPreviewUrl, resolveMediaMimeType } from "~/lib/media-compression"; @@ -141,9 +141,6 @@ export default function FamilySettingsPage() { resolveLogotypeFontName(selectedLogotypeFontName); const resolvedExistingBrandingConfig = tryParseBrandingConfig(brandingConfig); const hasInvalidBrandingConfig = brandingConfig !== null && resolvedExistingBrandingConfig === null; - const previewStylesheetUrl = resolvedSelectedLogotypeFont - ? buildLogotypeFontStylesheetUrl(resolvedSelectedLogotypeFont) - : null; const logotypeFontMetadata = useMemo( () => ({ provider: LOGOTYPE_FONT_PROVIDER, @@ -172,34 +169,6 @@ export default function FamilySettingsPage() { setIsPreviewConverting(false); }, [managementContextData?.family, resolvedExistingBrandingConfig]); - useEffect(() => { - if (!previewStylesheetUrl) { - return; - } - - const preconnectSelector = "link[data-logotype-font-preconnect='1']"; - const existingPreconnect = document.head.querySelector(preconnectSelector); - - if (!existingPreconnect) { - const preconnectLink = document.createElement("link"); - preconnectLink.rel = "preconnect"; - preconnectLink.href = "https://api.fonts.coollabs.io"; - preconnectLink.crossOrigin = "anonymous"; - preconnectLink.dataset.logotypeFontPreconnect = "1"; - document.head.appendChild(preconnectLink); - } - - const stylesheetLink = document.createElement("link"); - stylesheetLink.rel = "stylesheet"; - stylesheetLink.href = previewStylesheetUrl; - stylesheetLink.dataset.logotypeFontPreview = "1"; - document.head.appendChild(stylesheetLink); - - return () => { - stylesheetLink.remove(); - }; - }, [previewStylesheetUrl]); - useEffect(() => { return () => { if (selectedImagePreviewUrl) { @@ -558,24 +527,10 @@ export default function FamilySettingsPage() {

Live preview

{resolvedSelectedLogotypeFont ? ( -
- - The - - - {previewName} - - - Fircle - -
+ ) : ( <> diff --git a/src/components/auth/membership-guard.tsx b/src/components/auth/membership-guard.tsx index 42871e0..5017917 100644 --- a/src/components/auth/membership-guard.tsx +++ b/src/components/auth/membership-guard.tsx @@ -2,8 +2,11 @@ import { useEffect, useRef } from "react"; import { signOut } from "next-auth/react"; +import { FamilyLogotypeLockup } from "~/components/branding/family-logotype-lockup"; import { Loader } from "~/components/ui/icons"; import { Logo } from "~/components/ui/logo"; +import { tryParseBrandingConfig } from "~/lib/branding/branding-config"; +import { normalizeFamilyNameInput } from "~/lib/family-name"; import { api } from "~/trpc/react"; @@ -21,6 +24,11 @@ export function MembershipGuard({ refetchOnWindowFocus: false, }); + const parsedBrandingConfig = tryParseBrandingConfig(managementContext.data?.family?.brandingConfig ?? null); + const selectedLogotypeFontName = + parsedBrandingConfig?.logotype.enabled ? (parsedBrandingConfig.logotype.fontName ?? null) : null; + const selectedFamilyName = normalizeFamilyNameInput(managementContext.data?.family?.name ?? "") || "Family"; + const hasFamilyMembership = Boolean(managementContext.data?.family?.id); useEffect(() => { @@ -36,10 +44,17 @@ export function MembershipGuard({ return (
-
-
+ )}
diff --git a/src/components/branding/family-logotype-lockup.tsx b/src/components/branding/family-logotype-lockup.tsx new file mode 100644 index 0000000..5a29361 --- /dev/null +++ b/src/components/branding/family-logotype-lockup.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect } from "react"; + +import { buildLogotypeFontStylesheetUrl } from "~/lib/branding/logotype-fonts"; +import { cn } from "~/lib/utils"; + +type FamilyLogotypeLockupProps = { + familyName: string; + fontName: string; + className?: string; + familyNameClassName?: string; + leadingClassName?: string; + trailingClassName?: string; +}; + +export function FamilyLogotypeLockup({ + familyName, + fontName, + className, + familyNameClassName, + leadingClassName, + trailingClassName, +}: FamilyLogotypeLockupProps) { + const stylesheetUrl = buildLogotypeFontStylesheetUrl(fontName); + + useEffect(() => { + if (!document.head.querySelector("link[data-logotype-font-preconnect='1']")) { + const preconnectLink = document.createElement("link"); + preconnectLink.rel = "preconnect"; + preconnectLink.href = "https://api.fonts.coollabs.io"; + preconnectLink.crossOrigin = "anonymous"; + preconnectLink.dataset.logotypeFontPreconnect = "1"; + document.head.appendChild(preconnectLink); + } + + if ( + document.head.querySelector( + `link[data-logotype-font-stylesheet='1'][href='${stylesheetUrl}']`, + ) + ) { + return; + } + + const stylesheetLink = document.createElement("link"); + stylesheetLink.rel = "stylesheet"; + stylesheetLink.href = stylesheetUrl; + stylesheetLink.dataset.logotypeFontStylesheet = "1"; + document.head.appendChild(stylesheetLink); + }, [stylesheetUrl]); + + return ( + + + The + + + {familyName} + + + Fircle + + + ); +} diff --git a/src/components/nav/desktop-sidebar.tsx b/src/components/nav/desktop-sidebar.tsx index e36cabc..578f4a7 100644 --- a/src/components/nav/desktop-sidebar.tsx +++ b/src/components/nav/desktop-sidebar.tsx @@ -5,11 +5,14 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Bell, House, Image, Plus, Settings, Sparkles, User, Users } from "~/components/ui/icons"; +import { FamilyLogotypeLockup } from "~/components/branding/family-logotype-lockup"; import { useGlobalComposer } from "~/components/feed/global-composer-provider"; import { formatUnreadBadgeCount } from "~/components/nav/unread-badge"; import { ThemeToggle } from "~/components/theme-toggle"; import { Button } from "~/components/ui/button"; import { getFeatureNavigationMetadata } from "~/lib/ffeatures/activation"; +import { tryParseBrandingConfig } from "~/lib/branding/branding-config"; +import { normalizeFamilyNameInput } from "~/lib/family-name"; import { cn } from "~/lib/utils"; import { api } from "~/trpc/react"; import { Logo } from "~/components/ui/logo"; @@ -49,6 +52,10 @@ export function DesktopSidebar({ primaryLockup }: { primaryLockup: string }) { }); const familyId = managementContext.data?.family?.id; + const parsedBrandingConfig = tryParseBrandingConfig(managementContext.data?.family?.brandingConfig ?? null); + const selectedLogotypeFontName = + parsedBrandingConfig?.logotype.enabled ? (parsedBrandingConfig.logotype.fontName ?? null) : null; + const selectedFamilyName = normalizeFamilyNameInput(managementContext.data?.family?.name ?? "") || "Family"; const unreadCountQuery = api.notification.getUnreadCount.useQuery( { familyId: familyId ?? "", @@ -88,10 +95,23 @@ export function DesktopSidebar({ primaryLockup }: { primaryLockup: string }) { return (