Task #468: Match incoming-note popup colors to the note's own color

User reported that the floating "new note" popup had a fixed amber/
orange ring, avatar ring, ping pulse and shadow tint, which clashed
visually whenever the underlying note used a different color (e.g. a
pink note appeared inside an orange popup).

Changes:
- artifacts/tx-os/src/lib/notes-api.ts:
  - Extended the NOTE_COLORS array with four new fields per color:
    `ringStrong` (popup outer ring), `avatarBg` (avatar circle bg+text),
    `avatarRing` (2px avatar ring), and `ping` (animated pulse).
  - Each entry uses literal Tailwind utility strings so v4's build-time
    scanner picks them up — same pattern already used for `accent-*`.
  - Default + gray colors map to neutral slate variants so they stay
    legible on the white card surface.
  - Added small helpers: `colorRingStrong`, `colorAvatarBg`,
    `colorAvatarRing`, `colorPing` (each falls back to the default
    entry for unknown ids, matching `colorAccent`'s behavior).
- artifacts/tx-os/src/components/notes/incoming-note-popup.tsx:
  - Imported the new helpers and resolved them once per render from
    `current.color` (works for both note and reply popup variants since
    both expose the same `color` field on the payload).
  - Replaced every `amber-*` class on the outer ring, the avatar
    fallback bg/text, the avatar image ring, and the animated ping
    with the resolved color classes.
  - Dropped the colored shadow tint (`shadow-amber-500/30`) — the
    card now uses a neutral `shadow-2xl` so the only colored chrome
    is the ring matching the note.

Validation:
- `pnpm --filter @workspace/tx-os exec tsc --noEmit` passes.
- No remaining `amber` literal in the popup file (only an
  explanatory comment mentions the word).
- Pre-existing `test` workflow failures (executive-meetings PDF,
  notes-share, groups-crud) are unchanged and out of scope.
This commit is contained in:
Riyadh
2026-05-10 14:01:21 +00:00
parent 08aff63a2f
commit b91ae74ca0
2 changed files with 61 additions and 16 deletions
@@ -13,6 +13,10 @@ import { Button } from "@/components/ui/button";
import {
colorBg,
colorAccent,
colorRingStrong,
colorAvatarBg,
colorAvatarRing,
colorPing,
useMarkNoteRead,
useToggleChecklistItem,
userDisplayName,
@@ -342,6 +346,16 @@ export function IncomingNotePopup() {
if (!current) return null;
// Task #468: derive every accent class from the note's own color so
// the popup looks like an extension of the note (pink note → pink
// ring + pink ping + pink avatar tint, etc.) instead of the previous
// fixed amber chrome that clashed with non-amber notes.
const noteColor = current.color;
const ringClass = colorRingStrong(noteColor);
const avatarBgClass = colorAvatarBg(noteColor);
const avatarRingClass = colorAvatarRing(noteColor);
const pingClass = colorPing(noteColor);
const acknowledge = () => {
// Only the recipient (note variant) marks-as-read. The reply
// variant goes to the *original sender* (note owner), who can't
@@ -464,10 +478,10 @@ export function IncomingNotePopup() {
}}
>
<div
className={`rounded-2xl ring-4 ring-amber-400/70 shadow-2xl shadow-amber-500/30 overflow-hidden transition-transform duration-200 ${
current.color === "default"
className={`rounded-2xl ring-4 ${ringClass} shadow-2xl overflow-hidden transition-transform duration-200 ${
noteColor === "default"
? "bg-background"
: colorBg(current.color)
: colorBg(noteColor)
} ${enterAnim ? "scale-100" : "scale-95"}`}
data-popup-color={current.color}
>
@@ -488,17 +502,17 @@ export function IncomingNotePopup() {
/>
<span className="relative shrink-0">
<span
className="absolute inset-0 rounded-full bg-amber-400/60 animate-ping"
className={`absolute inset-0 rounded-full ${pingClass} animate-ping`}
aria-hidden="true"
/>
{senderForHeader?.avatarUrl ? (
<img
src={senderForHeader.avatarUrl}
alt=""
className="relative h-8 w-8 rounded-full object-cover ring-2 ring-amber-400"
className={`relative h-8 w-8 rounded-full object-cover ring-2 ${avatarRingClass}`}
/>
) : (
<div className="relative h-8 w-8 rounded-full bg-amber-200 text-amber-900 text-sm font-semibold flex items-center justify-center ring-2 ring-amber-400">
<div className={`relative h-8 w-8 rounded-full ${avatarBgClass} text-sm font-semibold flex items-center justify-center ring-2 ${avatarRingClass}`}>
{avatarInitial(senderForHeader, i18n.language)}
</div>
)}
+41 -10
View File
@@ -736,6 +736,16 @@ export const NOTE_COLORS: {
bg: string;
ring: string;
accent: string;
// Task #468: each color also carries the literal class strings used by
// the incoming-note popup so the popup's outer ring, the avatar's
// background + ring, and the animated ping pulse all match the note's
// own color family instead of being a fixed amber tint. Keeping these
// as literal strings (not interpolated) preserves Tailwind v4's
// build-time scanning, same as the existing `accent-*` pattern above.
ringStrong: string; // ring color for the popup card outline (4px ring)
avatarBg: string; // avatar background + text when there's no photo
avatarRing: string; // 2px ring around the avatar circle
ping: string; // animated pulse behind the avatar
}[] = [
// Tailwind v4 scans these literal class strings at build time, so each
// accent-* utility below ends up in the generated CSS even though we
@@ -745,16 +755,16 @@ export const NOTE_COLORS: {
// dark mode; every other color points at its own family's *-500
// accent so the checked tint belongs to the card instead of clashing
// with it. colorAccent() falls back to this default for unknown ids.
{ id: "default", bg: "bg-white dark:bg-card", ring: "ring-slate-300", accent: "accent-slate-500" },
{ id: "red", bg: "bg-red-100 dark:bg-red-900/40", ring: "ring-red-400", accent: "accent-red-500" },
{ id: "orange", bg: "bg-orange-100 dark:bg-orange-900/40", ring: "ring-orange-400", accent: "accent-orange-500" },
{ id: "yellow", bg: "bg-yellow-100 dark:bg-yellow-900/40", ring: "ring-yellow-400", accent: "accent-yellow-500" },
{ id: "green", bg: "bg-green-100 dark:bg-green-900/40", ring: "ring-green-400", accent: "accent-green-500" },
{ id: "teal", bg: "bg-teal-100 dark:bg-teal-900/40", ring: "ring-teal-400", accent: "accent-teal-500" },
{ id: "blue", bg: "bg-sky-100 dark:bg-sky-900/40", ring: "ring-sky-400", accent: "accent-sky-500" },
{ id: "purple", bg: "bg-purple-100 dark:bg-purple-900/40", ring: "ring-purple-400", accent: "accent-purple-500" },
{ id: "pink", bg: "bg-pink-100 dark:bg-pink-900/40", ring: "ring-pink-400", accent: "accent-pink-500" },
{ id: "gray", bg: "bg-slate-200 dark:bg-slate-800/60", ring: "ring-slate-400", accent: "accent-slate-500" },
{ id: "default", bg: "bg-white dark:bg-card", ring: "ring-slate-300", accent: "accent-slate-500", ringStrong: "ring-slate-400/70", avatarBg: "bg-slate-200 text-slate-900", avatarRing: "ring-slate-400", ping: "bg-slate-400/60" },
{ id: "red", bg: "bg-red-100 dark:bg-red-900/40", ring: "ring-red-400", accent: "accent-red-500", ringStrong: "ring-red-400/70", avatarBg: "bg-red-200 text-red-900", avatarRing: "ring-red-400", ping: "bg-red-400/60" },
{ id: "orange", bg: "bg-orange-100 dark:bg-orange-900/40", ring: "ring-orange-400", accent: "accent-orange-500", ringStrong: "ring-orange-400/70", avatarBg: "bg-orange-200 text-orange-900", avatarRing: "ring-orange-400", ping: "bg-orange-400/60" },
{ id: "yellow", bg: "bg-yellow-100 dark:bg-yellow-900/40", ring: "ring-yellow-400", accent: "accent-yellow-500", ringStrong: "ring-yellow-400/70", avatarBg: "bg-yellow-200 text-yellow-900", avatarRing: "ring-yellow-400", ping: "bg-yellow-400/60" },
{ id: "green", bg: "bg-green-100 dark:bg-green-900/40", ring: "ring-green-400", accent: "accent-green-500", ringStrong: "ring-green-400/70", avatarBg: "bg-green-200 text-green-900", avatarRing: "ring-green-400", ping: "bg-green-400/60" },
{ id: "teal", bg: "bg-teal-100 dark:bg-teal-900/40", ring: "ring-teal-400", accent: "accent-teal-500", ringStrong: "ring-teal-400/70", avatarBg: "bg-teal-200 text-teal-900", avatarRing: "ring-teal-400", ping: "bg-teal-400/60" },
{ id: "blue", bg: "bg-sky-100 dark:bg-sky-900/40", ring: "ring-sky-400", accent: "accent-sky-500", ringStrong: "ring-sky-400/70", avatarBg: "bg-sky-200 text-sky-900", avatarRing: "ring-sky-400", ping: "bg-sky-400/60" },
{ id: "purple", bg: "bg-purple-100 dark:bg-purple-900/40", ring: "ring-purple-400", accent: "accent-purple-500", ringStrong: "ring-purple-400/70", avatarBg: "bg-purple-200 text-purple-900", avatarRing: "ring-purple-400", ping: "bg-purple-400/60" },
{ id: "pink", bg: "bg-pink-100 dark:bg-pink-900/40", ring: "ring-pink-400", accent: "accent-pink-500", ringStrong: "ring-pink-400/70", avatarBg: "bg-pink-200 text-pink-900", avatarRing: "ring-pink-400", ping: "bg-pink-400/60" },
{ id: "gray", bg: "bg-slate-200 dark:bg-slate-800/60", ring: "ring-slate-400", accent: "accent-slate-500", ringStrong: "ring-slate-400/70", avatarBg: "bg-slate-300 text-slate-900", avatarRing: "ring-slate-400", ping: "bg-slate-400/60" },
];
export function colorBg(id: string): string {
@@ -766,6 +776,27 @@ export function colorAccent(id: string | null | undefined): string {
return NOTE_COLORS.find((c) => c.id === id)?.accent ?? NOTE_COLORS[0].accent;
}
function colorEntry(id: string | null | undefined) {
if (!id) return NOTE_COLORS[0];
return NOTE_COLORS.find((c) => c.id === id) ?? NOTE_COLORS[0];
}
export function colorRingStrong(id: string | null | undefined): string {
return colorEntry(id).ringStrong;
}
export function colorAvatarBg(id: string | null | undefined): string {
return colorEntry(id).avatarBg;
}
export function colorAvatarRing(id: string | null | undefined): string {
return colorEntry(id).avatarRing;
}
export function colorPing(id: string | null | undefined): string {
return colorEntry(id).ping;
}
export function userDisplayName(
u: UserSummary | null | undefined,
lang: string,