@tangle-network/brand 1.4.0 → 1.6.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.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4649d22: Replace the hard 2px indigo focus ring with one soft focus treatment for every control.
8
+
9
+ brand adds `--focus-border`, `--focus-halo`, `--focus-border-danger`, `--focus-halo-danger` and `--border-strong`. Each derives from the ring, danger-ink, border and foreground tokens, so every theme, named theme and light island resolves its own values. The focus border clears 3:1 against the field in every theme, which `focus.test.ts` enforces.
10
+
11
+ brand's `globals.css` also gives any control that styles no focus of its own (a hand-rolled button, link, radio card or tab) the same keyboard focus line and halo in the base layer, in place of the browser's blue outline. Component utilities still override it.
12
+
13
+ ui exports `focusField`, `focusFieldWithin`, `focusFieldInvalid`, `focusRing` and `focusRingInset` from `utils`, and the primitives use them:
14
+
15
+ - `Input`, `Textarea`, `SelectTrigger` and `TerminalInput` rest on the neutral `border-border` hairline instead of the muted-text `border-input`, darken slightly on hover, and on focus shift the border to `--focus-border` with a 3px `--focus-halo` ring. The `default` and `sandbox` variants now render the same field.
16
+ - `Button`, `Tabs`, `Switch`, `Badge`, `SegmentedControl`, the dialog and toast close buttons, the auth menu trigger, and the chat and file controls show a 1px focus line with the same halo on `:focus-visible` only. A mouse click no longer draws a ring.
17
+ - Interactive `Card`, tool-preview and code-block hovers darken the border instead of tinting it indigo.
18
+
19
+ A consumer that removed the old ring with `focus-visible:ring-0` on a field should pass `focus:ring-0` instead, because the field ring now keys off `:focus`.
20
+
21
+ ### Patch Changes
22
+
23
+ - 2909c24: `globals.css` gives a native text field that styles no focus of its own a fallback: a 1px `--focus-border` line over its border, in place of the browser's blue outline. It covers `input` (except button-like, checkbox, radio, range, colour, file and hidden types), `select` and `textarea`, on `:focus-visible`, in the base layer. Any outline utility on the field, such as `outline-none` or ui's `focusField`, still wins. The fallback has no halo, so a field that hands its focus to a container and clears its outline shows nothing extra.
24
+
25
+ ## 1.5.0
26
+
27
+ ### Minor Changes
28
+
29
+ - 532a6d8: Add `--shadow-overlay`, the elevation step above `--shadow-dropdown` for a surface that covers the page rather than sitting beside it — a drawer, a modal, a floating dock.
30
+
31
+ It is derived from `--hsl-foreground` rather than a fixed `rgba`, so it inverts with the theme: dark ink in light, a soft halo in dark. That is what makes it work on a dark canvas, where a black shadow renders as nothing. One declaration serves both themes, and the token is declared in the `@theme` block as well so Tailwind emits a `shadow-overlay` utility.
32
+
3
33
  ## 1.4.0
4
34
 
5
35
  ### Minor Changes
package/README.md CHANGED
@@ -55,6 +55,7 @@ import { Logo, TangleKnot } from "@tangle-network/brand";
55
55
  - **Depth stack** — `#0C0B1D` → `#262448` (dark); `#f4f4f9` → `#dcdbe8` (light)
56
56
  - **Status** — emerald / amber / coral for running / stopped / error
57
57
  - **Radii** — 6/8/12/16px (dark); 2/4/6/8px (light/"vault")
58
+ - **Focus** — `--focus-border` and `--focus-halo` (plus `-danger` variants) for a focused control, and `--border-strong` for a hovered field. They derive from the ring and border tokens, so every theme gets its own. `@tangle-network/ui` applies them through `focusField` and `focusRing`. `globals.css` gives a control or native text field that styles no focus of its own a fallback: a control gets the 1px line and halo on keyboard focus, and a text field gets the 1px line over its border.
58
59
 
59
60
  Light theme activates via `data-sandbox-theme="vault"` on a parent element.
60
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",
@@ -0,0 +1,102 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ blockIn,
6
+ blocksIn,
7
+ compositeOver,
8
+ contrastRatio,
9
+ hexIn,
10
+ hslIn,
11
+ hslToRgb,
12
+ } from "./css-test-utils";
13
+
14
+ /**
15
+ * The focus palette's contract.
16
+ *
17
+ * A focused field keeps a 1px border in --focus-border with a soft
18
+ * --focus-halo outside it. The halo is too faint to count as an indicator on
19
+ * its own, so the BORDER must clear the 3:1 non-text floor (WCAG 1.4.11)
20
+ * against the field it outlines, in every theme a consumer can select.
21
+ */
22
+ const read = (name: string) =>
23
+ readFileSync(path.resolve(import.meta.dirname, name), "utf8");
24
+ const tokens = read("tokens.css");
25
+ const themes = read("named-themes.css");
26
+
27
+ // The focus rule is the one whose selector list ends in `[data-theme]`.
28
+ const FOCUS = blockIn(tokens, "[data-theme]");
29
+ const DARK = blockIn(tokens, ".dark");
30
+ const LIGHT = blockIn(tokens, ".light");
31
+
32
+ function alphaOf(token: string, pattern: RegExp): number {
33
+ const m = FOCUS.match(pattern);
34
+ if (!m) throw new Error(`--${token} does not have the expected shape`);
35
+ return Number(m[1]) / (m[2] === "%" ? 100 : 1);
36
+ }
37
+ const borderAlpha = alphaOf(
38
+ "focus-border",
39
+ /--focus-border:\s*hsl\(var\(--hsl-ring\)\s*\/\s*([\d.]+)()\)/,
40
+ );
41
+ const dangerAlpha = alphaOf(
42
+ "focus-border-danger",
43
+ /--focus-border-danger:\s*color-mix\(in srgb, var\(--surface-danger-text\) ([\d.]+)(%), transparent\)/,
44
+ );
45
+
46
+ /** Reads a token from the first source that declares it, as the cascade would. */
47
+ function effective<T>(sources: string[], readToken: (css: string) => T): T {
48
+ for (const css of sources) {
49
+ try {
50
+ return readToken(css);
51
+ } catch {
52
+ // Not declared here; fall through to the scope it inherits from.
53
+ }
54
+ }
55
+ throw new Error("token resolves to nothing");
56
+ }
57
+
58
+ const lightStatus = blocksIn(themes, '[data-theme="tangle-light"]').join("\n");
59
+ const scopes: Array<{ name: string; sources: string[] }> = [
60
+ { name: "canonical dark", sources: [DARK] },
61
+ { name: "canonical light", sources: [LIGHT] },
62
+ ...["aubergine", "arena", "tangle-dark"].map((name) => ({
63
+ name,
64
+ sources: [...blocksIn(themes, `[data-theme="${name}"]`), DARK],
65
+ })),
66
+ ...["aubergine-light", "arena-light", "tangle-light"].map((name) => ({
67
+ name,
68
+ sources: [...blocksIn(themes, `[data-theme="${name}"]`), lightStatus, DARK],
69
+ })),
70
+ {
71
+ name: "intelligence",
72
+ sources: [blockIn(themes, '.dark[data-theme="intelligence"]'), DARK],
73
+ },
74
+ ];
75
+
76
+ describe("the focus palette resolves in every theme scope", () => {
77
+ it("is declared on each selector that can redefine the tokens it derives from", () => {
78
+ // A custom property that references another is resolved where it is
79
+ // declared. Declared on :root alone, a named theme on a descendant would
80
+ // inherit the root's focus colors instead of its own.
81
+ const rules = tokens.replace(/\/\*[\s\S]*?\*\//g, "");
82
+ const header = rules.match(/([^{}]*)\{\s*--focus-border:/)?.[1] ?? "";
83
+ for (const scope of [":root", ".dark", ".light", "[data-sandbox-ui]", "[data-sandbox-theme]", "[data-theme]"]) {
84
+ expect(header, `focus rule selector list`).toContain(scope);
85
+ }
86
+ });
87
+ });
88
+
89
+ describe("a focused field's border clears 3:1 against the field", () => {
90
+ for (const { name, sources } of scopes) {
91
+ it(name, () => {
92
+ const field = hslToRgb(effective(sources, (css) => hslIn(css, "hsl-card")));
93
+ const ring = hslToRgb(effective(sources, (css) => hslIn(css, "hsl-ring")));
94
+ const border = compositeOver(ring, field, borderAlpha);
95
+ expect(contrastRatio(border, field), "focus border on field").toBeGreaterThanOrEqual(3);
96
+
97
+ const danger = effective(sources, (css) => hexIn(css, "surface-danger-text"));
98
+ const dangerBorder = compositeOver(danger, field, dangerAlpha);
99
+ expect(contrastRatio(dangerBorder, field), "danger focus border on field").toBeGreaterThanOrEqual(3);
100
+ });
101
+ }
102
+ });
@@ -19,6 +19,61 @@
19
19
  "ss01" 1;
20
20
  }
21
21
 
22
+ /* The default keyboard focus for any control that does not style its own:
23
+ * the same 1px --focus-border line and soft halo as ui's `focusRing`, in
24
+ * place of the browser's blue outline. It sits in the base layer, so a
25
+ * component's own focus utilities always win over it. Text fields have
26
+ * their own fallback below. */
27
+ :where(
28
+ a[href],
29
+ button,
30
+ summary,
31
+ input:is([type="checkbox"], [type="radio"]),
32
+ [role="button"],
33
+ [role="checkbox"],
34
+ [role="link"],
35
+ [role="menuitem"],
36
+ [role="option"],
37
+ [role="radio"],
38
+ [role="switch"],
39
+ [role="tab"],
40
+ [tabindex]:not([tabindex="-1"])
41
+ ):focus-visible {
42
+ outline: 1px solid var(--focus-border);
43
+ outline-offset: 0;
44
+ box-shadow: 0 0 0 4px var(--focus-halo);
45
+ }
46
+
47
+ /* The fallback focus for a native text field that styles none: a 1px
48
+ * --focus-border line laid over the field's own border, in place of the
49
+ * browser's blue outline. It is an outline because any border-colour
50
+ * utility on the field outranks a base-layer border colour. It has no halo:
51
+ * a field that hands its focus to a container clears the line with
52
+ * `outline-none`, which a base-layer box-shadow would ignore. ui's
53
+ * `focusField` gives the full border and halo. */
54
+ :where(
55
+ input:not(
56
+ [type="button"],
57
+ [type="checkbox"],
58
+ [type="color"],
59
+ [type="file"],
60
+ [type="hidden"],
61
+ [type="image"],
62
+ [type="radio"],
63
+ [type="range"],
64
+ [type="reset"],
65
+ [type="submit"]
66
+ ),
67
+ select,
68
+ textarea
69
+ ):focus-visible {
70
+ outline: 1px solid var(--focus-border);
71
+ outline-offset: -1px;
72
+ /* A field with an explicit tabindex also matches the control rule above;
73
+ * drop that rule's halo. */
74
+ box-shadow: none;
75
+ }
76
+
22
77
  ::selection {
23
78
  background: var(--bg-selection);
24
79
  color: hsl(var(--hsl-foreground));
@@ -0,0 +1,86 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { blockIn } from "./css-test-utils";
5
+
6
+ /**
7
+ * A token declared in both files has to carry the same value in both.
8
+ *
9
+ * `tokens.css` is what a consumer reads at runtime through `var(--x)`.
10
+ * `theme.css` is the Tailwind v4 `@theme` block, and it exists because Tailwind
11
+ * only emits a utility for a token it can see declared there — so the shared
12
+ * ones are necessarily written twice.
13
+ *
14
+ * That duplication is structural and cannot be factored out: `@theme` needs a
15
+ * literal, and a self-reference would be circular. What it can be is GATED. An
16
+ * edit to one file that misses the other would leave `shadow-overlay` (the
17
+ * utility, from theme.css) rendering a different shadow from
18
+ * `shadow-[var(--shadow-overlay)]` (the variable, from tokens.css), and the
19
+ * mismatch would show up as an inconsistency no one could reproduce from
20
+ * either file alone.
21
+ */
22
+ const read = (name: string) =>
23
+ readFileSync(path.resolve(import.meta.dirname, name), "utf8");
24
+
25
+ /** `--token: value;` pairs in one CSS block, whitespace collapsed. */
26
+ function declarationsIn(css: string): Map<string, string> {
27
+ const out = new Map<string, string>();
28
+ for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
29
+ out.set(m[1] as string, (m[2] as string).split(/\s+/).join(" ").trim());
30
+ }
31
+ return out;
32
+ }
33
+
34
+ // The default spine, which is the one `@theme` mirrors. Anchor on the last
35
+ // selector of the multi-selector header, as the other suites here do.
36
+ const tokensCss = read("tokens.css");
37
+ const runtime = declarationsIn(blockIn(tokensCss, ".dark"));
38
+ const light = declarationsIn(blockIn(tokensCss, ".light"));
39
+ const theme = declarationsIn(blockIn(read("theme.css"), "@theme"));
40
+
41
+ const shared = [...theme.keys()].filter((t) => runtime.has(t)).sort();
42
+
43
+ describe("tokens.css and theme.css agree on every token they both declare", () => {
44
+ it("finds the shared tokens to check", () => {
45
+ // Guards the parse itself: a selector rename that silently emptied one
46
+ // block would otherwise make every assertion below vacuously pass.
47
+ expect(shared.length).toBeGreaterThan(5);
48
+ });
49
+
50
+ for (const token of shared) {
51
+ it(`${token} carries one value`, () => {
52
+ expect(theme.get(token)).toBe(runtime.get(token));
53
+ });
54
+ }
55
+ });
56
+
57
+ /**
58
+ * A token written in terms of `--hsl-foreground` already inverts, because that
59
+ * ramp is what each spine redefines. Restating it on the light spine would pin
60
+ * it to one theme's ink and defeat the derivation — the failure would be silent,
61
+ * since both declarations are individually valid CSS.
62
+ *
63
+ * So the omission is the contract, and this asserts it rather than trusting the
64
+ * comment that states it.
65
+ */
66
+ const selfInverting = [...runtime.entries()]
67
+ .filter(([, value]) => value.includes("var(--hsl-foreground)"))
68
+ .map(([token]) => token)
69
+ .sort();
70
+
71
+ describe("a self-inverting token is declared once, not per spine", () => {
72
+ it("finds the self-inverting tokens to check", () => {
73
+ expect(selfInverting.length).toBeGreaterThan(0);
74
+ // The assertions below are absence checks, so they hold trivially against
75
+ // an empty map. A selector rename that stopped matching the light spine
76
+ // would otherwise read as "nothing is restated" — the strongest possible
77
+ // pass from the weakest possible parse.
78
+ expect(light.size).toBeGreaterThan(5);
79
+ });
80
+
81
+ for (const token of selfInverting) {
82
+ it(`${token} is not restated on the light spine`, () => {
83
+ expect(light.has(token)).toBe(false);
84
+ });
85
+ }
86
+ });
@@ -104,4 +104,10 @@
104
104
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
105
105
  --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
106
106
  --shadow-glow: none;
107
+ /* Theme-derived, so it inverts — see tokens.css. Declared here as well so
108
+ Tailwind emits the `shadow-overlay` utility, which is how a consumer
109
+ reaches it without writing `shadow-[var(--shadow-overlay)]`. */
110
+ --shadow-overlay:
111
+ 0 2px 6px hsl(var(--hsl-foreground) / 0.1),
112
+ 0 16px 40px hsl(var(--hsl-foreground) / 0.16);
107
113
  }
@@ -319,6 +319,18 @@
319
319
  --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
320
320
  --shadow-glow: none;
321
321
  --shadow-accent: 0 1px 2px rgba(0, 0, 0, 0.3);
322
+ /* The lift above --shadow-dropdown, for a surface that covers the page
323
+ rather than sitting beside it: a drawer, a modal, a floating dock.
324
+ Two layers — a tight contact shadow plus a wide ambient one.
325
+ Alone among the shadows this is derived from --hsl-foreground instead of
326
+ a fixed rgba, so it INVERTS with the theme. That is what makes it work in
327
+ dark: a black shadow on a dark canvas renders as nothing (see the ladder
328
+ note at the top of this file), while the foreground is near-white there,
329
+ so the same declaration reads as a soft halo. One definition therefore
330
+ serves both themes and the light block does not restate it. */
331
+ --shadow-overlay:
332
+ 0 2px 6px hsl(var(--hsl-foreground) / 0.1),
333
+ 0 16px 40px hsl(var(--hsl-foreground) / 0.16);
322
334
  --shadow-status-running: none;
323
335
  --shadow-status-creating: none;
324
336
  --shadow-status-error: none;
@@ -669,3 +681,40 @@
669
681
  --radius-composer: 26px;
670
682
  }
671
683
 
684
+
685
+ /* Focus and field hover — the palette behind @tangle-network/ui's focus
686
+ * treatment (`focusField` / `focusRing` in ui's lib/focus.ts).
687
+ *
688
+ * A focused field keeps its 1px border and shifts it to --focus-border, with a
689
+ * 3px --focus-halo ring outside it. A focused button or tab draws a 1px
690
+ * --focus-border line with the same halo beyond it. Both render as box-shadow,
691
+ * so they follow border-radius and never move layout.
692
+ *
693
+ * The BORDER carries the contrast and the halo is decoration. --focus-border is
694
+ * the ring at 80%: 4.7:1 on the white light-mode field and 5.7:1 on the dark
695
+ * card, so the indicator clears the 3:1 non-text floor (WCAG 1.4.11) in every
696
+ * theme, while reading as a tint of the field instead of a hard indigo outline.
697
+ * An 18% halo sits near 1.3:1 against the page on purpose: it softens the edge
698
+ * and is never the only cue. Gated in focus.test.ts.
699
+ *
700
+ * Every value is derived from a spine token (--hsl-ring, --surface-danger-text,
701
+ * --hsl-border, --hsl-foreground), and a custom property that references
702
+ * another is resolved where it is declared. So this rule names every scope that
703
+ * can redefine those tokens: a named theme or a light island on a descendant
704
+ * gets its own focus colors instead of inheriting the root's. */
705
+ :root,
706
+ .dark,
707
+ .light,
708
+ [data-sandbox-ui],
709
+ [data-sandbox-theme],
710
+ [data-theme] {
711
+ --focus-border: hsl(var(--hsl-ring) / 0.8);
712
+ --focus-halo: hsl(var(--hsl-ring) / 0.18);
713
+ /* The danger INK, not the destructive fill: the fill is tuned to carry white
714
+ * text and measures about 2:1 on the dark card. */
715
+ --focus-border-danger: color-mix(in srgb, var(--surface-danger-text) 80%, transparent);
716
+ --focus-halo-danger: color-mix(in srgb, var(--surface-danger-text) 18%, transparent);
717
+ /* A resting hairline one step toward the ink, for hover: darker on light
718
+ * paper, lighter on a dark card, never tinted. */
719
+ --border-strong: color-mix(in srgb, hsl(var(--hsl-border)), hsl(var(--hsl-foreground)) 20%);
720
+ }