@hoardodile/ui 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import { useId } from "react"
3
+ import type { TagSpecialStyle } from "@hoardodile/ui/lib/colors"
4
+ import { goldConfig } from "./special/gold"
5
+ import { kintsugiConfig } from "./special/kintsugi"
6
+ import { oilslickConfig } from "./special/oilslick"
7
+ import { rainbowConfig } from "./special/rainbow"
8
+ import { silverConfig } from "./special/silver"
9
+ import type { SpecialTagStyleConfig } from "./special/types"
10
+
11
+ export type SpecialTagSurfaceProps = {
12
+ readonly style: TagSpecialStyle
13
+ readonly active?: boolean
14
+ readonly className?: string
15
+ }
16
+
17
+ const SPECIAL_TAG_CONFIG: Record<TagSpecialStyle, SpecialTagStyleConfig> = {
18
+ silver: silverConfig,
19
+ gold: goldConfig,
20
+ rainbow: rainbowConfig,
21
+ oilslick: oilslickConfig,
22
+ kintsugi: kintsugiConfig,
23
+ }
24
+
25
+ export function getSpecialTagStyleConfig(
26
+ style: TagSpecialStyle,
27
+ ): SpecialTagStyleConfig {
28
+ return SPECIAL_TAG_CONFIG[style]
29
+ }
30
+
31
+ /**
32
+ * SVG background surface for special tag styles.
33
+ *
34
+ * Each style owns a self-contained SVG renderer in `src/components/special/`.
35
+ * This component only picks the right renderer from the registry and applies
36
+ * positioning; all styling (filters, colors, shadows) lives in the renderer or
37
+ * the chip container config.
38
+ */
39
+ export function SpecialTagSurface(props: SpecialTagSurfaceProps) {
40
+ const { style, active, className } = props
41
+ const config = SPECIAL_TAG_CONFIG[style]
42
+ const Renderer = config.render
43
+ const reactId = useId()
44
+ // React useId() can produce ids containing colons, which break SVG
45
+ // url(#id) references in some browsers. Replace them with a safe delimiter.
46
+ const safeId = reactId.replace(/:/g, "-")
47
+
48
+ return (
49
+ <span className={cn(className)} aria-hidden="true">
50
+ <Renderer id={safeId} active={active} />
51
+ </span>
52
+ )
53
+ }
@@ -0,0 +1,180 @@
1
+ import { render, screen } from "@testing-library/react"
2
+ import userEvent from "@testing-library/user-event"
3
+ import { describe, expect, test, vi } from "vitest"
4
+ import { TAG_SPECIAL_STYLES } from "@hoardodile/ui/lib/colors"
5
+ import { TagChip } from "./tag-chip"
6
+
7
+ function mockBoundingRect(width: number, height: number) {
8
+ const original = Element.prototype.getBoundingClientRect
9
+ Element.prototype.getBoundingClientRect = vi.fn(() =>
10
+ DOMRect.fromRect({ x: 0, y: 0, width, height }),
11
+ )
12
+ return () => {
13
+ Element.prototype.getBoundingClientRect = original
14
+ }
15
+ }
16
+
17
+ describe("TagChip", () => {
18
+ test("renders a plain span with a single text wrapper (no layered nesting)", () => {
19
+ const { container } = render(<TagChip color="">tag</TagChip>)
20
+ const chip = container.firstElementChild
21
+ expect(chip).toHaveTextContent("tag")
22
+ expect(chip).not.toHaveClass("border")
23
+ expect(chip?.querySelectorAll("span")).toHaveLength(1)
24
+ })
25
+
26
+ test("renders normal colored chips with the tinted fill variables", () => {
27
+ const { container } = render(<TagChip color="#E74C3C">tag</TagChip>)
28
+ const chip = container.firstElementChild as HTMLElement
29
+ expect(chip.style.getPropertyValue("--chip-bg")).toContain("#E74C3C")
30
+ expect(chip).toHaveStyle({ color: "#E74C3C" })
31
+ })
32
+
33
+ test("renders every registered special style with an SVG texture", () => {
34
+ for (const style of TAG_SPECIAL_STYLES) {
35
+ const { container } = render(<TagChip color={style}>tag</TagChip>)
36
+ expect(container.querySelector("svg")).toBeInTheDocument()
37
+ }
38
+ })
39
+
40
+ test("falls back to the default palette when color is empty", () => {
41
+ const { container } = render(<TagChip color="">tag</TagChip>)
42
+ expect(container.firstElementChild).toHaveTextContent("tag")
43
+ })
44
+
45
+ test("display='button' renders a real button and fires onClick", async () => {
46
+ const user = userEvent.setup()
47
+ const onClick = vi.fn()
48
+ render(
49
+ <TagChip color="" display="button" onClick={onClick} data-testid="chip">
50
+ hello
51
+ </TagChip>,
52
+ )
53
+ const el = screen.getByTestId("chip")
54
+ expect(el.tagName).toBe("BUTTON")
55
+ expect(el.className).toContain("cursor-pointer")
56
+ await user.click(el)
57
+ expect(onClick).toHaveBeenCalledTimes(1)
58
+ })
59
+
60
+ test("an onClick handler alone never promotes — the element stays a span", async () => {
61
+ const user = userEvent.setup()
62
+ const onClick = vi.fn()
63
+ render(
64
+ <TagChip color="" onClick={onClick} data-testid="chip">
65
+ hello
66
+ </TagChip>,
67
+ )
68
+ const el = screen.getByTestId("chip")
69
+ expect(el.tagName).toBe("SPAN")
70
+ expect(el.className).not.toContain("cursor-pointer")
71
+ await user.click(el)
72
+ expect(onClick).toHaveBeenCalledTimes(1)
73
+ })
74
+
75
+ test("display mode stays a span", () => {
76
+ render(
77
+ <TagChip color="" data-testid="chip">
78
+ hello
79
+ </TagChip>,
80
+ )
81
+ expect(screen.getByTestId("chip").tagName).toBe("SPAN")
82
+ })
83
+
84
+ test("render swaps the root element and keeps the chip's children", () => {
85
+ render(
86
+ <TagChip color="" data-testid="chip" render={<button type="button" />}>
87
+ label
88
+ </TagChip>,
89
+ )
90
+ const el = screen.getByTestId("chip")
91
+ expect(el.tagName).toBe("BUTTON")
92
+ expect(el).toHaveTextContent("label")
93
+ })
94
+
95
+ test("active uncolored chips take the primary palette", () => {
96
+ render(
97
+ <TagChip color="" active onClick={() => undefined} data-testid="chip">
98
+ x
99
+ </TagChip>,
100
+ )
101
+ const el = screen.getByTestId("chip")
102
+ expect(el.className).toContain("bg-primary")
103
+ expect(el.className).toContain("text-primary-foreground")
104
+ })
105
+
106
+ test("dashed border mode renders the quiet outline and ignores color", () => {
107
+ render(
108
+ <TagChip color="#ff0000" border="dashed" data-testid="chip">
109
+ x
110
+ </TagChip>,
111
+ )
112
+ const el = screen.getByTestId("chip")
113
+ expect(el.className).toContain("border-dashed")
114
+ expect(el.className).toContain("border-border-strong")
115
+ expect(el.className).toContain("text-muted-foreground")
116
+ expect(el.className).not.toContain("bg-primary")
117
+ })
118
+
119
+ test("dashed border mode active swaps in the accent fill with a transparent border", () => {
120
+ render(
121
+ <TagChip
122
+ color=""
123
+ active
124
+ border="dashed"
125
+ onClick={() => undefined}
126
+ data-testid="chip"
127
+ >
128
+ x
129
+ </TagChip>,
130
+ )
131
+ const el = screen.getByTestId("chip")
132
+ expect(el.className).toContain("bg-accent")
133
+ expect(el.className).toContain("border-transparent")
134
+ })
135
+
136
+ test("roundedRight=false removes the right border-radius", () => {
137
+ render(
138
+ <TagChip color="" roundedRight={false} data-testid="chip">
139
+ x
140
+ </TagChip>,
141
+ )
142
+ expect(screen.getByTestId("chip").className).toContain("rounded-r-none")
143
+ })
144
+
145
+ test("special style renders an SVG gradient surface", () => {
146
+ const restore = mockBoundingRect(100, 36)
147
+ render(
148
+ <TagChip color="rainbow" data-testid="chip">
149
+ rainbow
150
+ </TagChip>,
151
+ )
152
+ const el = screen.getByTestId("chip")
153
+ expect(el.querySelector("svg")).toBeInTheDocument()
154
+ expect(el.querySelector("linearGradient")).toBeInTheDocument()
155
+ restore()
156
+ })
157
+
158
+ test("special style active state uses the active fill", () => {
159
+ const restore = mockBoundingRect(100, 36)
160
+ render(
161
+ <TagChip color="gold" active data-testid="chip">
162
+ gold
163
+ </TagChip>,
164
+ )
165
+ const rect = screen.getByTestId("chip").querySelector("rect")
166
+ expect(rect).toHaveAttribute("fill", "#8a6d1f")
167
+ restore()
168
+ })
169
+
170
+ test("special style does not apply the regular border or tint classes", () => {
171
+ render(
172
+ <TagChip color="silver" data-testid="chip">
173
+ silver
174
+ </TagChip>,
175
+ )
176
+ const el = screen.getByTestId("chip")
177
+ expect(el.className).not.toContain("border-transparent")
178
+ expect(el.className).not.toContain(" bg-(--chip-bg)")
179
+ })
180
+ })
@@ -0,0 +1,221 @@
1
+ import { cn } from "@hoardodile/ui/lib/utils"
2
+ import {
3
+ type ComponentPropsWithoutRef,
4
+ cloneElement,
5
+ forwardRef,
6
+ type MouseEvent,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ type Ref,
10
+ } from "react"
11
+ import { SpecialTagSurface } from "@hoardodile/ui/components/special-tag-surface"
12
+ import { resolveTagChipSurface } from "@hoardodile/ui/lib/tag-surface"
13
+
14
+ /** The two sanctioned chip sizes: `sm` (default, `px-2 py-1`) for cards and
15
+ inline rows, `md` (`px-2 py-1.5`) for the filterer facets (Categories,
16
+ Traits, Relations). */
17
+ export type TagChipSize = "sm" | "md"
18
+
19
+ type TagChipBaseProps = {
20
+ /**
21
+ * Effective display color. Special names (`silver`, `gold`, `rainbow`,
22
+ * ...) render their SVG texture; an empty string falls back to the
23
+ * default muted chip, taking the primary fill when `active`. Ignored
24
+ * when `border` is set — bordered modes never take a tint.
25
+ */
26
+ readonly color?: string
27
+ /** Chip height tier — see {@link TagChipSize}. */
28
+ readonly size?: TagChipSize
29
+ /**
30
+ * Border mode instead of the fill/ghost anatomies. Only `"dashed"`
31
+ * exists today (add pills, unused entities): a `border-dashed` hairline
32
+ * with muted ink and no color tint — `active` swaps in the accent fill
33
+ * with a transparent border so the width never moves with the state.
34
+ */
35
+ readonly border?: "dashed"
36
+ /** Selected: colored chips deepen to their hover tint, special styles
37
+ switch to their own active appearance, uncolored chips take the
38
+ primary fill. */
39
+ readonly active?: boolean
40
+ /** Leading icon; rides the chip's own gap, so no wrapper is needed. */
41
+ readonly icon?: ReactNode
42
+ /** False removes the right border-radius so the chip can glue flush
43
+ against a sibling (e.g. the category rail's chevron). */
44
+ readonly roundedRight?: boolean
45
+ /**
46
+ * Root element mode: `"inline"` (default) renders a plain `<span>`,
47
+ * `"button"` a real `<button type="button">`. The element is chosen
48
+ * only from this prop (or `render`) — passing an `onClick` never
49
+ * changes the element, so interactive wrappers (hover cards,
50
+ * popovers) that merge click handlers onto the chip cannot swap its
51
+ * DOM node mid-interaction.
52
+ */
53
+ readonly display?: "inline" | "button"
54
+ /**
55
+ * Polymorphic root: pass a `render={<button ... />}` element to render
56
+ * the chip as that element. The chip's own classes, style, handlers,
57
+ * texture and icon merge onto it, and the chip's children become the
58
+ * element's children — so the render element should carry only its own
59
+ * props. Wins over `display`; `display="button"` is the plain way to
60
+ * get a real button without supplying a render element.
61
+ */
62
+ readonly render?: ReactElement<Record<string, unknown>>
63
+ /**
64
+ * Trailing suffix, separated from the label by a middle dot (e.g. a
65
+ * trait's kind "cm" or a count). Rendered at the label's own size —
66
+ * same line box, so the chip height never depends on the suffix —
67
+ * with muted ink and a bold dot.
68
+ */
69
+ readonly suffix?: ReactNode
70
+ readonly children?: ReactNode
71
+ readonly onMouseDown?: (event: MouseEvent<HTMLSpanElement>) => void
72
+ }
73
+
74
+ export type TagChipProps = TagChipBaseProps &
75
+ Omit<ComponentPropsWithoutRef<"button">, keyof TagChipBaseProps>
76
+
77
+ /** Text container: ellipsis truncation that only clips horizontally —
78
+ `truncate`'s `overflow: hidden` would also clip descenders (y, g, p)
79
+ at the tight `leading-none` line box, so the x axis clips while the y
80
+ axis stays visible. */
81
+ const tagChipLabelClassName =
82
+ "min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap"
83
+
84
+ /**
85
+ * The one tag chip: a single rounded pill (inline-flex, `rounded-sm`,
86
+ * `px-2 py-1`, 12px text) that may carry an icon and a special SVG
87
+ * texture. It renders as a span by default; interactive surfaces pass
88
+ * `display="button"` (or `render={<button ... />}` for a custom root,
89
+ * e.g. a navigation anchor) and the chip's styling lands on that
90
+ * element — one element in the DOM plus a single text wrapper, no
91
+ * deeper nesting.
92
+ *
93
+ * The root element is decided purely by `display`/`render` — never by
94
+ * the presence of an `onClick`, so a chip's DOM element cannot change
95
+ * between renders (the hover-card trigger merge in {@link TagChipHover}
96
+ * relies on this).
97
+ *
98
+ * Coloring is delegated to {@link resolveTagChipSurface} so cards,
99
+ * pickers, the doc editor and the character pills all share one
100
+ * definition of "what a colored tag looks like".
101
+ *
102
+ * The chip forwards its root ref so composition wrappers (e.g. the
103
+ * {@link TagChipHover} preview-card trigger, which clones the chip and
104
+ * attaches a DOM ref for anchoring) can reach the actual element.
105
+ */
106
+ export const TagChip = forwardRef<HTMLElement, TagChipProps>(
107
+ function TagChip(props, ref) {
108
+ const {
109
+ color,
110
+ size = "sm",
111
+ border,
112
+ active,
113
+ icon,
114
+ roundedRight,
115
+ display,
116
+ render,
117
+ suffix,
118
+ children,
119
+ className,
120
+ style,
121
+ onMouseDown,
122
+ ...rest
123
+ } = props
124
+ const surface =
125
+ border !== undefined
126
+ ? undefined
127
+ : resolveTagChipSurface(color ?? "", active === true)
128
+
129
+ // The root element is fixed by the display mode / render slot —
130
+ // an onClick merged on by an interactive wrapper must never swap
131
+ // the chip's element (see the component doc comment).
132
+ const interactiveRoot = display === "button" || render !== undefined
133
+
134
+ const stateClass =
135
+ border === "dashed"
136
+ ? active === true
137
+ ? "border border-transparent bg-accent text-foreground"
138
+ : "border border-dashed border-border-strong text-muted-foreground hover:text-secondary-foreground"
139
+ : undefined
140
+
141
+ // Texture and icon are the chip's own decoration, so they lead the
142
+ // children even when the root element is swapped (render / button).
143
+ const content = (
144
+ <>
145
+ {surface !== undefined && surface.texture !== null && (
146
+ <SpecialTagSurface
147
+ style={surface.texture}
148
+ active={active}
149
+ className="absolute inset-0 -z-10 overflow-hidden rounded-[inherit]"
150
+ />
151
+ )}
152
+ {icon !== undefined && (
153
+ // Flex row: the slot accepts several icons as a fragment
154
+ // (e.g. a kind glyph + the pin), and flex keeps the
155
+ // blockified preflight svgs on one line.
156
+ <span className="flex shrink-0 items-center gap-1 text-muted-foreground">
157
+ {icon}
158
+ </span>
159
+ )}
160
+ <span className={tagChipLabelClassName}>
161
+ {children}
162
+ {suffix !== undefined ? (
163
+ <>
164
+ <span className="font-bold mx-0.5">·</span>
165
+ <span className="shrink-0 opacity-70">{suffix}</span>
166
+ </>
167
+ ) : null}
168
+ </span>
169
+ </>
170
+ )
171
+
172
+ const chipClassName = cn(
173
+ "inline-flex min-w-0 max-w-full items-center justify-center gap-1.5 rounded-sm text-xs font-normal leading-none disabled:pointer-events-none disabled:opacity-50",
174
+ size === "md" ? "px-2 py-1.5" : "px-2 py-1",
175
+ roundedRight === false && "rounded-r-none",
176
+ interactiveRoot && "cursor-pointer",
177
+ surface?.className,
178
+ stateClass,
179
+ className,
180
+ )
181
+ const chipStyle = { ...surface?.style, ...style }
182
+
183
+ if (render !== undefined) {
184
+ return cloneElement(render, {
185
+ ...rest,
186
+ ref,
187
+ className: chipClassName,
188
+ style: chipStyle,
189
+ onMouseDown,
190
+ children: content,
191
+ })
192
+ }
193
+
194
+ if (display === "button") {
195
+ return (
196
+ <button
197
+ type="button"
198
+ ref={ref as Ref<HTMLButtonElement>}
199
+ className={chipClassName}
200
+ style={chipStyle}
201
+ onMouseDown={onMouseDown}
202
+ {...rest}
203
+ >
204
+ {content}
205
+ </button>
206
+ )
207
+ }
208
+
209
+ return (
210
+ <span
211
+ ref={ref as Ref<HTMLSpanElement>}
212
+ className={chipClassName}
213
+ style={chipStyle}
214
+ onMouseDown={onMouseDown}
215
+ {...rest}
216
+ >
217
+ {content}
218
+ </span>
219
+ )
220
+ },
221
+ )
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest"
6
+ import {
7
+ computeTagChipColors,
8
+ isBlackHex,
9
+ isSpecialTagStyle,
10
+ isWhiteHex,
11
+ TAG_SPECIAL_STYLES,
12
+ } from "./colors"
13
+
14
+ describe("isWhiteHex", () => {
15
+ it("matches common white representations", () => {
16
+ expect(isWhiteHex("#fff")).toBe(true)
17
+ expect(isWhiteHex("#FFFFFF")).toBe(true)
18
+ expect(isWhiteHex("white")).toBe(true)
19
+ expect(isWhiteHex("rgb(255,255,255)")).toBe(true)
20
+ expect(isWhiteHex("rgba( 255 , 255 , 255 , 0.5)")).toBe(true)
21
+ })
22
+
23
+ it("rejects empty and non-white colors", () => {
24
+ expect(isWhiteHex("")).toBe(false)
25
+ expect(isWhiteHex("#000")).toBe(false)
26
+ expect(isWhiteHex("#abcdef")).toBe(false)
27
+ expect(isWhiteHex("rgb(254,255,255)")).toBe(false)
28
+ })
29
+ })
30
+
31
+ describe("isBlackHex", () => {
32
+ it("matches common black representations", () => {
33
+ expect(isBlackHex("#000")).toBe(true)
34
+ expect(isBlackHex("#000000")).toBe(true)
35
+ expect(isBlackHex("BLACK")).toBe(true)
36
+ expect(isBlackHex("rgb(0, 0, 0)")).toBe(true)
37
+ })
38
+
39
+ it("rejects empty and non-black colors", () => {
40
+ expect(isBlackHex("")).toBe(false)
41
+ expect(isBlackHex("#fff")).toBe(false)
42
+ expect(isBlackHex("rgb(0,0,1)")).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe("computeTagChipColors", () => {
47
+ it("falls back to muted/accent vars for empty color", () => {
48
+ const colors = computeTagChipColors("")
49
+ expect(colors.baseBg).toContain("var(--color-muted)")
50
+ expect(colors.hoverBg).toContain("var(--color-accent)")
51
+ expect(colors.fg).toContain("var(--color-foreground)")
52
+ })
53
+
54
+ it("returns explicit white treatment for white", () => {
55
+ const colors = computeTagChipColors("#ffffff")
56
+ expect(colors.baseBg).toBe("#ffffff")
57
+ expect(colors.fg).toBe("#0a0a0a")
58
+ })
59
+
60
+ it("returns explicit black treatment for black", () => {
61
+ const colors = computeTagChipColors("black")
62
+ expect(colors.baseBg).toBe("#0a0a0a")
63
+ expect(colors.fg).toBe("#ffffff")
64
+ })
65
+
66
+ it("uses color-mix blends for arbitrary colors", () => {
67
+ const colors = computeTagChipColors("#3366ff")
68
+ expect(colors.baseBg).toContain("color-mix")
69
+ expect(colors.baseBg).toContain("6%")
70
+ expect(colors.hoverBg).toContain("20%")
71
+ expect(colors.fg).toBe("#3366ff")
72
+ })
73
+
74
+ it("treats special style names as ordinary colors", () => {
75
+ const colors = computeTagChipColors("rainbow")
76
+ expect(colors.baseBg).toContain("color-mix")
77
+ expect(colors.fg).toBe("rainbow")
78
+ })
79
+ })
80
+
81
+ describe("isSpecialTagStyle", () => {
82
+ it("matches all known special styles", () => {
83
+ for (const style of TAG_SPECIAL_STYLES) {
84
+ expect(isSpecialTagStyle(style)).toBe(true)
85
+ }
86
+ })
87
+
88
+ it("rejects regular colors and empty", () => {
89
+ expect(isSpecialTagStyle("")).toBe(false)
90
+ expect(isSpecialTagStyle("#ff0000")).toBe(false)
91
+ expect(isSpecialTagStyle("red")).toBe(false)
92
+ expect(isSpecialTagStyle("rainbowish")).toBe(false)
93
+ })
94
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure color utilities used by tag/category chip rendering.
3
+ *
4
+ * Centralized here so chip components, color pickers, and tests share
5
+ * one definition for "looks white"/"looks black" and the color-mix
6
+ * blending used to keep chips subtle on cards and sidebars.
7
+ */
8
+
9
+ export const TAG_SPECIAL_STYLES = [
10
+ "silver",
11
+ "gold",
12
+ "rainbow",
13
+ "oilslick",
14
+ "kintsugi",
15
+ ] as const
16
+
17
+ /** Default color presets shared by every color picker in the app. */
18
+ export const DEFAULT_COLOR_PRESETS = [
19
+ "#9D9D9D",
20
+ "#000000",
21
+ "#FFFFFF",
22
+ "#27AE60",
23
+ "#0070DD",
24
+ "#00BCD4",
25
+ "#8E44AD",
26
+ "#FF8000",
27
+ "#F1C40F",
28
+ "#E74C3C",
29
+ ] as const
30
+
31
+ export type TagSpecialStyle = (typeof TAG_SPECIAL_STYLES)[number]
32
+
33
+ export function isSpecialTagStyle(color: string): color is TagSpecialStyle {
34
+ return TAG_SPECIAL_STYLES.includes(color as TagSpecialStyle)
35
+ }
36
+
37
+ export type TagChipColors = {
38
+ readonly baseBg: string
39
+ readonly hoverBg: string
40
+ readonly fg: string
41
+ }
42
+
43
+ const WHITE_COLOR_PATTERN =
44
+ /^(?:#fff(?:fff)?|white|rgba?\(\s*255\s*,\s*255\s*,\s*255\b)/i
45
+ const BLACK_COLOR_PATTERN =
46
+ /^(?:#000(?:000)?|black|rgba?\(\s*0\s*,\s*0\s*,\s*0\b)/i
47
+
48
+ /**
49
+ * Tests whether a CSS color string visually resolves to white in any
50
+ * common short form (`#fff`, `#ffffff`, `white`, `rgb(255,255,255,…)`).
51
+ */
52
+ export function isWhiteHex(color: string): boolean {
53
+ if (color === "") return false
54
+ return WHITE_COLOR_PATTERN.test(color.trim())
55
+ }
56
+
57
+ /** Mirror of {@link isWhiteHex} for black. */
58
+ export function isBlackHex(color: string): boolean {
59
+ if (color === "") return false
60
+ return BLACK_COLOR_PATTERN.test(color.trim())
61
+ }
62
+
63
+ /**
64
+ * Resolve a chip's background/hover/foreground triple for the given
65
+ * `color`. White and black are special-cased so they remain visibly
66
+ * black/white in both light and dark themes instead of vanishing into
67
+ * the chip background.
68
+ */
69
+ export function computeTagChipColors(color: string): TagChipColors {
70
+ if (color === "") {
71
+ return {
72
+ baseBg: "var(--color-muted)",
73
+ hoverBg: "var(--color-accent)",
74
+ fg: "var(--color-foreground)",
75
+ }
76
+ }
77
+ if (isWhiteHex(color)) {
78
+ return {
79
+ baseBg: "#ffffff",
80
+ hoverBg: "#f1f5f9",
81
+ fg: "#0a0a0a",
82
+ }
83
+ }
84
+ if (isBlackHex(color)) {
85
+ return {
86
+ baseBg: "#0a0a0a",
87
+ hoverBg: "#262626",
88
+ fg: "#ffffff",
89
+ }
90
+ }
91
+ return {
92
+ baseBg: `color-mix(in srgb, ${color} 6%, var(--color-card))`,
93
+ hoverBg: `color-mix(in srgb, ${color} 20%, var(--color-card))`,
94
+ fg: color,
95
+ }
96
+ }