@tangle-network/brand 1.0.0 → 1.3.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f3c5262: Add the missing `tangle-dark` named theme. The named set shipped `tangle-light` with no dark sibling, so `data-theme="tangle-dark"` matched no rule and silently fell through to whatever the element inherited — the light spine's values, inside a light-named wrapper. `tangle-dark` now selects the canonical dark spine by name: same indigo family as `tangle-light`, held to the dark contrast discipline, and self-sufficient inside a light-named wrapper (foregrounds and destructive included, so nothing leaks the light values).
8
+
9
+ ## 1.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 2088fb9: Retune the token spine to a neutral-grey surface ladder with indigo as trim rather than as field, in both themes. Surfaces separate by their own fill so a card, a nested panel and an overlay each read as a distinct plane, and the canvas sits off pure black so long reading sessions land away from the glare end of the range.
14
+
15
+ Faint text in `Input`, `Textarea`, `StatCard` and `TerminalLine` now takes a dedicated `--text-dim` token instead of a faded stronger one. A translucent foreground renders as its colour composited over the plane behind it, so those hints, subtitles and timestamps measured differently on a card than on the canvas and fell under the 4.5:1 floor on both.
16
+
17
+ **Worth a look after upgrading:** in dark mode `--sidebar-background` is now one step BELOW the canvas rather than above it, so the nav reads as chrome the content sits in front of instead of as another raised card. Apps that composited their own surfaces on top of the sidebar assuming it was the lighter plane should give that area a visual pass. Light mode is unchanged — the sidebar is still paper on a grey canvas.
18
+
19
+ ## 1.1.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 15402db: Ship the indigo surface ladder as the canonical Tangle palette.
24
+
25
+ The `:root` spine was a flat, desaturated neutral. Every product app overrode it with its own hand-written palette, so brand's own colors were rendered almost nowhere — and the apps that did inherit them read as grey and washed out, because a flat ladder forces surface separation onto borders instead of fills.
26
+
27
+ Both themes now ship the ladder the products actually converged on: an indigo-cast dark scale stepped in even ~4-5% lightness increments, and a light scale of white paper on a tinted canvas rather than white-on-white. Surfaces, depth scale, sidebar and `--bg-root` all move together so the ladder stays coherent.
28
+
29
+ Visual change for every consumer. Apps that were overriding the spine should delete those overrides; apps that were inheriting it get the branded look with no code change.
30
+
31
+ Also adds a dark-only `intelligence` named theme — a violet surface ramp — and exports the named-theme stylesheet as `./styles/named-themes.css`, so named themes can be imported at all (they shipped, but no export reached them). The name is deliberately not `themes.css`: one letter from the existing `./styles/theme.css` (the Tailwind `@theme` map) is a typo that resolves successfully to the wrong stylesheet. No published version ever exported `./styles/themes.css` — 1.0.0's export map is `./styles/{index,tokens,globals,theme}.css` — so this adds an export rather than renaming a public one, and nothing downstream can break. A named theme re-skins surfaces only; the Tangle accent stays put.
32
+
3
33
  ## 1.0.0
4
34
 
5
35
  ### Major Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "1.0.0",
3
+ "version": "1.3.0",
4
4
  "description": "Tangle brand system — design tokens, logo, Tailwind v4 theme. Single source of truth for every Tangle app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,8 @@
35
35
  "./styles/index.css": "./src/styles/index.css",
36
36
  "./styles/tokens.css": "./src/styles/tokens.css",
37
37
  "./styles/globals.css": "./src/styles/globals.css",
38
- "./styles/theme.css": "./src/styles/theme.css"
38
+ "./styles/theme.css": "./src/styles/theme.css",
39
+ "./styles/named-themes.css": "./src/styles/named-themes.css"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "react": ">=18"
@@ -46,9 +47,9 @@
46
47
  }
47
48
  },
48
49
  "devDependencies": {
49
- "@storybook/react": "^8.6.18",
50
+ "@storybook/react": "^10.5.5",
50
51
  "@types/react": "^19.0.0",
51
- "react": "^19.0.0",
52
+ "react": "^19.2.8",
52
53
  "tsup": "^8.3.5",
53
54
  "typescript": "^5.6.0"
54
55
  },
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { blockIn, hexRelativeLuminanceIn, hslIn } from "./css-test-utils";
3
+
4
+ /**
5
+ * The helpers every other suite's correctness rests on.
6
+ *
7
+ * Their failure mode is the dangerous one: they return something that still looks
8
+ * like CSS, so a wrong answer here makes assertions pass or fail against the wrong
9
+ * text instead of erroring. Each case below is a way that can happen.
10
+ */
11
+ describe("blockIn", () => {
12
+ it("anchors on the rule, not on prose that names the selector", () => {
13
+ const css = `
14
+ /* the .light spine is defined below */
15
+ .dark { --x: 1; }
16
+ .light { --x: 2; }`;
17
+ expect(blockIn(css, ".light")).toContain("--x: 2");
18
+ });
19
+
20
+ it("closes on THIS block's brace, not the first nested one", () => {
21
+ const css = `
22
+ .dark {
23
+ --outer: 1;
24
+ @media (min-width: 40rem) { --inner: 2; }
25
+ --after-nested: 3;
26
+ }
27
+ .light { --x: 9; }`;
28
+ const block = blockIn(css, ".dark");
29
+ expect(block, "a nested rule must not end the block early").toContain(
30
+ "--after-nested",
31
+ );
32
+ expect(block, "and must not swallow the following rule").not.toContain(
33
+ "--x: 9",
34
+ );
35
+ });
36
+
37
+ it("tolerates a missing space before the brace", () => {
38
+ expect(blockIn(".dark{ --x: 1; }", ".dark")).toContain("--x: 1");
39
+ });
40
+
41
+ it("throws on a missing or unterminated block rather than returning junk", () => {
42
+ expect(() => blockIn(".dark { --x: 1; }", ".nope")).toThrow(/missing/);
43
+ expect(() => blockIn(".dark { --x: 1;", ".dark")).toThrow(/unterminated/);
44
+ });
45
+ });
46
+
47
+ describe("hexRelativeLuminanceIn", () => {
48
+ it("reads #rgb, #rrggbb and #rrggbbaa alike", () => {
49
+ // #fff and #ffffff are the same color; the alpha byte does not change its
50
+ // luminance.
51
+ expect(hexRelativeLuminanceIn("--a: #fff;", "a")).toBeCloseTo(1, 10);
52
+ expect(hexRelativeLuminanceIn("--a: #ffffff;", "a")).toBeCloseTo(1, 10);
53
+ expect(hexRelativeLuminanceIn("--a: #ffffff80;", "a")).toBeCloseTo(1, 10);
54
+ expect(hexRelativeLuminanceIn("--a: #000;", "a")).toBeCloseTo(0, 10);
55
+ });
56
+
57
+ it("orders planes by perceived brightness, not by HSL lightness", () => {
58
+ const canvas = hexRelativeLuminanceIn("--a: #0a0a14;", "a");
59
+ const card = hexRelativeLuminanceIn("--a: #191826;", "a");
60
+ expect(card).toBeGreaterThan(canvas);
61
+ });
62
+
63
+ it("says a token is non-hex rather than claiming it is missing", () => {
64
+ expect(() => hexRelativeLuminanceIn("--a: var(--b);", "a")).toThrow(
65
+ /not a hex color/,
66
+ );
67
+ expect(() => hexRelativeLuminanceIn("--a: #fff;", "b")).toThrow(/missing/);
68
+ });
69
+ });
70
+
71
+ describe("hslIn", () => {
72
+ it("reads an `H S% L%` triple", () => {
73
+ expect(hslIn("--a: 240 33% 6%;", "a")).toEqual({ h: 240, s: 33, l: 6 });
74
+ });
75
+
76
+ it("throws when the slot is absent", () => {
77
+ expect(() => hslIn("--a: 240 33% 6%;", "b")).toThrow(/missing HSL slot/);
78
+ });
79
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Helpers for asserting on the stylesheets as source text.
3
+ *
4
+ * Deliberately NOT a `.test.ts` file: importing one test file from another makes
5
+ * Vitest register the imported file's top-level suites a second time, in the
6
+ * importing file's context.
7
+ */
8
+
9
+ /** Escape every regex metacharacter, so a selector is matched literally. */
10
+ function escapeRegex(literal: string): string {
11
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
+ }
13
+
14
+ /** Strip CSS comments, so a selector named in prose is never mistaken for a rule. */
15
+ function rulesOnly(css: string): string {
16
+ return css.replace(/\/\*[\s\S]*?\*\//g, "");
17
+ }
18
+
19
+ /** Extract a rule's body by selector, tolerant of formatting and of nesting. */
20
+ export function blockIn(source: string, selector: string): string {
21
+ const blocks = blocksIn(source, selector);
22
+ if (blocks.length === 0) throw new Error(`missing theme block: ${selector}`);
23
+ return blocks[0];
24
+ }
25
+
26
+ /**
27
+ * Every rule body whose selector list ENDS in `selector`, in source order — the
28
+ * match requires the selector immediately before the `{`, so a selector earlier
29
+ * in a comma-separated group (`selector, .other {`) is not returned.
30
+ *
31
+ * One selector can head MORE than one rule — `[data-theme="tangle-light"]` names
32
+ * both the shared light-status override group and the theme's own block — so a
33
+ * first-match read can silently slice the wrong region. Return them all and let
34
+ * the caller pick (e.g. the one that declares `--hsl-background`).
35
+ */
36
+ export function blocksIn(source: string, selector: string): string[] {
37
+ // Comments first: a block's own doc-comment routinely spells its selector out,
38
+ // and anchoring on that prose would slice the wrong region — silently, since
39
+ // the result still looks like CSS.
40
+ const css = rulesOnly(source);
41
+ // Tolerate any whitespace between selector and brace — a formatter that closes
42
+ // the gap must not turn a passing suite into a "missing block" error.
43
+ const pattern = new RegExp(`${escapeRegex(selector)}\\s*\\{`, "g");
44
+
45
+ const blocks: string[] = [];
46
+ for (const open of css.matchAll(pattern)) {
47
+ const start = open.index;
48
+
49
+ // Walk braces to find THIS block's close. Matching the first `}` would end the
50
+ // block at the first nested rule's close instead (an `@media`, a nested
51
+ // selector) — and the slice would still look like CSS, so assertions would run
52
+ // against a truncated region rather than failing. Depth-counting is exact, and
53
+ // an unterminated block throws instead of returning something plausible.
54
+ let depth = 0;
55
+ let closed = false;
56
+ for (let i = css.indexOf("{", start); i < css.length; i++) {
57
+ if (css[i] === "{") depth += 1;
58
+ else if (css[i] === "}") {
59
+ depth -= 1;
60
+ if (depth === 0) {
61
+ blocks.push(css.slice(start, i));
62
+ closed = true;
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ if (!closed) throw new Error(`unterminated theme block: ${selector}`);
68
+ }
69
+ return blocks;
70
+ }
71
+
72
+ /** Read a token's raw declaration value, whitespace-normalized. */
73
+ export function declIn(css: string, token: string): string {
74
+ const m = css.match(new RegExp(`--${token}:\\s*([^;]+)`));
75
+ if (!m) throw new Error(`missing token --${token}`);
76
+ return m[1].trim().replace(/\s+/g, " ");
77
+ }
78
+
79
+ /** Read an `H S% L%` channel triple. */
80
+ export function hslIn(
81
+ css: string,
82
+ token: string,
83
+ ): { h: number; s: number; l: number } {
84
+ const m = css.match(
85
+ new RegExp(
86
+ `--${token}:\\s*(\\d+(?:\\.\\d+)?)\\s+(\\d+(?:\\.\\d+)?)%\\s+(\\d+(?:\\.\\d+)?)%`,
87
+ ),
88
+ );
89
+ if (!m) throw new Error(`missing HSL slot --${token}`);
90
+ return { h: Number(m[1]), s: Number(m[2]), l: Number(m[3]) };
91
+ }
92
+
93
+ type Rgb = [number, number, number];
94
+
95
+ /**
96
+ * Read a hex surface token as RGB 0-255.
97
+ *
98
+ * Accepts `#rgb`, `#rrggbb` and `#rrggbbaa` (alpha is dropped — compositing is a
99
+ * separate question, answered by `compositeOver`). A token that exists but is not
100
+ * hex reports exactly that, rather than claiming it is missing.
101
+ */
102
+ export function hexIn(css: string, token: string): Rgb {
103
+ const declaration = css.match(new RegExp(`--${token}:\\s*([^;]+)`));
104
+ if (!declaration) throw new Error(`missing surface --${token}`);
105
+
106
+ const value = declaration[1].trim();
107
+ const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/.exec(value);
108
+ if (!hex) {
109
+ throw new Error(`--${token} is not a hex color (found "${value}")`);
110
+ }
111
+
112
+ const digits = hex[1];
113
+ // #rgb is shorthand for #rrggbb; #rrggbbaa carries an alpha byte we drop.
114
+ const pairs =
115
+ digits.length === 3
116
+ ? [...digits].map((d) => d + d)
117
+ : [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 6)];
118
+ return pairs.map((p) => Number.parseInt(p, 16)) as Rgb;
119
+ }
120
+
121
+ /** `H S% L%` → RGB 0-255. */
122
+ export function hslToRgb({
123
+ h,
124
+ s,
125
+ l,
126
+ }: {
127
+ h: number;
128
+ s: number;
129
+ l: number;
130
+ }): Rgb {
131
+ const sat = s / 100;
132
+ const lit = l / 100;
133
+ const c = (1 - Math.abs(2 * lit - 1)) * sat;
134
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
135
+ const m = lit - c / 2;
136
+ const [r, g, b] =
137
+ h < 60
138
+ ? [c, x, 0]
139
+ : h < 120
140
+ ? [x, c, 0]
141
+ : h < 180
142
+ ? [0, c, x]
143
+ : h < 240
144
+ ? [0, x, c]
145
+ : h < 300
146
+ ? [x, 0, c]
147
+ : [c, 0, x];
148
+ return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
149
+ }
150
+
151
+ function relativeLuminance([r, g, b]: Rgb): number {
152
+ const lin = (c: number) => {
153
+ const v = c / 255;
154
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
155
+ };
156
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
157
+ }
158
+
159
+ /**
160
+ * What a translucent foreground actually becomes once the surface behind it shows
161
+ * through. A `/70` utility does not render the token's color — it renders this.
162
+ */
163
+ export function compositeOver(fg: Rgb, bg: Rgb, alpha: number): Rgb {
164
+ return fg.map((c, i) => alpha * c + (1 - alpha) * bg[i]) as Rgb;
165
+ }
166
+
167
+ /** WCAG contrast ratio between two opaque colors. */
168
+ export function contrastRatio(a: Rgb, b: Rgb): number {
169
+ const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort(
170
+ (x, y) => y - x,
171
+ );
172
+ return (hi + 0.05) / (lo + 0.05);
173
+ }
174
+
175
+ /**
176
+ * Read a hex surface token as WCAG relative luminance (0-1).
177
+ *
178
+ * Relative luminance, not HSL lightness: the ladder varies hue and saturation as
179
+ * it rises, and HSL L is not perceptually uniform — two steps can share an L and
180
+ * still read as different brightnesses, so an "is each plane brighter than the one
181
+ * below" assertion made on HSL L would be measuring the wrong thing. Hex parsing
182
+ * (and its error reporting) is `hexIn`'s.
183
+ */
184
+ export function hexRelativeLuminanceIn(css: string, token: string): number {
185
+ return relativeLuminance(hexIn(css, token));
186
+ }
@@ -7,5 +7,5 @@
7
7
 
8
8
  @import "./tokens.css";
9
9
  @import "./theme.css";
10
- @import "./themes.css";
10
+ @import "./named-themes.css";
11
11
  @import "./globals.css";
@@ -2,7 +2,8 @@
2
2
  * components re-skin automatically because they reference the semantic tokens
3
3
  * (bg-card / border-border / surface-container, mapped in theme.css). Apply with
4
4
  * data-theme="<name>" on a wrapper (or <html>). The default :root / .dark spine
5
- * is the neutral "tangle" theme; "<name>-light" scopes are the light variants. */
5
+ * is the neutral "tangle" theme — also selectable by name as "tangle-dark";
6
+ * "<name>-light" scopes are the light variants. */
6
7
 
7
8
  /* Bridge — the actual re-skin mechanism. Tailwind utilities read the --color-*
8
9
  * layer (theme.css @theme: `bg-card` → var(--color-card) = hsl(var(--card))).
@@ -166,8 +167,42 @@
166
167
  --text-primary: #06231a; --text-secondary: #285048; --text-muted: #5f8472; --text-dim: #82a699;
167
168
  }
168
169
 
169
- /* ───────────────────────── Tangle (neutral) — light variant alias ─────────── */
170
- /* dark = default :root spine; this is its light mode for the showcase. */
170
+ /* ───────────────────────── Tangle (neutral) — the canonical pair ──────────── */
171
+ /* tangle-dark names the default :root/.dark spine explicitly — the values mirror
172
+ * tokens.css exactly (pinned in named-themes.test.ts). The named scope matters the
173
+ * moment the attribute would NOT resolve to :root: a dark island inside a
174
+ * light-named wrapper inherits the light --hsl-* without it, and a theme picker
175
+ * switching between named themes needs a `tangle-dark` that actually selects. Same
176
+ * indigo hue family as tangle-light (~239-246), held to the dark discipline —
177
+ * graphite-indigo neutrals, elevation by lightening, the fill indigo that carries
178
+ * white (7.4:1) with the bright indigo reserved for accent text. The foreground
179
+ * and destructive redeclares equal :root standalone, but the block must be
180
+ * self-sufficient: in the island nesting an omitted token inherits the WRAPPER's
181
+ * light value (near-black secondary text on the dark secondary measured ~1.5:1).
182
+ * Status tokens are deliberately not redeclared: the :root dark status palette is
183
+ * already dark-tuned, same as aubergine/arena. */
184
+ [data-theme="tangle-dark"] {
185
+ --hsl-background: 240 24% 11%; --hsl-foreground: 225 22% 91%;
186
+ --hsl-card: 238 20% 22%; --hsl-card-foreground: 225 22% 91%;
187
+ --hsl-popover: 240 22% 27%; --hsl-popover-foreground: 225 22% 91%;
188
+ --hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
189
+ --hsl-secondary: 240 22% 27%; --hsl-secondary-foreground: 225 22% 91%;
190
+ --hsl-muted: 240 22% 27%; --hsl-muted-foreground: 223 14% 69%;
191
+ --hsl-accent: 240 22% 27%; --hsl-accent-foreground: 225 22% 91%;
192
+ --hsl-destructive: 348 74% 48%; --hsl-destructive-foreground: 0 0% 100%;
193
+ --hsl-border: 236 20% 35%; --hsl-input: 240 24% 15%;
194
+ --hsl-ring: 233 100% 87%;
195
+ --depth-1: #151523; --depth-2: #2d2e43; --depth-3: #363654; --depth-4: #434365;
196
+ --bg-root: #151523; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
197
+ --md3-surface-container-lowest: #0f0f1a; --md3-surface-container-low: #222235;
198
+ --md3-surface-container: #2d2e43; --md3-surface-container-high: #363654;
199
+ --md3-surface-container-highest: #434365; --md3-outline-variant: #474a6b;
200
+ --border-subtle: rgba(165, 180, 252, 0.10); --border-default: rgba(165, 180, 252, 0.16);
201
+ --border-hover: rgba(165, 180, 252, 0.28); --accent-text: #A5B4FC;
202
+ --brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
203
+ --text-primary: #e3e6ed; --text-secondary: #bec3d0; --text-muted: #a5abbb; --text-dim: #9299ab;
204
+ }
205
+ /* Its light mode. */
171
206
  [data-theme="tangle-light"] {
172
207
  --hsl-background: 240 33% 98%; --hsl-foreground: 245 32% 14%;
173
208
  --hsl-card: 0 0% 100%; --hsl-card-foreground: 245 32% 14%;
@@ -187,3 +222,58 @@
187
222
  --brand-primary: #6366f1; --brand-glow: #818cf8;
188
223
  --text-primary: #15131f; --text-secondary: #3f3a52; --text-muted: #6a6680; --text-dim: #9490a6;
189
224
  }
225
+
226
+ /* ───────────────────── Intelligence — violet surface ramp ────────────────────
227
+ * A violet retint of the surface ladder (hue ~260-265, saturation tapering as
228
+ * lightness rises). The brand indigo primary / ring / accent-text are
229
+ * deliberately NOT overridden: the accent stays Tangle, only the surfaces carry
230
+ * the product's identity.
231
+ *
232
+ * Apply `data-theme="intelligence"` on <html> alongside the `.dark` class.
233
+ * The selector REQUIRES `.dark` rather than trusting the consumer to swap the
234
+ * attribute on every mode change: a violet dark ramp left standing over a light
235
+ * page would repaint every surface. Dark-only is the contract, so the selector —
236
+ * not the caller — enforces it. There is deliberately no `-light` variant;
237
+ * light falls through to brand's canonical light spine. */
238
+ .dark[data-theme="intelligence"] {
239
+ --md3-surface: #0d0a10;
240
+ --md3-surface-dim: #0a080d;
241
+ --md3-surface-bright: #2e2936;
242
+ --md3-surface-container-lowest: #070509;
243
+ --md3-surface-container-low: #100d15;
244
+ --md3-surface-container: #14111a;
245
+ --md3-surface-container-high: #1b1821;
246
+ --md3-surface-container-highest: #24202b;
247
+ --md3-surface-variant: #24202b;
248
+ --md3-on-surface-variant: #aaa6bb;
249
+
250
+ --hsl-background: 262 24% 5%;
251
+ --hsl-card: 262 19% 11%;
252
+ --hsl-popover: 260 16% 13%;
253
+ --hsl-secondary: 262 15% 18%;
254
+ --hsl-muted: 262 17% 15%;
255
+ --hsl-muted-foreground: 255 12% 62%;
256
+ --hsl-accent: 262 15% 20%;
257
+ --hsl-border: 262 12% 28%;
258
+ --hsl-input: 260 14% 16%;
259
+
260
+ /* seated a touch deeper than the canvas so the panel reads as a base plane */
261
+ --sidebar-background: 264 26% 4%;
262
+ --sidebar-accent: 262 16% 16%;
263
+ --sidebar-border: 262 12% 13%;
264
+
265
+ --depth-1: #0e0b13;
266
+ --depth-2: #16131c;
267
+ --depth-3: #201c28;
268
+ --depth-4: #2a2533;
269
+ --bg-elevated: hsl(262 17% 15%);
270
+ --bg-section: hsl(262 22% 7%);
271
+ --bg-root:
272
+ radial-gradient(ellipse 70% 50% at 20% 0%, rgba(129, 99, 241, 0.08), transparent),
273
+ radial-gradient(ellipse 50% 40% at 80% 10%, rgba(129, 140, 248, 0.06), transparent),
274
+ linear-gradient(180deg, var(--depth-1), #0d0a12 100%);
275
+
276
+ --border-subtle: rgba(120, 108, 158, 0.1);
277
+ --border-default: rgba(120, 108, 158, 0.18);
278
+ --border-hover: rgba(120, 108, 158, 0.28);
279
+ }