From de9d97be9a4cd4bac7a274b37a3f268bc2169b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 06:11:30 +0000 Subject: [PATCH 1/3] feat(design-system): give the brand a written identity and a per-ground mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related things the design system did not have: one home for the product's written identity, a colour role for the logo, and a phone drawer header built for a phone. **Brand copy had four homes.** The page metadata, the web manifest, the OG card and the install prompt each carried a hand-typed sentence, and two of them disagreed about whether PsychSift is a "RAG knowledge base" or a "knowledge base". Nobody decided to say two things; the second sentence was written later by someone who could not see the first. `src/lib/brand.ts` now owns the name, the catchphrase and the descriptive lines, and `tests/brand-copy.test.ts` fails if any of them is retyped as a literal outside that module. The catchphrase is "From question to source." It promises a path, not a verdict — which is the only kind of claim this product may make, given it is a clinical reference prototype and not validated decision support. A test enforces that boundary in words: the line may not contain trust/accuracy/safety claims. The wording is not new, either; `guide-content.ts` already taught the "evidence-first workflow from question to source". "RAG" also leaves the user-facing description. It is accurate internally and meaningless on an install sheet. **The logo had no colour role of its own.** It read `--clinical-accent` directly, so a future accent retune for selection/evidence/send would have silently repainted the brand, and a surface needing the mark to hold its ground had nowhere to go but a pressed-state token. Adds `--brand-mark`, `--brand-mark-emphasis` and `--brand-mark-contrast` as aliases onto the accent ramp — one declaration each, since `.dark` and the forced-colors block both land on `` — plus a `tone` prop on ``. Every production chrome surface now asks for `emphasis`. The mark is two counter-turning strokes whose lower one is the thinner read, so at chrome sizes (28-40px) on a near-white band the resting accent let the bottom of the S dissolve into the ground while the dot-and-bowl cluster stayed solid: the mark went top-heavy. Emphasis is a full ramp step, so it restores an even read without touching the geometry. Mockups keep the resting tone. **The phone drawer header spent ~160px saying what the drawer is.** A third of the first screenful above the notch on a 390px phone, to a user who had just tapped the menu button and could see it — and the description wrapped to two lines because its column is squeezed between the mark and a 48px close control. It now reads as a brand lockup: mark, wordmark, one strapline that cannot wrap, and a close control that keeps its full 48px tap target while giving up the boxed chrome that made it the loudest thing in the header. The functional sentence moves to `sr-only`, where it is still the accessible description — a screen-reader user wants to know what the dialog contains, not what the product stands for, so the two audiences get different sentences instead of one compromise serving neither. The desktop sidebar takes the same lockup. These are overrides on the shared Sheet, not edits to it: every other dialog uses that header, and the case for a compact brand header is specific to a navigation drawer already showing its own contents. Verification - Full unit suite: 11721 passed | 4 skipped, 929 files. 5 pre-existing failures in clinical-hazard-controls, privacy-readiness-contract, rag-plan-package-parity and caring-contacts-contact-time-adjustment — all reproduced on a clean stashed tree (6 there; one caring-contacts case is flaky), none in this diff. - Design-system contract passed; every ratchet at or under baseline. The hardcoded-CSS-duration count reads 25 against a baseline of 26 on a clean tree too, so it is pre-existing slack, not this change masking a finding. - check:type-scale, check:icon-scale clean. Lint clean at --max-warnings 0 on the changed files. Typecheck exit 0, unscoped. Whole-tree format committed. Not done here, and recorded rather than guessed: the app renders the mark's display geometry at every chrome size, where docs/brand/psychsift-logo.md describes a small-size optical cut (a widened stroke gap and a point slid out of its cradle) for exactly that band. That is the root cause of the top-heavy read that the emphasis tone compensates for, and fixing it properly means new brand geometry — an artwork decision, not a styling one. RAG impact: no retrieval behaviour change — this diff touches no file under src/lib/rag/** and none of the protected ranking surfaces (clinical-search, retrieval-selection, released-search-order, ranking-config, answer-ranking, answer-verification), adds no comparator key, and leaves the golden fixture and eval harness untouched. Verified by listing the diff's paths against that set. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YRGeyyiZNrXnFJjVVnykgp --- src/app/globals.css | 21 ++++ src/app/layout.tsx | 9 +- src/app/manifest.ts | 10 +- src/app/opengraph-image.tsx | 7 +- .../clinical-dashboard/ClinicalSidebar.tsx | 74 +++++++++-- .../account-setup-dialog.tsx | 2 +- src/components/clinical-dashboard/brand.tsx | 35 +++++- .../master-search-header.tsx | 2 +- src/components/privacy-quiet-signal-page.tsx | 2 +- src/components/pwa-lifecycle.tsx | 13 +- .../ward-management-navigation.tsx | 8 +- .../ward-management/ward-sidebar-content.tsx | 2 +- src/lib/brand.ts | 77 ++++++++++++ tests/brand-copy.test.ts | 119 ++++++++++++++++++ 14 files changed, 342 insertions(+), 39 deletions(-) create mode 100644 src/lib/brand.ts create mode 100644 tests/brand-copy.test.ts diff --git a/src/app/globals.css b/src/app/globals.css index 092e89a9d4..ddbfffd56b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -410,6 +410,27 @@ --clinical-accent-soft: var(--primary-soft); --clinical-accent-border: #c6dcf2; --clinical-accent-contrast: #ffffff; + + /* Brand-mark ink. The logo is identity, not UI state, so it carries its own + role rather than reading --clinical-accent directly: retuning the accent for + selection/evidence/send must not silently repaint the brand, and a surface + that needs the mark to hold its ground can ask for the emphasis role by name + instead of borrowing a pressed-state colour. + + Why an emphasis role exists at all: the mark is drawn as two counter-turning + strokes whose lower one is the thinner read, so at chrome sizes (28-40px) on + a near-white band the resting accent lets the bottom of the S dissolve into + the ground while the dot-and-bowl cluster at the top stays solid — the mark + goes top-heavy. Emphasis is a full ramp step, not a nudge, and restores an + even read without changing the geometry. + + All three are aliases, so light, dark and forced-colors are inherited from + the accent ramp with no second definition to keep in sync. `.dark` and the + forced-colors block both land on , the same element as `:root`, so the + var() substitution below resolves against whichever theme is winning. */ + --brand-mark: var(--clinical-accent); + --brand-mark-emphasis: var(--clinical-accent-strong); + --brand-mark-contrast: var(--clinical-accent-contrast); /* Tinted filter-count pill on the results band — quieter than a solid disc. */ --search-band-badge-bg: color-mix(in oklab, var(--clinical-accent) 16%, transparent); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9659cc5a66..3eae06dd42 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -11,6 +11,7 @@ import { MobileKeyboardProvider } from "@/components/use-mobile-keyboard"; import { AppAnnouncements } from "@/components/app-announcements"; import { OverlayRoot } from "@/components/ui/overlay-root"; import { PRIVATE_APP_ROBOTS_METADATA } from "@/lib/crawler-policy"; +import { BRAND_DESCRIPTION, BRAND_NAME } from "@/lib/brand"; import "./globals.css"; /** @@ -58,13 +59,13 @@ const geistMono = localFont({ }); const baseMetadata: Metadata = { - applicationName: "PsychSift", - title: "PsychSift", - description: "Private medical guideline RAG knowledge base", + applicationName: BRAND_NAME, + title: BRAND_NAME, + description: BRAND_DESCRIPTION, robots: PRIVATE_APP_ROBOTS_METADATA, appleWebApp: { capable: true, - title: "PsychSift", + title: BRAND_NAME, statusBarStyle: "black-translucent", }, }; diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 997d2d7467..3528f4698c 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -1,5 +1,7 @@ import type { MetadataRoute } from "next"; +import { BRAND_DESCRIPTION, BRAND_NAME } from "@/lib/brand"; + // PWA manifest — makes the app installable with a proper icon. Icons derive from // the single brand-mark source: the SVG for modern browsers, plus generated PNG // "any" and "maskable" sets from app/icons/[variant]. Theme colours stay on @@ -7,9 +9,9 @@ import type { MetadataRoute } from "next"; // so light/dark can update without a static PWA manifest colour lock. export default function manifest(): MetadataRoute.Manifest { return { - name: "PsychSift", - short_name: "PsychSift", - description: "Private medical guideline RAG knowledge base", + name: BRAND_NAME, + short_name: BRAND_NAME, + description: BRAND_DESCRIPTION, id: "/", start_url: "/", scope: "/", @@ -35,7 +37,7 @@ export default function manifest(): MetadataRoute.Manifest { ], shortcuts: [ { - name: "Ask PsychSift", + name: `Ask ${BRAND_NAME}`, short_name: "Ask", description: "Open a source-backed clinical question", url: "/?mode=answer&focus=1", diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx index 81b7300d4d..0daf8b48a6 100644 --- a/src/app/opengraph-image.tsx +++ b/src/app/opengraph-image.tsx @@ -1,8 +1,9 @@ import { ImageResponse } from "next/og"; +import { BRAND_CATCHPHRASE_BARE, BRAND_NAME, BRAND_OG_ALT } from "@/lib/brand"; import { brandMarkDataUri } from "@/lib/brand-image"; -export const alt = "PsychSift — private medical guideline knowledge base"; +export const alt = BRAND_OG_ALT; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; @@ -23,8 +24,8 @@ export default function OpengraphImage() { >
-
PsychSift
-
Private medical guideline knowledge base
+
{BRAND_NAME}
+
{BRAND_CATCHPHRASE_BARE}
, { ...size }, diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 9a59c4bfc9..73c2c298aa 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -26,6 +26,7 @@ import { } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; import { BrandMark } from "@/components/clinical-dashboard/brand"; +import { BRAND_CATCHPHRASE, BRAND_MENU_DESCRIPTION } from "@/lib/brand"; import { cn, fieldControlWithIcon, @@ -133,6 +134,40 @@ const collapsedSidebarButton = `grid ${collapsedSidebarControl}`; const collapsedSidebarActiveButton = "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]"; +/* Phone drawer header (ClinicalMobileSidebar). + * + * The stock Sheet header is built for a dialog that has to explain itself: a + * 5-unit pad, an 18px title, and a `text-sm leading-6` description paragraph. + * On this drawer that combination spent roughly 160px — a third of the first + * screenful above the notch on a 390px phone — restating what the drawer is, + * to a user who has just tapped the menu button and can see it. The description + * also wrapped to two lines, because the title column is squeezed between the + * brand mark and a 48px close button, which is why the block was so tall. + * + * The header now reads as a brand lockup rather than a dialog preamble: mark, + * wordmark, one strapline that cannot wrap, and a close control that keeps its + * full 48px tap target while giving up the boxed chrome that made it the + * loudest thing in the header. The functional sentence moves to `sr-only`, + * where it is still the accessible description. + * + * These are overrides on the shared Sheet rather than edits to it: every other + * dialog in the app uses that header, and the case for a compact brand header + * is specific to a navigation drawer that is already showing its own contents. */ +const drawerHeader = "gap-x-2.5 px-4 py-3 sm:px-5 sm:py-3.5"; +const drawerHeaderTitle = "text-base leading-6 tracking-tight sm:text-lg"; +const drawerHeaderStrapline = "block truncate text-2xs font-medium leading-4 text-[color:var(--text-muted)]"; +/* Ghost close control, matching the collapsed rail's idiom (transparent border + * that resolves on hover, so forced-colors still has an edge to paint) instead + * of the toolbar recipe's resting border, fill and inset shadow. The tap target + * is unchanged at h-tap/w-tap; only the chrome is quieter. + * + * The glyph is lifted to --spacing-icon-lg, the 20px "header / primary controls" + * step, via a child variant: Sheet hardcodes its X at 16px, which reads as a + * default-sized icon adrift once the surrounding box is removed. Scoped here + * rather than changed in Sheet, since that icon is shared by every dialog. */ +const drawerHeaderClose = + "grid h-tap w-tap shrink-0 place-items-center rounded-lg border border-transparent text-[color:var(--text-muted)] transition hover:border-[color:var(--border)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] [&>svg]:size-icon-lg"; + function SidebarModesTrigger({ variant, active, @@ -587,10 +622,16 @@ export function ClinicalSidebarContent({ {showHeader ? (
- -

- Clinical Guide -

+ + {/* Same lockup as the phone drawer header: wordmark over strapline, + so the two entry points to the same navigation read as one brand + rather than two headers that happen to share a title. */} +
+

+ Clinical Guide +

+

{BRAND_CATCHPHRASE}

+
{sharedHomeIdentity ? (
- + {/* The name leads and the strapline supports, which is a weight and a colour apart, not just a size. The wordmark takes the display diff --git a/src/components/privacy-quiet-signal-page.tsx b/src/components/privacy-quiet-signal-page.tsx index cdcd7d7127..e9509c1c86 100644 --- a/src/components/privacy-quiet-signal-page.tsx +++ b/src/components/privacy-quiet-signal-page.tsx @@ -210,7 +210,7 @@ export function PrivacyQuietSignalPage() { }> - +

{privacyCopy.pageEyebrow}

diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx index 5c23298582..d919733c45 100644 --- a/src/components/pwa-lifecycle.tsx +++ b/src/components/pwa-lifecycle.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { BrandMark } from "@/components/clinical-dashboard/brand"; +import { BRAND_INSTALL_TAGLINE, BRAND_NAME } from "@/lib/brand"; import { createBrowserStore } from "@/lib/client-store-factory"; const SERVICE_WORKER_URL = "/sw.js"; @@ -167,7 +168,7 @@ function InstallHeader({ return (

- +

{title}

@@ -201,7 +202,7 @@ function InstallBenefits() { function InstallManualSteps() { return ( -
    +
    1. 1. Tap Share