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
11 changes: 8 additions & 3 deletions app/frontend/src/contexts/instance-accent-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/re
import { ThemeProvider } from "@/contexts/theme-context";
import { ToastProvider } from "@/components/toast";
import { InstanceAccentProvider, useInstanceAccent } from "./instance-accent-context";
import { readInstanceColorEcho, writeInstanceColorEcho } from "@/instance-accent";
import { readInstanceColorEcho, writeInstanceColorEcho, deriveAccentHexes } from "@/instance-accent";
import { DEFAULT_DARK_THEME } from "@/themes";

// Mock the API client module so no real HTTP calls happen in tests.
vi.mock("@/api/client", () => ({
Expand Down Expand Up @@ -83,9 +84,13 @@ describe("InstanceAccentProvider resolution chain", () => {
expect(screen.getByTestId("stripe").textContent).toMatch(/^#[0-9a-f]{6}$/i);
// Echo rewritten with the authoritative value.
await waitFor(() => expect(readInstanceColorEcho()?.value).toBe("5"));
// Meta carries the accent hex, not the bare background.
// Meta carries the subtle titlebar blend (mock parity) — NOT the full-hue
// stripe hex — and the echo's hex matches it for the pre-paint script.
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute("content")).toBe(screen.getByTestId("stripe").textContent);
const titlebarHex = deriveAccentHexes("5", DEFAULT_DARK_THEME)?.titlebarHex;
expect(meta?.getAttribute("content")).toBe(titlebarHex);
expect(meta?.getAttribute("content")).not.toBe(screen.getByTestId("stripe").textContent);
expect(readInstanceColorEcho()?.hex).toBe(meta?.getAttribute("content"));
});

it("defaults to no accent when no explicit color is set (no derived default)", async () => {
Expand Down
13 changes: 8 additions & 5 deletions app/frontend/src/contexts/instance-accent-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ export type InstanceAccent = {
color: string | null;
/** True when an explicit instance color is set. */
isExplicit: boolean;
/** Contrast-guarded accent hex — top-bar stripe, HOST hostname tint, and
* the theme-color meta content. Null when no accent is resolved. */
/** Contrast-guarded accent hex — top-bar stripe and HOST hostname tint
* (the theme-color meta takes the subtler `titlebarHex` blend instead).
* Null when no accent is resolved. */
stripeHex: string | null;
/** Subtle accent-into-background blend for the top-bar wash. */
washHex: string | null;
Expand Down Expand Up @@ -70,11 +71,13 @@ export function InstanceAccentProvider({ children }: { children: React.ReactNode
);

// Bridge: keep the theme-color meta and the localStorage echo in sync with
// the resolved accent under the active theme.
// the resolved accent under the active theme. The meta (installed-PWA
// titlebar) carries the subtle titlebar blend — NOT the full-hue stripeHex —
// so the 2px stripe below the titlebar stays visible (mock parity).
useEffect(() => {
if (resolved != null && hexes != null) {
setAccentThemeColor(hexes.stripeHex);
writeInstanceColorEcho({ value: resolved, hex: hexes.stripeHex });
setAccentThemeColor(hexes.titlebarHex);
writeInstanceColorEcho({ value: resolved, hex: hexes.titlebarHex });
} else if (authoritative) {
setAccentThemeColor(null);
writeInstanceColorEcho(null);
Expand Down
8 changes: 7 additions & 1 deletion app/frontend/src/instance-accent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,20 @@ describe("instance color echo", () => {
});

describe("deriveAccentHexes", () => {
it("derives stripe and wash hexes for single and blend descriptors, per theme", () => {
it("derives stripe, wash, and titlebar hexes for single and blend descriptors, per theme", () => {
for (const value of ["4", "1+3"]) {
for (const theme of [DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME]) {
const hexes = deriveAccentHexes(value, theme);
expect(hexes).not.toBeNull();
expect(hexes?.stripeHex).toMatch(/^#[0-9a-f]{6}$/i);
expect(hexes?.washHex).toMatch(/^#[0-9a-f]{6}$/i);
expect(hexes?.titlebarHex).toMatch(/^#[0-9a-f]{6}$/i);
// The wash is a near-background blend, never the raw accent.
expect(hexes?.washHex).not.toBe(hexes?.stripeHex);
// The titlebar tint is its own blend — stronger than the wash,
// far dimmer than the full-hue stripe.
expect(hexes?.titlebarHex).not.toBe(hexes?.stripeHex);
expect(hexes?.titlebarHex).not.toBe(hexes?.washHex);
}
}
});
Expand All @@ -72,6 +77,7 @@ describe("deriveAccentHexes", () => {
const dark = deriveAccentHexes("4", DEFAULT_DARK_THEME);
const light = deriveAccentHexes("4", DEFAULT_LIGHT_THEME);
expect(dark?.washHex).not.toBe(light?.washHex);
expect(dark?.titlebarHex).not.toBe(light?.titlebarHex);
});
});

Expand Down
18 changes: 13 additions & 5 deletions app/frontend/src/instance-accent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export const INSTANCE_COLOR_STORAGE_KEY = "runkit-instance-color";
* (intake latitude: ~6-7%; one trivially-tunable constant). */
export const INSTANCE_WASH_RATIO = 0.065;

/** Ratio of the accent blended into the theme background for the PWA titlebar
* (theme-color meta) tint — mock parity ≈ 12% within the granted ~0.12–0.15
* band; a taste constant, trivially tunable. */
export const INSTANCE_TITLEBAR_RATIO = 0.12;

export type InstanceColorEcho = { value: string; hex: string };

function isEcho(v: unknown): v is InstanceColorEcho {
Expand Down Expand Up @@ -65,20 +70,23 @@ export function writeInstanceColorEcho(echo: InstanceColorEcho | null): void {
}

/** Theme-derived hexes for the accent surfaces: `stripeHex` is the
* contrast-guarded family hex (top-bar stripe, HOST hostname tint, and the
* theme-color meta content), `washHex` the subtle top-bar background blend.
* Accepts family names and legacy descriptors incl. blends ("1+3") via the
* owned-family mapping. Null when the value maps to no owned family. */
* contrast-guarded family hex (top-bar stripe and HOST hostname tint),
* `washHex` the subtle top-bar background blend, and `titlebarHex` the
* slightly stronger blend that becomes the theme-color meta content (the
* installed-PWA titlebar tint — subtle wash above, full-brightness stripe
* below). Accepts family names and legacy descriptors incl. blends ("1+3")
* via the owned-family mapping. Null when the value maps to no owned family. */
export function deriveAccentHexes(
value: string,
theme: Theme,
): { stripeHex: string; washHex: string } | null {
): { stripeHex: string; washHex: string; titlebarHex: string } | null {
const src = colorValueToHex(value, theme.palette);
if (src == null) return null;
const bg = theme.palette.background;
return {
stripeHex: adjustBorderForContrast(src, bg, theme.category === "dark", BORDER_MIN_CONTRAST),
washHex: blendHex(src, bg, INSTANCE_WASH_RATIO),
titlebarHex: blendHex(src, bg, INSTANCE_TITLEBAR_RATIO),
};
}

Expand Down
Loading
Loading