refactor(tags): one calm, AA-safe chip vocabulary across every surface

Impeccable pass. Replaces the solid full-saturation chips (a heavy-accent
inconsistency vs the settings card's soft chips) with a single quiet vocabulary
per PRODUCT.md: a soft tint of the Tag's freeform hex, a colour dot, and dark
hue-preserving ink whose contrast on the tint is guaranteed ≥4.5:1 for any
colour (new tagChipTokens, TDD — yellow and every hue verified). Settings,
table, filter, bulk bar, upload, and run UI now share one TagChip. Also drops a
stray dev scratch file and gitignores the pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-13 19:09:27 +00:00
parent 9749e8525b
commit 1d12fa1baf
10 changed files with 178 additions and 100 deletions

6
.gitignore vendored
View file

@ -46,3 +46,9 @@ docs/wip/**
# Personal Claude Code settings (per-developer, not shared)
.claude/settings.local.json
# transient local scratch (dev session helpers)
.mint.cjs
.chip-preview.*
.shot.cjs
scratch-*.cjs

View file

@ -1,3 +0,0 @@
const fs=require("fs");const {encode}=require("next-auth/jwt");
const env=Object.fromEntries(fs.readFileSync(".env.local","utf8").split("\n").filter(l=>l.includes("=")).map(l=>{const i=l.indexOf("=");return[l.slice(0,i).trim(),l.slice(i+1).trim().replace(/^["']|["']$/g,"")]}));
encode({token:{email:"khalim@domna.homes",name:"K",onboarded:true,sub:"verify"},secret:env.NEXTAUTH_SECRET}).then(t=>console.log(t));

View file

@ -6,6 +6,7 @@ import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import { Pencil, Trash2, Check, X, Plus } from "lucide-react";
import { MAX_TAG_NAME_LENGTH } from "@/lib/tags/model";
import { TagChip } from "@/app/portfolio/[slug]/components/TagChip";
interface Tag {
id: string;
@ -135,18 +136,6 @@ export default function TagsCard({ portfolioId }: { portfolioId: string }) {
);
}
function TagChip({ colour, name }: { colour: string; name: string }) {
return (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: `${colour}22`, color: colour }}
>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: colour }} />
{name}
</span>
);
}
function TagRow({
portfolioId,
tag,

View file

@ -3,7 +3,7 @@
import React, { useState, useRef, useEffect } from "react";
import { X, Plus, ChevronDown, Check } from "lucide-react";
import { getEpcColorClass } from "@/app/utils";
import { tagChipStyle } from "@/lib/tags/colour";
import { tagDotColour } from "@/lib/tags/colour";
import { Tag, usePortfolioTags } from "./useTags";
import {
FilterGroups,
@ -512,7 +512,7 @@ function TagMultiDropdown({
>
<span
className={`h-3 w-3 shrink-0 rounded-full border ${untagged ? "border-dashed border-gray-400" : ""}`}
style={untagged ? undefined : { backgroundColor: tagChipStyle(opt.colour!).backgroundColor }}
style={untagged ? undefined : { backgroundColor: tagDotColour(opt.colour!) }}
/>
<span className="flex-1 text-gray-700 leading-tight truncate">{opt.label}</span>
<span

View file

@ -12,7 +12,7 @@ import {
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { FilterGroups } from "@/app/utils/propertyFilters";
import { tagChipStyle } from "@/lib/tags/colour";
import { tagDotColour } from "@/lib/tags/colour";
import { Tag, useInvalidateTagSurfaces, usePortfolioTags } from "./useTags";
import { BulkTagUploadModal } from "./BulkTagUploadModal";
@ -194,7 +194,7 @@ function TagMenu({
>
<span
className="h-3 w-3 shrink-0 rounded-full border"
style={{ backgroundColor: tagChipStyle(t.colour).backgroundColor }}
style={{ backgroundColor: tagDotColour(t.colour) }}
/>
<span className="truncate text-xs">{t.name}</span>
</DropdownMenuItem>

View file

@ -1,12 +1,14 @@
"use client";
import { X } from "lucide-react";
import { tagChipStyle } from "@/lib/tags/colour";
import { tagChipTokens } from "@/lib/tags/colour";
/**
* A Tag chip: solid fill in the Tag's freeform hex, with auto-contrast text so
* any colour stays legible (ADR-0013). Shared across the property table, the
* tags filter, and the run UI so every surface reads as one system.
* A Tag chip in the app's one calm, AA-safe chip vocabulary (ADR-0013,
* PRODUCT.md): a soft tint of the Tag's freeform hex, a colour dot, and dark
* hue-preserving ink that stays legible for any colour. Shared across the
* property table, tags filter, bulk bar, upload, settings, and run UI so every
* surface reads as one system.
*/
export function TagChip({
name,
@ -21,16 +23,23 @@ export function TagChip({
onRemove?: () => void;
title?: string;
}) {
const tokens = tagChipTokens(colour);
const sizing =
size === "xs"
? "px-1.5 py-0.5 text-[10px]"
: "px-2 py-0.5 text-[11px]";
? "gap-1 px-1.5 py-0.5 text-[10px]"
: "gap-1.5 px-2 py-0.5 text-[11px]";
const dot = size === "xs" ? "h-1.5 w-1.5" : "h-2 w-2";
return (
<span
className={`inline-flex max-w-[9rem] items-center gap-1 rounded-full border font-semibold leading-none ${sizing}`}
style={tagChipStyle(colour)}
className={`inline-flex max-w-[10rem] items-center rounded-full border font-medium leading-none ${sizing}`}
style={{
backgroundColor: tokens.backgroundColor,
borderColor: tokens.borderColor,
color: tokens.color,
}}
title={title ?? name}
>
<span className={`${dot} shrink-0 rounded-full`} style={{ backgroundColor: tokens.dot }} />
<span className="truncate">{name}</span>
{onRemove && (
<button
@ -39,7 +48,7 @@ export function TagChip({
e.stopPropagation();
onRemove();
}}
className="-mr-0.5 shrink-0 rounded-full opacity-80 transition hover:opacity-100"
className="-mr-0.5 shrink-0 rounded-full opacity-70 transition hover:opacity-100"
aria-label={`Remove ${name}`}
>
<X className="h-3 w-3" />

View file

@ -9,7 +9,7 @@ import {
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import { PropertyWithRelations, PropertyTag } from "@/app/db/schema/property";
import { tagChipStyle } from "@/lib/tags/colour";
import { tagDotColour } from "@/lib/tags/colour";
import { TagChip } from "./TagChip";
import {
Tag,
@ -160,7 +160,7 @@ export function TagsCell({ property }: { property: PropertyWithRelations }) {
>
<span
className="h-3 w-3 shrink-0 rounded-full border"
style={{ backgroundColor: tagChipStyle(t.colour).backgroundColor }}
style={{ backgroundColor: tagDotColour(t.colour) }}
/>
<span className="flex-1 truncate text-xs text-slate-700">
{t.name}

View file

@ -16,7 +16,7 @@ import {
selectionSummary,
} from "@/lib/modellingRuns/model";
import { usePortfolioTags } from "@/app/portfolio/[slug]/components/useTags";
import { tagChipStyle } from "@/lib/tags/colour";
import { tagDotColour } from "@/lib/tags/colour";
export interface ScenarioOption {
id: string;
@ -119,7 +119,7 @@ function FilterColumn({
{o.colour && (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full border"
style={{ backgroundColor: tagChipStyle(o.colour).backgroundColor }}
style={{ backgroundColor: tagDotColour(o.colour) }}
/>
)}
<span className="truncate">{o.label ?? o.value}</span>

View file

@ -1,45 +1,82 @@
import { describe, expect, it } from "vitest";
import { readableTextColour, tagChipStyle } from "./colour";
import {
normaliseHex,
tagDotColour,
tagChipTokens,
luminance,
} from "./colour";
describe("readableTextColour", () => {
it("returns dark text on a light background", () => {
expect(readableTextColour("#ffffff")).toBe("#1f2937");
expect(readableTextColour("#fde047")).toBe("#1f2937"); // bright yellow
/** WCAG relative-luminance + contrast for asserting chip legibility in tests. */
function lum(hex: string): number {
const n = hex.replace("#", "");
const [r, g, b] = [0, 2, 4].map((i) => {
const s = parseInt(n.slice(i, i + 2), 16) / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [la, lb] = [lum(a), lum(b)];
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
it("returns white text on a dark background", () => {
expect(readableTextColour("#000000")).toBe("#ffffff");
expect(readableTextColour("#7c3aed")).toBe("#ffffff"); // violet
describe("normaliseHex", () => {
it("lowercases, hashes, and expands 3-digit hex", () => {
expect(normaliseHex(" #FDE047 ")).toBe("#fde047");
expect(normaliseHex("#FFF")).toBe("#ffffff");
});
it("accepts 3-digit hex", () => {
expect(readableTextColour("#fff")).toBe("#1f2937");
expect(readableTextColour("#000")).toBe("#ffffff");
});
it("is case-insensitive and tolerates surrounding whitespace", () => {
expect(readableTextColour(" #7C3AED ")).toBe("#ffffff");
});
it("falls back to dark text for an unparseable colour", () => {
expect(readableTextColour("not-a-colour")).toBe("#1f2937");
it("returns null for an unparseable colour", () => {
expect(normaliseHex("not-a-colour")).toBeNull();
});
});
describe("tagChipStyle", () => {
it("builds a solid chip with an auto-contrast foreground", () => {
expect(tagChipStyle("#7c3aed")).toEqual({
backgroundColor: "#7c3aed",
color: "#ffffff",
borderColor: "#7c3aed",
});
describe("tagDotColour", () => {
it("returns the normalised hex", () => {
expect(tagDotColour("#7C3AED")).toBe("#7c3aed");
});
it("normalises the hex it echoes back (trim + lowercase)", () => {
expect(tagChipStyle(" #FDE047 ")).toEqual({
backgroundColor: "#fde047",
color: "#1f2937",
borderColor: "#fde047",
});
it("falls back to slate for garbage input", () => {
expect(tagDotColour("nope")).toBe("#64748b");
});
});
describe("luminance", () => {
it("ranks black below white", () => {
expect(luminance("#000000")).toBeLessThan(luminance("#ffffff"));
});
});
describe("tagChipTokens", () => {
it("builds a soft tint, border, dot, and readable ink", () => {
const t = tagChipTokens("#7c3aed");
expect(t.dot).toBe("#7c3aed");
// Both tint and border are blended over white, so lighter than the source;
// the border carries more of the colour, so it sits between tint and source.
expect(luminance(t.backgroundColor)).toBeGreaterThan(luminance("#7c3aed"));
expect(luminance(t.borderColor)).toBeGreaterThan(luminance("#7c3aed"));
expect(luminance(t.borderColor)).toBeLessThan(luminance(t.backgroundColor));
});
it("guarantees AA (≥4.5:1) ink for a hard, pale colour (yellow)", () => {
const t = tagChipTokens("#fde047");
expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5);
});
it("guarantees AA ink across a spread of freeform hues", () => {
for (const c of ["#ef4444", "#22c55e", "#3b82f6", "#000000", "#ffffff", "#14163d"]) {
const t = tagChipTokens(c);
expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5);
}
});
it("keeps a dark tag's ink close to its own hue (minimal darkening)", () => {
// Navy is already dark enough — ink should not collapse to pure black.
expect(tagChipTokens("#14163d").color).not.toBe("#000000");
});
it("falls back to a neutral chip for an unparseable colour", () => {
const t = tagChipTokens("garbage");
expect(t.dot).toBe("#64748b");
expect(contrast(t.color, t.backgroundColor)).toBeGreaterThanOrEqual(4.5);
});
});

View file

@ -1,23 +1,25 @@
/**
* Pure colour helpers for rendering Tag chips (ADR-0013). A Tag's colour is a
* freeform hex, so the foreground text must be chosen per-colour to stay legible
* on both pale and vivid backgrounds. Kept DB-free + side-effect-free so it unit
* tests cleanly and is shared by every chip surface (table, filter, run UI).
* freeform hex, so chips must stay calm and legible for ANY hue the product is
* institutional and WCAG-AA (PRODUCT.md). The chip vocabulary is one system
* everywhere: a soft tint of the colour, a colour dot, and hue-preserving dark
* ink whose contrast against the tint is guaranteed 4.5:1. Kept DB-free +
* side-effect-free so it unit-tests cleanly and is shared by every chip surface.
*/
/** Slate-800 — the dark foreground used on light chip backgrounds. */
const DARK_TEXT = "#1f2937";
const LIGHT_TEXT = "#ffffff";
type Rgb = [number, number, number];
/** Slate-500 — a neutral fallback for an unparseable colour. */
const FALLBACK = "#64748b";
/** Minimum WCAG contrast for the chip's ink against its tint background. */
const MIN_CONTRAST = 4.5;
/** Parse `#rgb` / `#rrggbb` (any case, surrounding space) → [r,g,b] 0-255, or null. */
function parseHex(input: string): [number, number, number] | null {
function parseHex(input: string): Rgb | null {
const hex = input.trim().toLowerCase().replace(/^#/, "");
const full =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
? hex.split("").map((c) => c + c).join("")
: hex;
if (!/^[0-9a-f]{6}$/.test(full)) return null;
return [
@ -27,47 +29,85 @@ function parseHex(input: string): [number, number, number] | null {
];
}
function toHex(rgb: Rgb): string {
return "#" + rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("");
}
/** Normalise a hex to lowercase `#rrggbb`, or null if unparseable. */
export function normaliseHex(input: string): string | null {
const rgb = parseHex(input);
if (!rgb) return null;
return "#" + rgb.map((c) => c.toString(16).padStart(2, "0")).join("");
return rgb ? toHex(rgb) : null;
}
/**
* Relative luminance (WCAG) of a hex colour, 0 (black) 1 (white). Unparseable
* colours are treated as light so the caller falls back to dark text.
*/
export function luminance(input: string): number {
const rgb = parseHex(input);
if (!rgb) return 1;
const [r, g, b] = rgb.map((c) => {
/** The dot colour for a Tag — its normalised hex, or a neutral fallback. */
export function tagDotColour(colour: string): string {
return normaliseHex(colour) ?? FALLBACK;
}
function luminanceRgb([r, g, b]: Rgb): number {
const lin = [r, g, b].map((c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
/** Relative luminance (WCAG) of a hex colour, 0 → 1; unparseable → 1 (light). */
export function luminance(input: string): number {
const rgb = parseHex(input);
return rgb ? luminanceRgb(rgb) : 1;
}
function contrast(a: Rgb, b: Rgb): number {
const la = luminanceRgb(a);
const lb = luminanceRgb(b);
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
/** Blend `colour` over white at `alpha` (0-1) → the resulting opaque rgb. */
function overWhite(rgb: Rgb, alpha: number): Rgb {
return rgb.map((c) => c * alpha + 255 * (1 - alpha)) as Rgb;
}
/** Mix `rgb` toward black by `t` (0 = unchanged, 1 = black). */
function towardBlack(rgb: Rgb, t: number): Rgb {
return rgb.map((c) => c * (1 - t)) as Rgb;
}
/**
* The text colour to lay over a `background` chip: dark on light colours, white
* on dark ones. 0.5 is a practical threshold for readable both-ways contrast.
* A dark, hue-preserving ink for `colour` that reads at 4.5:1 on `bg`. Starts
* from the colour itself (dark tags keep their exact hue) and darkens toward
* black only as far as legibility requires.
*/
export function readableTextColour(background: string): string {
return luminance(background) > 0.5 ? DARK_TEXT : LIGHT_TEXT;
function readableInk(rgb: Rgb, bg: Rgb): string {
for (let t = 0; t <= 1.0001; t += 0.05) {
const candidate = towardBlack(rgb, t);
if (contrast(candidate, bg) >= MIN_CONTRAST) return toHex(candidate);
}
return "#000000";
}
export interface ChipStyle {
export interface ChipTokens {
backgroundColor: string;
color: string;
borderColor: string;
color: string;
dot: string;
}
/** Inline style for a solid Tag chip filled with its colour + auto-contrast text. */
export function tagChipStyle(colour: string): ChipStyle {
const bg = normaliseHex(colour) ?? colour.trim().toLowerCase();
/**
* Inline tokens for a soft Tag chip: a light tint of the colour, a slightly
* stronger border, a colour dot, and dark hue-preserving ink guaranteed legible
* on the tint. One quiet, AA-safe vocabulary for every chip in the app.
*/
export function tagChipTokens(colour: string): ChipTokens {
const rgb = parseHex(colour) ?? parseHex(FALLBACK)!;
const tint = overWhite(rgb, 0.14);
const border = overWhite(rgb, 0.34);
return {
backgroundColor: bg,
color: readableTextColour(bg),
borderColor: bg,
backgroundColor: toHex(tint),
borderColor: toHex(border),
color: readableInk(rgb, tint),
dot: toHex(rgb),
};
}