mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
9749e8525b
commit
1d12fa1baf
10 changed files with 178 additions and 100 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue