Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html>, 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);

Expand Down
9 changes: 5 additions & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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",
},
};
Expand Down
10 changes: 6 additions & 4 deletions src/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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
// viewport.themeColor / meta theme-color (see app/layout.tsx and use-theme.ts)
// 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: "/",
Expand All @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -23,8 +24,8 @@ export default function OpengraphImage() {
>
<img src={brandMarkDataUri()} width={192} height={192} alt="" />
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 14 }}>
<div style={{ fontSize: 78, fontWeight: 700, letterSpacing: -1.5 }}>PsychSift</div>
<div style={{ fontSize: 30, color: "#9fb2b8" }}>Private medical guideline knowledge base</div>
<div style={{ fontSize: 78, fontWeight: 700, letterSpacing: -1.5 }}>{BRAND_NAME}</div>
<div style={{ fontSize: 30, color: "#9fb2b8" }}>{BRAND_CATCHPHRASE_BARE}</div>
</div>
</div>,
{ ...size },
Expand Down
74 changes: 65 additions & 9 deletions src/components/clinical-dashboard/ClinicalSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -587,10 +622,16 @@ export function ClinicalSidebarContent({
{showHeader ? (
<div className="flex shrink-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<BrandMark className="h-10 w-10" />
<p className="truncate text-base font-semibold tracking-tight text-[color:var(--text-heading)]">
Clinical Guide
</p>
<BrandMark tone="emphasis" className="h-10 w-10" />
{/* 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. */}
<div className="min-w-0">
<p className="truncate text-base font-semibold tracking-tight text-[color:var(--text-heading)]">
Clinical Guide
</p>
<p className={drawerHeaderStrapline}>{BRAND_CATCHPHRASE}</p>
</div>
</div>
<Button
variant="ghost"
Expand Down Expand Up @@ -895,14 +936,14 @@ function ClinicalCollapsedRail({
<div className="grid w-full shrink-0 justify-items-center gap-2 px-3">
{collapseLocked ? (
<span className={collapsedSidebarButton} aria-hidden="true">
<BrandMark className="h-7 w-7" />
<BrandMark tone="emphasis" className="h-7 w-7" />
</span>
) : (
<>
{/* Tablet: the expanded panel does not exist below lg, so show a
static brand mark instead of a dead expand control. */}
<span className={cn("hidden md:grid lg:hidden", collapsedSidebarControl)} aria-hidden="true">
<BrandMark className="h-7 w-7" />
<BrandMark tone="emphasis" className="h-7 w-7" />
</span>
<Button
variant="ghost"
Expand All @@ -911,7 +952,7 @@ function ClinicalCollapsedRail({
title="Expand sidebar"
onClick={() => onCollapsedChange(false)}
>
<BrandMark className="h-7 w-7 group-hover:hidden group-focus-visible:hidden" />
<BrandMark tone="emphasis" className="h-7 w-7 group-hover:hidden group-focus-visible:hidden" />
<PanelLeftOpen
aria-hidden="true"
className="hidden size-icon-lg group-hover:block group-focus-visible:block"
Expand Down Expand Up @@ -1144,11 +1185,26 @@ export function ClinicalMobileSidebar({
open={open}
onClose={() => onOpenChange(false)}
title="Clinical Guide"
description="Recent chats, navigation, and settings."
closeLabel="Close Clinical Guide menu"
placement="left"
contentClassName={hiddenFrom === "lg" ? "lg:hidden" : "md:hidden"}
headerLeading={<BrandMark className="h-8 w-8" />}
headerLeading={<BrandMark tone="emphasis" className="h-7 w-7 sm:h-8 sm:w-8" />}
headerClassName={drawerHeader}
titleClassName={drawerHeaderTitle}
closeButtonClassName={drawerHeaderClose}
descriptionContent={
<>
{/* The functional sentence still reaches assistive technology, where
"what does this dialog contain" is the useful answer. The visible
line is the strapline, which is identity — the two audiences want
different sentences, so they get different sentences rather than one
compromise that serves neither. */}
<span className="sr-only">{BRAND_MENU_DESCRIPTION}</span>
<span aria-hidden="true" className={drawerHeaderStrapline}>
{BRAND_CATCHPHRASE}
</span>
</>
}
>
<ClinicalSidebarContent
showHeader={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ function AccountOrientationPanel() {
data-testid="account-workspace-mark"
className="grid size-tap place-items-center rounded-xl border border-[color:var(--border-lux)] bg-[color:var(--surface)] shadow-[var(--e2),var(--shadow-inset)]"
>
<BrandMark className="h-8 w-8" />
<BrandMark tone="emphasis" className="h-8 w-8" />
</span>
<h3
id="account-workspace-benefits"
Expand Down
35 changes: 30 additions & 5 deletions src/components/clinical-dashboard/brand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,37 @@ import {
* ground has to be painted because the format has no transparency to fall back
* on: the .ico, the Apple touch icon and the PWA raster icons.
*
* The colour is the clinical accent token, so the mark follows light, dark and
* forced-colors without a second definition. Geometry is the single source in
* `@/lib/brand-mark` (shared with app/icon.svg and the app-icon image routes).
* The colour comes from the `--brand-mark*` role tokens, which alias the accent
* ramp, so the mark follows light, dark and forced-colors without a second
* definition. Geometry is the single source in `@/lib/brand-mark` (shared with
* app/icon.svg and the app-icon image routes).
* Size it via className (h-10 w-10 expanded sidebar, h-7 w-7 collapsed rail).
*/
export function BrandMark({ className }: { className?: string }) {

/**
* Which ink the mark uses. A logo has one resting lockup colour and defined
* alternates for grounds the resting colour cannot hold — this is that set,
* named so a surface states its choice instead of hand-picking a hex or reaching
* for a UI-state token.
*
* - `resting` — the default lockup. Correct wherever the mark is shown at
* display size, or on a ground with enough tint of its own to frame it.
* - `emphasis` — one full ramp step deeper in light, one step brighter in dark.
* For the mark at chrome size (28-40px) on a near-white or glass band, where
* the resting accent lets the thinner lower stroke wash out and the mark reads
* top-heavy. Same geometry, more ink.
* - `contrast` — for a filled accent or command ground, where the mark must
* reverse out rather than sit on the colour.
*/
export type BrandMarkTone = "resting" | "emphasis" | "contrast";

const BRAND_MARK_INK: Record<BrandMarkTone, string> = {
resting: "var(--brand-mark)",
emphasis: "var(--brand-mark-emphasis)",
contrast: "var(--brand-mark-contrast)",
};

export function BrandMark({ className, tone = "resting" }: { className?: string; tone?: BrandMarkTone }) {
return (
<svg
viewBox={BRAND_VIEWBOX}
Expand All @@ -31,7 +56,7 @@ export function BrandMark({ className }: { className?: string }) {
focusable="false"
className={cn("shrink-0", className)}
>
<g transform={BRAND_GLYPH_TRANSFORM_BARE} fill="var(--clinical-accent)">
<g transform={BRAND_GLYPH_TRANSFORM_BARE} fill={BRAND_MARK_INK[tone]}>
<path d={BRAND_STROKE_PATH} />
<path d={BRAND_STROKE_PATH} transform={BRAND_COUNTER_TRANSFORM} />
<circle cx={BRAND_POINT.cx} cy={BRAND_POINT.cy} r={BRAND_POINT.r} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2298,7 +2298,7 @@ export function MasterSearchHeader({
</button>
{sharedHomeIdentity ? (
<div data-testid="shared-home-brand" className="hidden min-w-0 items-center gap-3 lg:flex">
<BrandMark className="h-10 w-10" />
<BrandMark tone="emphasis" className="h-10 w-10" />
<span className="min-w-0">
{/* The name leads and the strapline supports, which is a weight and a
colour apart, not just a size. The wordmark takes the display
Expand Down
2 changes: 1 addition & 1 deletion src/components/privacy-quiet-signal-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export function PrivacyQuietSignalPage() {
<Suspense fallback={<NavigationBackButton fallbackHref="/" />}>
<PrivacyPageBackButton />
</Suspense>
<BrandMark className="h-9 w-9 shrink-0 sm:h-10 sm:w-10 lg:h-12 lg:w-12" />
<BrandMark tone="emphasis" className="h-9 w-9 shrink-0 sm:h-10 sm:w-10 lg:h-12 lg:w-12" />
<div className="min-w-0 flex-1">
<p className={cn(eyebrowText, "shrink-0")}>{privacyCopy.pageEyebrow}</p>
<p className="mt-0.5 truncate text-sm font-semibold tracking-display text-[color:var(--text-heading)] sm:text-base-minus lg:text-lg">
Expand Down
13 changes: 7 additions & 6 deletions src/components/pwa-lifecycle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -167,7 +168,7 @@ function InstallHeader({
return (
<div className="pwa-install-header">
<div className="flex min-w-0 items-center gap-2.5">
<BrandMark className="pwa-install-mark h-9 w-9" />
<BrandMark tone="emphasis" className="pwa-install-mark h-9 w-9" />
<p id={titleId} className="min-w-0 text-sm font-bold leading-5 text-[color:var(--text-heading)]">
{title}
</p>
Expand Down Expand Up @@ -201,7 +202,7 @@ function InstallBenefits() {

function InstallManualSteps() {
return (
<ol className="pwa-install-steps" aria-label="Add PsychSift to your Home Screen">
<ol className="pwa-install-steps" aria-label={`Add ${BRAND_NAME} to your Home Screen`}>
<li>
<span>1. Tap Share</span>
<Share className="h-icon-md w-icon-md" aria-hidden="true" />
Expand Down Expand Up @@ -713,13 +714,13 @@ export function PwaLifecycle() {
>
<InstallSheetGrip />
<InstallHeader
title="Install PsychSift"
title={`Install ${BRAND_NAME}`}
titleId="pwa-ios-install-title"
dismissLabel="Dismiss install hint"
onDismiss={dismissIosHint}
/>
<div className="pwa-install-body">
<p className="pwa-install-tagline">Clinical guidelines on your home screen.</p>
<p className="pwa-install-tagline">{BRAND_INSTALL_TAGLINE}</p>
<p className="pwa-install-copy">In Safari, tap Share, then Add to Home Screen.</p>
<InstallManualSteps />
<p className="pwa-install-support">Private clinical features still require a connection.</p>
Expand All @@ -741,14 +742,14 @@ export function PwaLifecycle() {
>
<InstallSheetGrip />
<InstallHeader
title="Install PsychSift"
title={`Install ${BRAND_NAME}`}
titleId="pwa-install-title"
dismissLabel="Dismiss install prompt"
onDismiss={dismissInstall}
/>
<div className="pwa-install-body">
<p className="pwa-install-compact-copy">Quick access · No app store</p>
<p className="pwa-install-tagline">Clinical guidelines on your home screen.</p>
<p className="pwa-install-tagline">{BRAND_INSTALL_TAGLINE}</p>
<p className="pwa-install-copy">
Open it from your device like an app. Private clinical features still require a connection.
</p>
Expand Down
Loading
Loading